mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Add forward-simulating combat engine with consumables, monster abilities, and narrative rendering
Replaces the single-roll probability system for dungeon and arena combat with a multi-phase simulation engine where gear, buffs, pets, and NPCs are meaningful mechanical inputs. Adds consumable items (auto-crafted from forage/mine/fish drops), monster abilities for T2+ enemies, and Dragon Quest-style combat narrative with phased message delivery. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ type AdventurePlugin struct {
|
||||
arenaBailCh sync.Map // userID string -> chan struct{} (bail signal for countdown goroutine)
|
||||
shopSessions sync.Map // userID string -> *advShopSession
|
||||
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
|
||||
craftResults sync.Map // userID string -> []CraftResult (pending craft results for narrative)
|
||||
morningHour int
|
||||
summaryHour int
|
||||
}
|
||||
@@ -92,7 +93,8 @@ func (p *AdventurePlugin) chatLevel(userID id.UserID) int {
|
||||
return p.xp.GetLevel(userID)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) Name() string { return "adventure" }
|
||||
func (p *AdventurePlugin) Name() string { return "adventure" }
|
||||
func (p *AdventurePlugin) Version() string { return "2.5.0" }
|
||||
|
||||
// SetAchievements wires the achievements plugin after both are initialized.
|
||||
func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) {
|
||||
@@ -275,6 +277,7 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure respond`" + ` — Respond to a mid-day event
|
||||
` + "`!adventure rivals`" + ` — View rival duel records
|
||||
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
|
||||
` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
|
||||
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
|
||||
` + "`!adventure repair all`" + ` — Repair all damaged equipment
|
||||
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
|
||||
@@ -291,6 +294,10 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!arena status`" + ` — Current run state
|
||||
` + "`!arena leaderboard`" + ` — Top arena players
|
||||
|
||||
**Systems:**
|
||||
• Foraging level 10+ unlocks auto-crafting consumables from loot ingredients before combat.
|
||||
• A 5% community tax funds the lottery pot. Arena and hospital apply 10%.
|
||||
|
||||
**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`
|
||||
|
||||
// ── Command Handlers ─────────────────────────────────────────────────────────
|
||||
@@ -581,6 +588,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
return p.resolveShopCategoryChoice(ctx, interaction)
|
||||
case "shop_item":
|
||||
return p.resolveShopItemChoice(ctx, interaction)
|
||||
case "shop_supply":
|
||||
return p.resolveShopSupplyChoice(ctx, interaction)
|
||||
case "shop_confirm":
|
||||
return p.resolveShopConfirm(ctx, interaction)
|
||||
case "hospital_pay":
|
||||
@@ -804,8 +813,13 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %d minutes, or pick a different location.", loc.Name, m))
|
||||
}
|
||||
|
||||
// Resolve the action
|
||||
result := resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
|
||||
// Resolve the action — dungeon uses combat engine, others use probability bands
|
||||
var result *AdvActionResult
|
||||
if loc.Activity == AdvActivityDungeon {
|
||||
result = p.resolveDungeonAction(char, equip, loc, bonuses, inPenaltyZone)
|
||||
} else {
|
||||
result = resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
|
||||
}
|
||||
|
||||
// Select flavor text
|
||||
result.FlavorText, result.FlavorKey = p.selectFlavorText(char, result)
|
||||
@@ -836,42 +850,33 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
p.checkRivalPoolUnlock(char)
|
||||
}
|
||||
|
||||
engineDeathSaved := result.CombatLog != nil && checkDeathSaveEvent(result.CombatLog.Events)
|
||||
|
||||
// Handle death
|
||||
deathReprieved := false
|
||||
pardonFired := false
|
||||
petRecovered := false
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
// Chat level death pardon (does NOT apply in arena — arena uses separate code path)
|
||||
chatLvl := p.chatLevel(char.UserID)
|
||||
if chatLvl >= 20 && char.PardonAvailable() && rand.Float64() < 0.33 {
|
||||
pardonFired = true
|
||||
deathReprieved = true
|
||||
now := time.Now().UTC()
|
||||
char.LastPardonUsed = &now
|
||||
dt := transitionDeath(DeathTransitionParams{
|
||||
Char: char,
|
||||
Equip: equip,
|
||||
ChatLevel: p.chatLevel(char.UserID),
|
||||
Location: loc.Name,
|
||||
AllowPardon: true,
|
||||
AllowSovereign: true,
|
||||
EngineSaved: engineDeathSaved,
|
||||
})
|
||||
pardonFired = dt.Pardoned
|
||||
deathReprieved = dt.Pardoned || dt.Reprieved
|
||||
petRecovered = dt.PetRecovered
|
||||
if dt.Pardoned {
|
||||
result.Outcome = AdvOutcomeEmpty
|
||||
char.GrudgeLocation = loc.Name
|
||||
}
|
||||
if !pardonFired {
|
||||
// Sovereign set: Death's Reprieve — survive lethal outcome
|
||||
if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() {
|
||||
deathReprieved = true
|
||||
now := time.Now().UTC()
|
||||
char.DeathReprieveLast = &now
|
||||
char.GrudgeLocation = loc.Name
|
||||
// Gear absorbs the blow — all equipment set to 1 condition
|
||||
for _, slot := range allSlots {
|
||||
if eq, ok := equip[slot]; ok {
|
||||
eq.Condition = 1
|
||||
}
|
||||
}
|
||||
// Post room announcement
|
||||
nextWindow := now.Add(168 * time.Hour)
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow))
|
||||
}
|
||||
} else {
|
||||
char.Kill()
|
||||
char.GrudgeLocation = loc.Name
|
||||
if dt.Reprieved {
|
||||
nextWindow := time.Now().UTC().Add(168 * time.Hour)
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow))
|
||||
}
|
||||
}
|
||||
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
|
||||
@@ -884,6 +889,14 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
_ = addAdvInventoryItem(char.UserID, item)
|
||||
}
|
||||
|
||||
// Roll for consumable drop on success/exceptional at T2+
|
||||
if (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) && loc.Tier >= 2 {
|
||||
if drop := RollConsumableDrop(loc.Activity, loc.Tier); drop != nil {
|
||||
_ = addAdvInventoryItem(char.UserID, *drop)
|
||||
result.LootItems = append(result.LootItems, *drop)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark action consumed in the correct bucket
|
||||
if isCombatActivity(activity) {
|
||||
char.CombatActionsUsed++
|
||||
@@ -932,8 +945,8 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
|
||||
if partyBonus > 0 {
|
||||
result.TotalLootValue += partyBonus
|
||||
// Credit the bonus directly
|
||||
p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus")
|
||||
net, _ := communityTax(char.UserID, float64(partyBonus), 0.05)
|
||||
p.euro.Credit(char.UserID, net, "adventure_party_bonus")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -950,18 +963,38 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
if closing != "" {
|
||||
text += "\n" + closing
|
||||
}
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Quiet DM for death pardon
|
||||
if pardonFired {
|
||||
p.SendDM(ctx.Sender, "The healers got there in time. Barely. Don't make this a habit.")
|
||||
}
|
||||
// Dungeon combat: send phased combat messages with delays, then resolution DM
|
||||
if result.CombatLog != nil {
|
||||
phaseMessages := RenderCombatLog(*result.CombatLog, char.DisplayName, loc.Denizens)
|
||||
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
|
||||
done := p.sendCombatMessages(ctx.Sender, phaseMessages, text)
|
||||
|
||||
// Send hospital ad on death (delayed, arrives after resolution DM)
|
||||
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
go func() {
|
||||
<-done
|
||||
if pardonFired {
|
||||
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
|
||||
}
|
||||
if petRecovered && char.PetName != "" {
|
||||
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
|
||||
}
|
||||
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
if pardonFired {
|
||||
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
|
||||
}
|
||||
if petRecovered && char.PetName != "" {
|
||||
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
|
||||
}
|
||||
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for treasure drop
|
||||
|
||||
@@ -548,7 +548,6 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
|
||||
|
||||
switch outcome {
|
||||
case AdvOutcomeDeath:
|
||||
// All slots -20, weapon and armor -30 (additional)
|
||||
for _, slot := range allSlots {
|
||||
damage[slot] = 20
|
||||
}
|
||||
@@ -560,7 +559,6 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
|
||||
damage[SlotArmor] = 10
|
||||
|
||||
case AdvOutcomeEmpty:
|
||||
// Failed dungeon run
|
||||
damage[SlotWeapon] = 15
|
||||
damage[SlotArmor] = 10
|
||||
|
||||
@@ -572,32 +570,9 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
|
||||
damage[SlotBoots] = 20
|
||||
|
||||
case AdvOutcomeHornets:
|
||||
// No equipment damage — they don't care about your sword
|
||||
}
|
||||
|
||||
// Tempered set: Seasoned — condition degrades 25% slower (applied once per set)
|
||||
tempered := advEquippedArenaSets(equip)["tempered"]
|
||||
|
||||
// Apply damage and check for breaks
|
||||
for slot, dmg := range damage {
|
||||
eq, ok := equip[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tempered {
|
||||
dmg = int(float64(dmg) * 0.75)
|
||||
}
|
||||
// Equipment mastery: well-used gear degrades slower
|
||||
if eq.ActionsUsed >= 20 {
|
||||
dmg = int(float64(dmg) * 0.8)
|
||||
}
|
||||
eq.Condition -= dmg
|
||||
if eq.Condition < 0 {
|
||||
eq.Condition = 0
|
||||
}
|
||||
}
|
||||
|
||||
return damage
|
||||
return applyDegradationModifiers(damage, equip)
|
||||
}
|
||||
|
||||
// advCheckBrokenEquipment checks which slots hit 0 condition and reverts them to tier 0.
|
||||
@@ -645,6 +620,7 @@ type AdvActionResult struct {
|
||||
LootItems []AdvItem
|
||||
TotalLootValue int64
|
||||
XPGained int
|
||||
XPBreakdown string // human-readable bonus breakdown
|
||||
XPSkill string
|
||||
EquipDamage map[EquipmentSlot]int
|
||||
LeveledUp bool
|
||||
@@ -655,6 +631,7 @@ type AdvActionResult struct {
|
||||
EquipBroken []EquipmentSlot
|
||||
NearDeath bool
|
||||
StreakBonus int
|
||||
CombatLog *CombatResult
|
||||
}
|
||||
|
||||
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
|
||||
@@ -712,24 +689,15 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
|
||||
}
|
||||
}
|
||||
|
||||
// Near-death XP bonus
|
||||
if result.NearDeath {
|
||||
xp = int(float64(xp) * 1.15)
|
||||
}
|
||||
|
||||
// XP multiplier from bonuses
|
||||
if bonuses.XPMultiplier != 0 {
|
||||
xp = int(float64(xp) * (1 + bonuses.XPMultiplier/100))
|
||||
}
|
||||
// Ironclad set: Battle-Hardened — +5% XP gain
|
||||
if advEquippedArenaSets(equip)["ironclad"] {
|
||||
xp = int(float64(xp) * 1.05)
|
||||
}
|
||||
// Apply overlevel penalty to XP
|
||||
if overlevelMult < 1.0 {
|
||||
xp = max(1, int(float64(xp)*overlevelMult))
|
||||
}
|
||||
result.XPGained = xp
|
||||
xpResult := applyXPBonuses(XPBonusParams{
|
||||
BaseXP: xp,
|
||||
NearDeath: result.NearDeath,
|
||||
BonusMult: bonuses.XPMultiplier,
|
||||
Ironclad: advEquippedArenaSets(equip)["ironclad"],
|
||||
OverlevelMult: overlevelMult,
|
||||
})
|
||||
result.XPGained = xpResult.Total
|
||||
result.XPBreakdown = xpResult.Breakdown
|
||||
|
||||
// Equipment degradation on bad outcomes
|
||||
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
|
||||
|
||||
@@ -130,6 +130,9 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load character.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. The arena requires living participants.")
|
||||
}
|
||||
|
||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||
|
||||
@@ -139,57 +142,7 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "Arena data error. This shouldn't happen.")
|
||||
}
|
||||
|
||||
// NPC arena effects — sniper checked first (independent of combat roll)
|
||||
npcResult := npcCheckArenaEffects(char, monster.Name)
|
||||
|
||||
if npcResult != nil && npcResult.SniperKill {
|
||||
// Sniper fired — enemy dies, skip combat roll entirely
|
||||
combatLog := &ArenaCombatLog{PlayerHP: 100, EnemyHP: 0, PlayerWon: true}
|
||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||
}
|
||||
|
||||
// Pet combat actions
|
||||
petResult := petRollCombatActions(char, monster.Name)
|
||||
|
||||
// Normal combat roll — pet deflect reduces effective death chance
|
||||
deathChance := arenaDeathChance(monster, char, equip)
|
||||
if petResult != nil && petResult.Deflected {
|
||||
deathChance *= 0.5 // deflect halves death chance for this round
|
||||
}
|
||||
roll := rand.Float64()
|
||||
died := roll < deathChance
|
||||
|
||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||
combatLog := generateArenaCombatLog(!died, closeness)
|
||||
|
||||
// Append pet text to combat log
|
||||
var petText string
|
||||
if petResult != nil {
|
||||
if petResult.Attacked {
|
||||
petText += "\n\n" + petResult.AttackText
|
||||
}
|
||||
if petResult.Deflected {
|
||||
petText += "\n\n" + petResult.DeflectText
|
||||
}
|
||||
}
|
||||
|
||||
if died {
|
||||
if npcResult != nil && npcResult.Text != "" {
|
||||
combatLog.NPCText = npcResult.Text
|
||||
}
|
||||
combatLog.NPCText += petText
|
||||
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
|
||||
combatLog.NPCText += "\n\n" + petDeathText(char)
|
||||
}
|
||||
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
|
||||
}
|
||||
|
||||
combatLog.NPCText += petText
|
||||
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
|
||||
combatLog.NPCText += "\n\n" + petVictoryText(char)
|
||||
}
|
||||
|
||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||
return p.resolveArenaRound(ctx, run, char, equip, tier, monster)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
||||
@@ -231,52 +184,42 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
|
||||
npcResult := npcCheckArenaEffects(char, monster.Name)
|
||||
return p.resolveArenaRound(ctx, run, char, equip, tier, monster)
|
||||
}
|
||||
|
||||
if npcResult != nil && npcResult.SniperKill {
|
||||
combatLog := &ArenaCombatLog{PlayerHP: 100, EnemyHP: 0, PlayerWon: true}
|
||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||
}
|
||||
// resolveArenaRound runs a single arena round through the combat engine and
|
||||
// dispatches to survival or death handlers.
|
||||
func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error {
|
||||
result, condRepair := p.runArenaCombat(ctx.Sender, char, equip, monster, run.Round, run.Tier)
|
||||
|
||||
petResult := petRollCombatActions(char, monster.Name)
|
||||
|
||||
deathChance := arenaDeathChance(monster, char, equip)
|
||||
if petResult != nil && petResult.Deflected {
|
||||
deathChance *= 0.5
|
||||
}
|
||||
roll := rand.Float64()
|
||||
died := roll < deathChance
|
||||
|
||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||
combatLog := generateArenaCombatLog(!died, closeness)
|
||||
|
||||
var petText string
|
||||
if petResult != nil {
|
||||
if petResult.Attacked {
|
||||
petText += "\n\n" + petResult.AttackText
|
||||
}
|
||||
if petResult.Deflected {
|
||||
petText += "\n\n" + petResult.DeflectText
|
||||
// Apply event-based equipment degradation
|
||||
degradation := combatDegradation(result, equip)
|
||||
for slot, eq := range equip {
|
||||
if d, ok := degradation[slot]; ok && d > 0 {
|
||||
_ = saveAdvEquipment(ctx.Sender, eq)
|
||||
}
|
||||
}
|
||||
|
||||
if died {
|
||||
if npcResult != nil && npcResult.Text != "" {
|
||||
combatLog.NPCText = npcResult.Text
|
||||
deathSaved := checkDeathSaveEvent(result.Events)
|
||||
|
||||
if deathSaved {
|
||||
now := time.Now().UTC()
|
||||
char.DeathReprieveLast = &now
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("arena: failed to save character after reprieve", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
combatLog.NPCText += petText
|
||||
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
|
||||
combatLog.NPCText += "\n\n" + petDeathText(char)
|
||||
}
|
||||
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
|
||||
}
|
||||
|
||||
combatLog.NPCText += petText
|
||||
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
|
||||
combatLog.NPCText += "\n\n" + petVictoryText(char)
|
||||
if !result.PlayerWon {
|
||||
return p.resolveArenaDeath(ctx, run, char, tier, monster, result)
|
||||
}
|
||||
|
||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||
// Misty condition repair (post-combat)
|
||||
if condRepair > 0 {
|
||||
npcRepairMostDamaged(ctx.Sender, equip, condRepair)
|
||||
}
|
||||
|
||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, result)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
|
||||
@@ -323,7 +266,7 @@ func (p *AdventurePlugin) handleArenaStatus(ctx MessageContext) error {
|
||||
|
||||
run, err := loadActiveArenaRun(ctx.Sender)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "No active arena run. Start one with `!arena tier <1-5>`.")
|
||||
return p.SendDM(ctx.Sender, "No active arena run. Start one with `!arena`.")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, renderArenaStatus(run, char))
|
||||
@@ -355,7 +298,7 @@ func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
|
||||
var arenaStreakEuroMultiplier = [6]float64{0, 1.0, 1.5, 2.0, 2.75, 4.0} // index = tier
|
||||
var arenaStreakXPMultiplier = [6]float64{0, 1.0, 1.2, 1.5, 1.85, 2.5} // index = tiers won
|
||||
|
||||
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog, npcResult *npcArenaResult) error {
|
||||
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error {
|
||||
// Calculate reward — accumulate in tier earnings, not credited yet
|
||||
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
|
||||
run.TierEarnings += reward
|
||||
@@ -373,28 +316,19 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
}
|
||||
run.XPAccumulated += battleXP
|
||||
|
||||
// Build survival message
|
||||
var text string
|
||||
if npcResult != nil && npcResult.SniperKill {
|
||||
// Sniper killed the enemy — no combat, just the sniper line
|
||||
text = npcResult.Text + "\n\n"
|
||||
text += fmt.Sprintf("🏆 +%d XP | €%d earned\n", battleXP, reward)
|
||||
} else {
|
||||
closer := arenaWinCloser(monster.Name, len(combatLog.Rounds))
|
||||
text = renderArenaCombatLog(combatLog, monster, true, reward, battleXP, closer)
|
||||
// Render combat log as phased messages + final outcome
|
||||
phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name)
|
||||
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
|
||||
finalMessage := renderArenaCombatFinalMessage(result, monster, reward, battleXP, run.Round)
|
||||
|
||||
// Append NPC effects (Misty food/crowd)
|
||||
if npcResult != nil && npcResult.Text != "" {
|
||||
text += npcResult.Text
|
||||
if npcResult.CondRepair > 0 {
|
||||
npcRepairMostDamaged(ctx.Sender, equip, npcResult.CondRepair)
|
||||
}
|
||||
}
|
||||
// Suppress the "(at risk)" line on the tier-completing round — those earnings
|
||||
// are about to be locked in by the tier-complete branch below, so labelling
|
||||
// them as at-risk is stale the moment it's written.
|
||||
if run.Round < 4 {
|
||||
finalMessage += fmt.Sprintf("\nTier earnings: %s | Session total: %s (at risk)\n",
|
||||
fmtEuro(run.TierEarnings), fmtEuro(run.Earnings+run.TierEarnings))
|
||||
}
|
||||
|
||||
text += fmt.Sprintf("\nTier earnings: €%d | Session total: €%d (at risk)\n",
|
||||
run.TierEarnings, run.Earnings+run.TierEarnings)
|
||||
|
||||
// Achievement: first blood
|
||||
if run.RoundsSurvived == 1 && p.achievements != nil {
|
||||
p.achievements.GrantAchievement(ctx.Sender, "arena_first_blood")
|
||||
@@ -407,21 +341,18 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
|
||||
// Check if tier is complete (4 rounds)
|
||||
if run.Round >= 4 {
|
||||
// Add completion bonus to tier earnings
|
||||
run.TierEarnings += tier.CompletionBonus
|
||||
run.Round = 4
|
||||
|
||||
// Apply streak euro multiplier for this tier and add to session total
|
||||
tierRaw := run.TierEarnings
|
||||
run.TierEarnings += tier.CompletionBonus
|
||||
|
||||
multiplier := arenaStreakEuroMultiplier[run.Tier]
|
||||
run.Earnings += int64(float64(run.TierEarnings) * multiplier)
|
||||
tierRaw := run.TierEarnings
|
||||
run.TierEarnings = 0 // reset for next tier
|
||||
run.TierEarnings = 0
|
||||
|
||||
// Grant tier achievement
|
||||
p.grantArenaTierAchievement(ctx.Sender, run.Tier)
|
||||
|
||||
if run.Tier >= 5 {
|
||||
// Tier 5 complete — session ends, process full payout
|
||||
run.Status = "completed"
|
||||
now := time.Now().UTC()
|
||||
run.EndedAt = &now
|
||||
@@ -429,35 +360,33 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
slog.Error("arena: failed to save run after T5", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
text += fmt.Sprintf("\n🏆 **Tier %d cleared!** Completion bonus: €%d (×%.1f streak)\n",
|
||||
tier.Number, tierRaw, multiplier)
|
||||
finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: €%d + completion bonus: €%d (×%.1f streak)\n",
|
||||
tier.Number, tierRaw, tier.CompletionBonus, multiplier)
|
||||
|
||||
return p.arenaCompleteSession(ctx.Sender, run, char, text)
|
||||
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
<-done
|
||||
return p.arenaCompleteSession(ctx.Sender, run, char, "")
|
||||
}
|
||||
|
||||
// Tier complete — start 30-second countdown for next tier
|
||||
run.Status = "awaiting"
|
||||
if err := saveArenaRun(run); err != nil {
|
||||
slog.Error("arena: failed to save run after tier complete", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
text += fmt.Sprintf("\n🏆 **Tier %d cleared!** Completion bonus: €%d (×%.1f streak)\n"+
|
||||
"Session total: €%d (at risk)\n",
|
||||
tier.Number, tierRaw, multiplier, run.Earnings)
|
||||
finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: %s + completion bonus: %s (×%.1f streak)\n"+
|
||||
"Session total: %s (at risk)\n",
|
||||
tier.Number, fmtEuro(tierRaw), fmtEuro(tier.CompletionBonus), multiplier, fmtEuro(run.Earnings))
|
||||
|
||||
// Check for arena helmet drop (existing per-tier helmets)
|
||||
if dropped := p.arenaRollHelmetDrop(ctx.Sender, run.Tier); dropped != nil {
|
||||
text += "\n" + renderArenaHelmetDrop(dropped)
|
||||
finalMessage += "\n" + renderArenaHelmetDrop(dropped)
|
||||
p.postArenaDropAnnouncement(char.DisplayName, dropped)
|
||||
}
|
||||
|
||||
// Send the tier complete message, then start countdown
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("arena: failed to send tier complete DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Launch countdown goroutine
|
||||
go p.arenaCountdown(ctx.Sender, run)
|
||||
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
go func() {
|
||||
<-done
|
||||
p.arenaCountdown(ctx.Sender, run)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -467,172 +396,28 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
slog.Error("arena: failed to save run after round", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Reveal next monster
|
||||
nextMonster := arenaGetMonster(run.Tier, run.Round)
|
||||
if nextMonster != nil {
|
||||
text += fmt.Sprintf("\n\n─────────────────────────────\n\n")
|
||||
text += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name)
|
||||
text += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor)
|
||||
text += "`!arena fight` — Face this opponent"
|
||||
finalMessage += fmt.Sprintf("\n\n─────────────────────────────\n\n")
|
||||
finalMessage += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name)
|
||||
finalMessage += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor)
|
||||
finalMessage += "`!arena fight` — Face this opponent"
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog) error {
|
||||
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error {
|
||||
run.LastMonster = monster.Name
|
||||
|
||||
// Sovereign set: Death's Reprieve — survive lethal arena outcome
|
||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||
if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() {
|
||||
now := time.Now().UTC()
|
||||
char.DeathReprieveLast = &now
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("arena: failed to save character after reprieve", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Gear absorbs the blow — all equipment set to 1 condition
|
||||
for _, slot := range allSlots {
|
||||
if eq, ok := equip[slot]; ok {
|
||||
eq.Condition = 1
|
||||
saveAdvEquipment(ctx.Sender, eq)
|
||||
}
|
||||
}
|
||||
|
||||
// Run continues — not dead, streak preserved
|
||||
nextWindow := now.Add(168 * time.Hour)
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, monster.Name, nextWindow))
|
||||
}
|
||||
|
||||
closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds))
|
||||
text := renderArenaCombatLog(combatLog, monster, false, 0, 0, closer)
|
||||
text += fmt.Sprintf("\n💀→⚔️ **%s nearly killed you.**\n\n"+
|
||||
"Your Sovereign gear activated **Death's Reprieve**. You survived — barely.\n"+
|
||||
"All equipment set to 1 condition.\n\n"+
|
||||
"Next reprieve window: %s\n",
|
||||
monster.Name, nextWindow.Format("2006-01-02 15:04 UTC"))
|
||||
|
||||
run.RoundsSurvived++
|
||||
|
||||
// Check if this was round 4 — tier completion via reprieve
|
||||
if run.Round >= 4 {
|
||||
run.TierEarnings += tier.CompletionBonus
|
||||
run.Round = 4
|
||||
|
||||
// Apply streak multiplier
|
||||
multiplier := arenaStreakEuroMultiplier[run.Tier]
|
||||
run.Earnings += int64(float64(run.TierEarnings) * multiplier)
|
||||
tierRaw := run.TierEarnings
|
||||
run.TierEarnings = 0
|
||||
|
||||
p.grantArenaTierAchievement(ctx.Sender, run.Tier)
|
||||
|
||||
if run.Tier >= 5 {
|
||||
run.Status = "completed"
|
||||
endNow := time.Now().UTC()
|
||||
run.EndedAt = &endNow
|
||||
if err := saveArenaRun(run); err != nil {
|
||||
slog.Error("arena: failed to save run after reprieve T5", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
text += fmt.Sprintf("\n🏆 **Tier %d cleared!** Completion bonus: €%d (×%.1f streak)\n",
|
||||
tier.Number, tierRaw, multiplier)
|
||||
return p.arenaCompleteSession(ctx.Sender, run, char, text)
|
||||
}
|
||||
|
||||
// Tier complete — start countdown
|
||||
run.Status = "awaiting"
|
||||
if err := saveArenaRun(run); err != nil {
|
||||
slog.Error("arena: failed to save run after reprieve tier complete", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
text += fmt.Sprintf("\n🏆 **Tier %d cleared!** Completion bonus: €%d (×%.1f streak)\nSession total: €%d (at risk)\n",
|
||||
tier.Number, tierRaw, multiplier, run.Earnings)
|
||||
|
||||
if dropped := p.arenaRollHelmetDrop(ctx.Sender, run.Tier); dropped != nil {
|
||||
text += "\n" + renderArenaHelmetDrop(dropped)
|
||||
p.postArenaDropAnnouncement(char.DisplayName, dropped)
|
||||
}
|
||||
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("arena: failed to send reprieve tier complete DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
go p.arenaCountdown(ctx.Sender, run)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Not round 4 — advance to next round
|
||||
run.Round++
|
||||
if err := saveArenaRun(run); err != nil {
|
||||
slog.Error("arena: failed to save run after reprieve", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
text += fmt.Sprintf("\nSession earnings: €%d (still at risk)\n", run.Earnings+run.TierEarnings)
|
||||
|
||||
nextMonster := arenaGetMonster(run.Tier, run.Round)
|
||||
if nextMonster != nil {
|
||||
text += fmt.Sprintf("\n─────────────────────────────\n\n")
|
||||
text += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name)
|
||||
text += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor)
|
||||
text += "`!arena fight` — Face this opponent"
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// ── Pet ditch recovery — reduced death penalty ──
|
||||
petRecovery := petRollDitchRecovery(char)
|
||||
if petRecovery {
|
||||
// Pet intervenes — player still dies but respawn timer is reduced
|
||||
char.Kill()
|
||||
if char.DeadUntil != nil {
|
||||
reduced := time.Now().UTC().Add(petDitchRecoveryTime(char.PetLevel))
|
||||
char.DeadUntil = &reduced
|
||||
}
|
||||
|
||||
char.ArenaLosses++
|
||||
char.CombatXP += arenaParticipationXP
|
||||
if leveled, _ := checkAdvLevelUp(char, "combat"); leveled {
|
||||
p.checkRivalPoolUnlock(char)
|
||||
}
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("arena: failed to save after pet ditch recovery", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
run.Status = "dead"
|
||||
run.Earnings = 0
|
||||
run.TierEarnings = 0
|
||||
run.XPAccumulated = 0
|
||||
endNow := time.Now().UTC()
|
||||
run.EndedAt = &endNow
|
||||
_ = saveArenaRun(run)
|
||||
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
|
||||
upsertArenaStats(run.UserID, 0, true, run.Tier)
|
||||
|
||||
closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds))
|
||||
text := renderArenaCombatLog(combatLog, monster, false, 0, arenaParticipationXP, closer)
|
||||
text += "\n\n_oof_"
|
||||
|
||||
_ = p.SendDM(ctx.Sender, text)
|
||||
|
||||
// Game room posts
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
_ = p.SendMessage(gr, petDitchRecoveryGameRoom(char.DisplayName, char.PetName, true))
|
||||
}
|
||||
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Actual death — forfeit all session rewards ──
|
||||
lostEarnings := run.Earnings + run.TierEarnings
|
||||
|
||||
char.Kill()
|
||||
now := time.Now().UTC()
|
||||
phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name)
|
||||
|
||||
dt := transitionDeath(DeathTransitionParams{Char: char})
|
||||
|
||||
char.ArenaLosses++
|
||||
char.CombatXP += arenaParticipationXP // +60 flat participation XP
|
||||
char.CombatXP += arenaParticipationXP
|
||||
if leveled, _ := checkAdvLevelUp(char, "combat"); leveled {
|
||||
p.checkRivalPoolUnlock(char)
|
||||
}
|
||||
@@ -640,7 +425,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
||||
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// End the run — everything forfeited
|
||||
now := time.Now().UTC()
|
||||
run.Status = "dead"
|
||||
run.Earnings = 0
|
||||
run.TierEarnings = 0
|
||||
@@ -649,32 +434,35 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
||||
if err := saveArenaRun(run); err != nil {
|
||||
slog.Error("arena: failed to end arena run", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Insert history
|
||||
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
|
||||
|
||||
// Update stats
|
||||
upsertArenaStats(run.UserID, 0, true, run.Tier)
|
||||
|
||||
// Achievement: death in T5
|
||||
if run.Tier == 5 && p.achievements != nil {
|
||||
p.achievements.GrantAchievement(ctx.Sender, "arena_death_t5")
|
||||
finalMsg := renderArenaCombatFinalMessage(result, monster, 0, arenaParticipationXP, run.Round)
|
||||
if dt.PetRecovered {
|
||||
finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.")
|
||||
} else {
|
||||
if run.Tier == 5 && p.achievements != nil {
|
||||
p.achievements.GrantAchievement(ctx.Sender, "arena_death_t5")
|
||||
}
|
||||
finalMsg += fmt.Sprintf("\nYou died in Tier %d. Everything you were carrying goes with you. The arena keeps its own ledger.\n", run.Tier)
|
||||
if lostEarnings > 0 {
|
||||
finalMsg += fmt.Sprintf("Forfeited: %s\n", fmtEuro(lostEarnings))
|
||||
}
|
||||
}
|
||||
|
||||
// Death message per spec
|
||||
closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds))
|
||||
text := renderArenaCombatLog(combatLog, monster, false, 0, arenaParticipationXP, closer)
|
||||
text += fmt.Sprintf("\nYou died in Tier %d. Everything you were carrying goes with you. The arena keeps its own ledger.\n", run.Tier)
|
||||
if lostEarnings > 0 {
|
||||
text += fmt.Sprintf("Forfeited: €%d\n", lostEarnings)
|
||||
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMsg)
|
||||
|
||||
if dt.PetRecovered {
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
_ = p.SendMessage(gr, petDitchRecoveryGameRoom(char.DisplayName, char.PetName, true))
|
||||
}
|
||||
}
|
||||
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("arena: failed to send death DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Send hospital ad (delayed)
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
go func() {
|
||||
<-done
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -691,8 +479,14 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
}
|
||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
||||
|
||||
// Credit euros
|
||||
p.euro.Credit(userID, float64(run.Earnings), "arena_streak_payout")
|
||||
// Arena tax: 10% of earnings to community pot.
|
||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||
arenaNet := run.Earnings - arenaTax
|
||||
if arenaTax > 0 {
|
||||
communityPotAdd(int(arenaTax))
|
||||
trackTaxPaid(userID, int(arenaTax))
|
||||
}
|
||||
p.euro.Credit(userID, float64(arenaNet), "arena_streak_payout")
|
||||
|
||||
// Credit XP
|
||||
char.CombatXP += totalXP
|
||||
@@ -763,9 +557,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
}
|
||||
|
||||
// Build payout summary DM
|
||||
text := prefixText + "\n\n"
|
||||
text := ""
|
||||
if prefixText != "" {
|
||||
text = prefixText + "\n\n"
|
||||
}
|
||||
text += fmt.Sprintf("⚔️ **Arena Session Complete — %d tiers cleared**\n\n", tiersWon)
|
||||
text += fmt.Sprintf("Euros: +€%d\n", run.Earnings)
|
||||
text += fmt.Sprintf("Euros: +%s (%s after 10%% arena tax → community pot)\n", fmtEuro(run.Earnings), fmtEuro(arenaNet))
|
||||
text += fmt.Sprintf("XP: +%d (%.1f× streak bonus)\n", totalXP, xpMult)
|
||||
if helmText != "" {
|
||||
text += fmt.Sprintf("Helm drop: %s\n", helmText)
|
||||
@@ -1266,8 +1063,8 @@ func (p *AdventurePlugin) arenaRollHelmetDrop(userID id.UserID, tier int) *Arena
|
||||
}
|
||||
|
||||
helmet, hasHelmet := equip[SlotHelmet]
|
||||
if hasHelmet && helmet.ArenaTier >= tier {
|
||||
// Already has same or better arena helmet — silent discard
|
||||
if hasHelmet && (helmet.ArenaTier >= tier || helmet.Tier > tier) {
|
||||
// Already has same-or-better arena helmet, or a higher-tier normal helmet — discard
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,389 +3,8 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── Combat Log Types ───────────────────────────────────────────────────────
|
||||
|
||||
type ArenaCombatLog struct {
|
||||
Rounds []ArenaCombatRound
|
||||
PlayerHP int
|
||||
EnemyHP int
|
||||
PlayerWon bool
|
||||
NPCText string // Misty/Arina effect text, appended to round log
|
||||
}
|
||||
|
||||
type ArenaCombatRound struct {
|
||||
Number int
|
||||
Text string // action description with damage filled in
|
||||
Type string // "player_hit", "enemy_hit", "block", "environmental"
|
||||
DamageToPlayer int
|
||||
DamageToEnemy int
|
||||
PlayerHP int // HP after this round
|
||||
EnemyHP int // HP after this round
|
||||
}
|
||||
|
||||
// ── Combat Log Generation ──────────────────────────────────────────────────
|
||||
|
||||
// generateArenaCombatLog assembles a turn-by-turn narrative for a fight whose
|
||||
// outcome is already determined. The log is cosmetic — the roll already happened.
|
||||
// closeness is 0.0 (decisive) to 1.0 (razor-thin margin).
|
||||
func generateArenaCombatLog(playerWon bool, closeness float64) *ArenaCombatLog {
|
||||
// Pick HP pools
|
||||
playerHP := 60 + rand.IntN(41) // 60-100
|
||||
enemyHP := 60 + rand.IntN(41) // 60-100
|
||||
|
||||
// Determine round count: decisive=3-4, close=5-6
|
||||
numRounds := 3
|
||||
if closeness > 0.7 {
|
||||
numRounds = 5 + rand.IntN(2) // 5-6
|
||||
} else if closeness > 0.4 {
|
||||
numRounds = 4 + rand.IntN(2) // 4-5
|
||||
} else {
|
||||
numRounds = 3 + rand.IntN(2) // 3-4
|
||||
}
|
||||
|
||||
// Assign round types
|
||||
types := assignRoundTypes(numRounds, playerWon)
|
||||
|
||||
// Calculate damage distribution
|
||||
picker := newActionPicker()
|
||||
rounds := distributeDamage(types, playerHP, enemyHP, playerWon, picker)
|
||||
|
||||
return &ArenaCombatLog{
|
||||
Rounds: rounds,
|
||||
PlayerHP: playerHP,
|
||||
EnemyHP: enemyHP,
|
||||
PlayerWon: playerWon,
|
||||
}
|
||||
}
|
||||
|
||||
// assignRoundTypes determines what happens each round.
|
||||
// Final round is always winner hitting — this is enforced.
|
||||
// Guarantees at least 1 hit round per side.
|
||||
func assignRoundTypes(numRounds int, playerWon bool) []string {
|
||||
types := make([]string, numRounds)
|
||||
|
||||
// Final round: winner lands the killing blow
|
||||
if playerWon {
|
||||
types[numRounds-1] = "player_hit"
|
||||
} else {
|
||||
types[numRounds-1] = "enemy_hit"
|
||||
}
|
||||
|
||||
// Fill remaining rounds
|
||||
for i := 0; i < numRounds-1; i++ {
|
||||
roll := rand.Float64()
|
||||
switch {
|
||||
case roll < 0.15:
|
||||
types[i] = "environmental"
|
||||
case roll < 0.35:
|
||||
types[i] = "block"
|
||||
case roll < 0.65:
|
||||
if i%2 == 0 {
|
||||
types[i] = "enemy_hit"
|
||||
} else {
|
||||
types[i] = "player_hit"
|
||||
}
|
||||
default:
|
||||
if i%2 == 0 {
|
||||
types[i] = "player_hit"
|
||||
} else {
|
||||
types[i] = "enemy_hit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee at least 1 hit round per side (besides the final round).
|
||||
hasPlayerHit := false
|
||||
hasEnemyHit := false
|
||||
for _, t := range types {
|
||||
if t == "player_hit" {
|
||||
hasPlayerHit = true
|
||||
}
|
||||
if t == "enemy_hit" || t == "environmental" {
|
||||
hasEnemyHit = true
|
||||
}
|
||||
}
|
||||
// If missing a side, convert the first block round (or first non-final round).
|
||||
if !hasPlayerHit {
|
||||
for i := 0; i < numRounds-1; i++ {
|
||||
if types[i] == "block" || types[i] == "enemy_hit" || types[i] == "environmental" {
|
||||
types[i] = "player_hit"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasEnemyHit {
|
||||
for i := 0; i < numRounds-1; i++ {
|
||||
if types[i] == "block" || types[i] == "player_hit" {
|
||||
types[i] = "enemy_hit"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return types
|
||||
}
|
||||
|
||||
// distributeDamage creates rounds with damage values that sum correctly.
|
||||
func distributeDamage(types []string, playerHP, enemyHP int, playerWon bool, picker *actionPicker) []ArenaCombatRound {
|
||||
numRounds := len(types)
|
||||
|
||||
// Total damage dealt: winner kills the loser (deals their full HP).
|
||||
// Loser deals some but not all of winner's HP.
|
||||
var totalDmgToEnemy, totalDmgToPlayer int
|
||||
if playerWon {
|
||||
totalDmgToEnemy = enemyHP
|
||||
totalDmgToPlayer = int(float64(playerHP) * (0.3 + rand.Float64()*0.5)) // 30-80% of player HP
|
||||
} else {
|
||||
totalDmgToPlayer = playerHP
|
||||
totalDmgToEnemy = int(float64(enemyHP) * (0.3 + rand.Float64()*0.5))
|
||||
}
|
||||
|
||||
// Count damage rounds for each side
|
||||
var playerHitRounds, enemyHitRounds []int
|
||||
for i, t := range types {
|
||||
switch t {
|
||||
case "player_hit":
|
||||
playerHitRounds = append(playerHitRounds, i)
|
||||
case "enemy_hit":
|
||||
enemyHitRounds = append(enemyHitRounds, i)
|
||||
case "environmental":
|
||||
enemyHitRounds = append(enemyHitRounds, i) // environmental damages player
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute damage to enemy across player_hit rounds
|
||||
enemyDmgPerRound := splitDamage(totalDmgToEnemy, len(playerHitRounds))
|
||||
// Distribute damage to player across enemy_hit + environmental rounds
|
||||
playerDmgPerRound := splitDamage(totalDmgToPlayer, len(enemyHitRounds))
|
||||
|
||||
// Build rounds
|
||||
rounds := make([]ArenaCombatRound, numRounds)
|
||||
currentPlayerHP := playerHP
|
||||
currentEnemyHP := enemyHP
|
||||
playerDmgIdx := 0
|
||||
enemyDmgIdx := 0
|
||||
|
||||
for i, t := range types {
|
||||
r := ArenaCombatRound{
|
||||
Number: i + 1,
|
||||
Type: t,
|
||||
}
|
||||
|
||||
switch t {
|
||||
case "player_hit":
|
||||
dmg := 0
|
||||
if enemyDmgIdx < len(enemyDmgPerRound) {
|
||||
dmg = enemyDmgPerRound[enemyDmgIdx]
|
||||
enemyDmgIdx++
|
||||
}
|
||||
r.DamageToEnemy = dmg
|
||||
currentEnemyHP -= dmg
|
||||
if currentEnemyHP < 0 {
|
||||
currentEnemyHP = 0
|
||||
}
|
||||
r.Text = pickFrom(arenaPlayerHitActions, picker.player, dmg)
|
||||
|
||||
case "enemy_hit":
|
||||
dmg := 0
|
||||
if playerDmgIdx < len(playerDmgPerRound) {
|
||||
dmg = playerDmgPerRound[playerDmgIdx]
|
||||
playerDmgIdx++
|
||||
}
|
||||
r.DamageToPlayer = dmg
|
||||
currentPlayerHP -= dmg
|
||||
if currentPlayerHP < 0 {
|
||||
currentPlayerHP = 0
|
||||
}
|
||||
// Mix in player-miss actions (~30% of enemy_hit rounds)
|
||||
if rand.IntN(100) < 30 {
|
||||
r.Text = pickFrom(arenaPlayerMissActions, picker.playerMiss, dmg)
|
||||
} else {
|
||||
r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg)
|
||||
}
|
||||
|
||||
case "block":
|
||||
r.Text = pickFromNoFmt(arenaBlockActions, picker.block)
|
||||
|
||||
case "environmental":
|
||||
dmg := 0
|
||||
if playerDmgIdx < len(playerDmgPerRound) {
|
||||
dmg = playerDmgPerRound[playerDmgIdx]
|
||||
playerDmgIdx++
|
||||
}
|
||||
r.DamageToPlayer = dmg
|
||||
currentPlayerHP -= dmg
|
||||
if currentPlayerHP < 0 {
|
||||
currentPlayerHP = 0
|
||||
}
|
||||
r.Text = pickFrom(arenaEnvironmentalActions, picker.environment, dmg)
|
||||
}
|
||||
|
||||
r.PlayerHP = currentPlayerHP
|
||||
r.EnemyHP = currentEnemyHP
|
||||
rounds[i] = r
|
||||
}
|
||||
|
||||
// Ensure final round ends at exactly 0 for the loser
|
||||
last := &rounds[numRounds-1]
|
||||
if playerWon {
|
||||
last.EnemyHP = 0
|
||||
} else {
|
||||
last.PlayerHP = 0
|
||||
}
|
||||
|
||||
return rounds
|
||||
}
|
||||
|
||||
// splitDamage distributes total damage across n rounds with some variance.
|
||||
// Each round gets at least 1 damage. If total < n, excess rounds get 0.
|
||||
func splitDamage(total, n int) []int {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
if n == 1 {
|
||||
return []int{total}
|
||||
}
|
||||
|
||||
result := make([]int, n)
|
||||
|
||||
// If total < n, give 1 to the first `total` rounds, 0 to the rest.
|
||||
if total <= n {
|
||||
for i := 0; i < total && i < n; i++ {
|
||||
result[i] = 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
remaining := total
|
||||
|
||||
for i := 0; i < n-1; i++ {
|
||||
avg := remaining / (n - i)
|
||||
if avg <= 0 {
|
||||
avg = 1
|
||||
}
|
||||
// Variance: 50%-150% of average
|
||||
lo := avg / 2
|
||||
if lo < 1 {
|
||||
lo = 1
|
||||
}
|
||||
hi := avg + avg/2
|
||||
if hi < lo {
|
||||
hi = lo
|
||||
}
|
||||
dmg := lo + rand.IntN(hi-lo+1)
|
||||
// Reserve at least 1 per remaining round
|
||||
maxThisRound := remaining - (n - 1 - i)
|
||||
if maxThisRound < 1 {
|
||||
maxThisRound = 1
|
||||
}
|
||||
if dmg > maxThisRound {
|
||||
dmg = maxThisRound
|
||||
}
|
||||
if dmg < 1 {
|
||||
dmg = 1
|
||||
}
|
||||
result[i] = dmg
|
||||
remaining -= dmg
|
||||
}
|
||||
result[n-1] = remaining
|
||||
if result[n-1] < 1 {
|
||||
result[n-1] = 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// actionPicker tracks used indices per pool to avoid repeats within a fight.
|
||||
type actionPicker struct {
|
||||
enemy map[int]bool
|
||||
player map[int]bool
|
||||
playerMiss map[int]bool
|
||||
block map[int]bool
|
||||
environment map[int]bool
|
||||
}
|
||||
|
||||
func newActionPicker() *actionPicker {
|
||||
return &actionPicker{
|
||||
enemy: make(map[int]bool),
|
||||
player: make(map[int]bool),
|
||||
playerMiss: make(map[int]bool),
|
||||
block: make(map[int]bool),
|
||||
environment: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// pickFrom selects a random unused entry from pool, formats it with damage, and marks it used.
|
||||
// Resets if pool is exhausted.
|
||||
func pickFrom(pool []string, used map[int]bool, damage int) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return fmt.Sprintf(pool[idx], damage)
|
||||
}
|
||||
|
||||
func pickFromNoFmt(pool []string, used map[int]bool) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return pool[idx]
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaCombatLog(log *ArenaCombatLog, monster *ArenaMonster, won bool, reward int64, xp int, closerLine string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for _, r := range log.Rounds {
|
||||
sb.WriteString(r.Text + "\n")
|
||||
|
||||
// Compact HP status line — damage is already in the action text via %d
|
||||
switch r.Type {
|
||||
case "player_hit":
|
||||
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
|
||||
case "enemy_hit", "environmental":
|
||||
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
|
||||
}
|
||||
// Blocks: no HP line (no damage happened)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
if won {
|
||||
sb.WriteString(fmt.Sprintf("💀 %s has been defeated.\n", monster.Name))
|
||||
sb.WriteString(closerLine + "\n")
|
||||
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned\n", xp, reward))
|
||||
} else {
|
||||
sb.WriteString("The healers are already moving.\n")
|
||||
sb.WriteString("💀 Defeated.\n")
|
||||
sb.WriteString(closerLine + "\n")
|
||||
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.\n", arenaParticipationXP))
|
||||
}
|
||||
|
||||
if log.NPCText != "" {
|
||||
sb.WriteString(log.NPCText)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
const arenaParticipationXP = 60
|
||||
|
||||
// ── Closer Lines ───────────────────────────────────────────────────────────
|
||||
@@ -415,96 +34,3 @@ func arenaLoseCloser(winnerName string, lastRound int) string {
|
||||
}
|
||||
return closers[rand.IntN(len(closers))]
|
||||
}
|
||||
|
||||
// ── Action Pools ───────────────────────────────────────────────────────────
|
||||
|
||||
// Enemy actions — hit the player. %d is damage.
|
||||
var arenaEnemyActions = []string{
|
||||
"The enemy insults your clothing choices. Spot-on. Hits you for %d emotional damage. They weren't wrong about the boots. They do not go with that top on this planet nor any other.",
|
||||
"The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity of the move hurts more than the hit itself. %d damage.",
|
||||
"The enemy questions your life choices. You pause to genuinely reflect. They hit you during the pause. %d damage.",
|
||||
"The enemy delivers a full monologue. You listen to the whole thing. It was actually pretty good. %d damage from the time lost.",
|
||||
"The enemy compliments you unexpectedly. You thank them. They snicker because you actually believed them and revealed to everyone that you're somehow a bigger buffoon than previously known. %d damage.",
|
||||
"The enemy points at something behind you. You don't fall for it. They throw a projectile which bounces off the wall and hits you in the back of the head. What an amazing trick shot. %d damage. The crowd roars in laughter at the spectacle. But mostly at you.",
|
||||
"The enemy pulls out their phone and starts filming. You perform for the camera. This was a mistake. %d damage.",
|
||||
"The enemy sneezes directly in your face. You lose your turn being disgusted. %d damage while you process this.",
|
||||
"The enemy whispers something. You lean in to hear it. %d damage. There was nothing worth hearing.",
|
||||
"The enemy trips. Recovers. Hits you anyway. %d damage. You were rooting for them for a second there.",
|
||||
"The enemy takes a phone call, hits you one-handed, and continues the call. %d damage. You were barely a distraction.",
|
||||
"The enemy critiques your fighting stance in detail. You correct it instinctively. Your corrected stance is worse. %d damage.",
|
||||
"The enemy yawns mid-fight. Not performatively. Genuinely. %d damage while you process the disrespect.",
|
||||
"The enemy pauses to stretch before attacking. You wait. You don't know why you waited. %d damage when they finish.",
|
||||
"The enemy hits you with the flat of their blade. A choice. A message. %d damage. The message is received.",
|
||||
"The enemy stares at you for an uncomfortably long time before attacking. You break eye contact first. This was the plan. %d damage.",
|
||||
"The enemy sighs before hitting you. Like they had somewhere better to be. %d damage.",
|
||||
"The enemy recounts a mildly interesting story mid-fight. You get drawn in. %d damage before the ending, which was not worth it.",
|
||||
"The enemy raises one eyebrow at you and then attacks. The eyebrow did more damage than the hit. %d damage total.",
|
||||
"The enemy adjusts their grip, rolls their shoulders, and hits you with what is technically the bare minimum of effort. %d damage. You gave it everything. They did not.",
|
||||
}
|
||||
|
||||
// Player actions — hit the enemy. %d is damage to enemy.
|
||||
var arenaPlayerHitActions = []string{
|
||||
"You make a joke using a painfully dated reference. While the enemy stands there pondering what on earth you could possibly be referring to, you seize the opportunity and land a critical hit. %d damage. Your jokes are always great at leaving people dazed and confused.",
|
||||
"You attempt a battle cry. It comes out as a question. The enemy is briefly confused. You hit them for %d damage before they recover.",
|
||||
"You wind up for a big hit and connect for %d damage. You pulled something. The enemy doesn't know this yet.",
|
||||
"You hit the enemy for %d damage. They seem fine. You are less fine about this than they are.",
|
||||
"You connect cleanly for %d damage and immediately look at your hand like you're surprised it worked. You were.",
|
||||
"You score a clean hit for %d damage and immediately start explaining to no one in particular how you did that. Nobody asked. The fight is still happening.",
|
||||
"You land a hit for %d damage and follow up with a second strike that connects with nothing. You style it out. Nobody is convinced.",
|
||||
}
|
||||
|
||||
// Player actions — player's turn goes wrong. %d is damage to player.
|
||||
var arenaPlayerMissActions = []string{
|
||||
"You reach for your weapon and grab the wrong item. You are holding a receipt. The enemy hits you for %d damage. You find this receipt later and it's actually useful.",
|
||||
"You make prolonged eye contact with a spectator. It goes on too long. The enemy hits you for %d damage. The spectator looks away first.",
|
||||
"Your shoelace comes untied. You are wearing boots. You address this. The enemy does not wait. %d damage.",
|
||||
"You sneeze at a critical moment. The enemy respectfully waits. Then hits you for %d damage. There was no respect involved actually.",
|
||||
"You perform a move you saw in a film once. It does not work like in the film. %d damage. The physics were always wrong in that film.",
|
||||
"You get distracted by a food vendor passing the arena perimeter. So does the enemy. You recover second. %d damage.",
|
||||
"You attempt to intimidate the enemy. They laugh. Genuinely. This is worse than if they hadn't. You take %d damage from the experience.",
|
||||
"You slip on something. There is nothing to slip on. %d damage. The arena floor is flat and dry. You will be thinking about this.",
|
||||
"You decide mid-swing to do something different. The original plan was better. %d damage.",
|
||||
"You attempt a combo you've been mentally rehearsing for weeks. It goes fine until the third move. %d damage.",
|
||||
"You feint left. The enemy doesn't move. You feint right. They still don't move. You just stand there feinting at someone who is not playing along. The enemy hits you. %d damage.",
|
||||
"You remember reading something about fighting once. You implement it. It was about chess. %d damage.",
|
||||
"You close your eyes for the strike because it feels more dramatic. You miss. The enemy doesn't. %d damage.",
|
||||
"You decide this is the moment for something new. It is not the moment for something new. %d damage. File this under lessons.",
|
||||
}
|
||||
|
||||
// Block/dodge actions — no damage.
|
||||
var arenaBlockActions = []string{
|
||||
"You swing with conviction. The enemy sidesteps it with the energy of someone who has somewhere else to be. Nothing happens. You both reset.",
|
||||
"The enemy lunges. You step aside. They continue past you for several feet and have to walk back. The pause is awkward for everyone.",
|
||||
"You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally. You don't know their relationship so it probably did, but also you were faster.",
|
||||
"The enemy's attack grazes you but doesn't connect. They seem more annoyed by this than you are relieved.",
|
||||
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was. Then the fight continues.",
|
||||
"The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked. You will be thinking about how unnecessary it was.",
|
||||
"You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it. It was still a good parry.",
|
||||
"The enemy blocks your hilariously poor-timed strike with their forearm. This speaks less about the strength of their forearm and much more so about the pathetic nature of your striking abilities.",
|
||||
"You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The enemy didn't do that. The pillar gets no credit either.",
|
||||
"The enemy telegraphs the attack so clearly that you block it before they've finished committing to it. They look briefly embarrassed. They recover. The fight continues.",
|
||||
"You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional. The enemy hesitates, which was also not intentional. Nothing lands.",
|
||||
"The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena. They retrieve a backup weapon from somewhere. Nobody asks where they got it.",
|
||||
"You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other. Someone has to move first. It's them. The fight continues.",
|
||||
"The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects. You land. The fight continues.",
|
||||
"You sidestep a strike that wasn't aimed at you. The enemy had already redirected. You both end up slightly confused about where the other one is. The round resolves without damage.",
|
||||
"A referee walks through the arena on the way to somewhere else. Eye contact is made with both fighters. They keep walking. There is a beat. The fight resumes.",
|
||||
}
|
||||
|
||||
// Environmental actions — damage to player. %d is damage.
|
||||
var arenaEnvironmentalActions = []string{
|
||||
"Your mother calls in the middle of battle asking when you're giving her grandchildren. The enemy hits you for %d damage while you work out how to answer that on speakerphone.",
|
||||
"A bird lands between you and the enemy. Both combatants stop. The bird leaves. The enemy recovers first. %d damage.",
|
||||
"A spectator in the front row is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.",
|
||||
"The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage. The announcer mispronounces it again.",
|
||||
"An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. Mutual acknowledgment. The enemy hits you for %d damage during this social transaction.",
|
||||
"Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.",
|
||||
"A cloud passes in front of the sun at the wrong moment. %d damage. The cloud did not mean anything by it.",
|
||||
"The arena's background music cuts out unexpectedly. The silence is louder than the fight. The enemy hits you for %d damage in the disorientation.",
|
||||
"The arena PA crackles and announces something completely unrelated to your fight. You both look up. The enemy looks back down first. %d damage.",
|
||||
"Something falls from the spectator area. Nobody claims it. You both look at it. The enemy decides faster. %d damage.",
|
||||
"A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. You both needed that break more than you'd like to admit. The enemy uses the reset better. %d damage.",
|
||||
"The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round trying to work out if that changes anything. It does not. %d damage while you calculate.",
|
||||
"The arena sells a limited merch item at exactly this moment. The announcement is enthusiastic. You are briefly curious. %d damage.",
|
||||
"The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear from the enemy's corner. %d damage.",
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type ArenaMonster struct {
|
||||
Flavor string
|
||||
BaseLethality float64
|
||||
ThreatLevel int
|
||||
Ability *MonsterAbility // nil = no special ability
|
||||
}
|
||||
|
||||
var arenaTiers = [5]ArenaTier{
|
||||
@@ -75,6 +76,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Armored Disagreement",
|
||||
Flavor: "A dark knight who has made peace with violence as a communication strategy. Extensively equipped.",
|
||||
BaseLethality: 0.65, ThreatLevel: 30,
|
||||
Ability: &MonsterAbility{Name: "Shield Bash", Phase: "clash", ProcChance: 0.20, Effect: "stun"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -92,6 +94,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Wyrm of Moderate Ambition",
|
||||
Flavor: "Aspires to be a world-ending dragon. Currently a regional threat at best. Very sensitive about this.",
|
||||
BaseLethality: 0.65, ThreatLevel: 42,
|
||||
Ability: &MonsterAbility{Name: "Venom Breath", Phase: "clash", ProcChance: 0.30, Effect: "poison"},
|
||||
},
|
||||
{
|
||||
Name: "Behemoth Adjacent",
|
||||
@@ -102,6 +105,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "The Inevitable",
|
||||
Flavor: "A reaper-class entity. No grievances. No agenda. Simply the direction all things are heading.",
|
||||
BaseLethality: 0.80, ThreatLevel: 55,
|
||||
Ability: &MonsterAbility{Name: "Soul Drain", Phase: "clash", ProcChance: 0.25, Effect: "lifesteal"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -114,6 +118,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "The Watcher in the Peripheral",
|
||||
Flavor: "Seventeen eyes. None of them blink at the same time. Has been observing you specifically for longer than you've been alive.",
|
||||
BaseLethality: 0.72, ThreatLevel: 62,
|
||||
Ability: &MonsterAbility{Name: "Gaze of Unmaking", Phase: "opening", ProcChance: 0.35, Effect: "armor_break"},
|
||||
},
|
||||
{
|
||||
Name: "Herald of the Outer Dark",
|
||||
@@ -124,11 +129,13 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Lich Adjacent",
|
||||
Flavor: "Not the Lich King. Definitely not. Unrelated individual. Happens to be a skeletal sorcerer of immense power. Coincidence.",
|
||||
BaseLethality: 0.87, ThreatLevel: 78,
|
||||
Ability: &MonsterAbility{Name: "Necrotic Siphon", Phase: "any", ProcChance: 0.30, Effect: "lifesteal"},
|
||||
},
|
||||
{
|
||||
Name: "The Collector of Faces",
|
||||
Flavor: "It has yours already. Has had it for some time. The fight is a formality at this point.",
|
||||
BaseLethality: 0.92, ThreatLevel: 88,
|
||||
Ability: &MonsterAbility{Name: "Harvest", Phase: "clash", ProcChance: 0.35, Effect: "cleave"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -141,21 +148,25 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Omega Mk. Zero",
|
||||
Flavor: "A machine built to be the final test. Has never lost. Is aware of this.",
|
||||
BaseLethality: 0.85, ThreatLevel: 95,
|
||||
Ability: &MonsterAbility{Name: "System Override", Phase: "opening", ProcChance: 0.40, Effect: "armor_break"},
|
||||
},
|
||||
{
|
||||
Name: "The Calamity That Dreamed It Was Sleeping",
|
||||
Flavor: "An ancient parasitic entity that fell from the sky an indeterminate number of years ago and has been ending timelines since. Definitely not Lavos.",
|
||||
BaseLethality: 0.90, ThreatLevel: 105,
|
||||
Ability: &MonsterAbility{Name: "Temporal Drain", Phase: "any", ProcChance: 0.35, Effect: "lifesteal"},
|
||||
},
|
||||
{
|
||||
Name: "The Architect of Endings",
|
||||
Flavor: "A god who decided the world was a failed experiment and appointed itself project manager of its destruction. Silver hair. Long coat. Personal.",
|
||||
BaseLethality: 0.95, ThreatLevel: 115,
|
||||
Ability: &MonsterAbility{Name: "Deconstruct", Phase: "clash", ProcChance: 0.40, Effect: "cleave"},
|
||||
},
|
||||
{
|
||||
Name: "That Which Has Always Been",
|
||||
Flavor: "Pre-dates language. Pre-dates light. The Arena was built around it, not the other way around. Winning this fight is not something the game's designers fully accounted for.",
|
||||
BaseLethality: 0.98, ThreatLevel: 130,
|
||||
Ability: &MonsterAbility{Name: "Primordial Wrath", Phase: "decisive", ProcChance: 0.50, Effect: "enrage"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -217,7 +217,7 @@ func renderArenaHelmetDrop(gear *ArenaGearSet) string {
|
||||
|
||||
switch gear.SetKey {
|
||||
case "bloodied":
|
||||
b.WriteString("Set bonus: **Survivor's Instinct** — +3% to all activity success rates.")
|
||||
b.WriteString("Set bonus: **Survivor's Instinct** — +3% to all activity success rates, +3% critical hit rate in combat.")
|
||||
case "ironclad":
|
||||
b.WriteString("Set bonus: **Battle-Hardened** — +5% XP gain from all activities.")
|
||||
case "tempered":
|
||||
|
||||
481
internal/plugin/adventure_consumables.go
Normal file
481
internal/plugin/adventure_consumables.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Consumable Definitions ───────────────────────────────────────────────────
|
||||
// Items that are auto-consumed during combat. Players don't choose when to use
|
||||
// them — the engine selects up to 2 (1 offensive + 1 defensive) based on threat
|
||||
// assessment to avoid wasting items on trivial fights.
|
||||
|
||||
type ConsumableEffect string
|
||||
|
||||
const (
|
||||
EffectHeal ConsumableEffect = "heal"
|
||||
EffectDefBoost ConsumableEffect = "def_boost"
|
||||
EffectAtkBoost ConsumableEffect = "atk_boost"
|
||||
EffectWard ConsumableEffect = "ward"
|
||||
EffectSpeedBoost ConsumableEffect = "speed_boost"
|
||||
EffectCritBoost ConsumableEffect = "crit_boost"
|
||||
EffectFlatDmg ConsumableEffect = "flat_dmg"
|
||||
EffectSpore ConsumableEffect = "spore"
|
||||
EffectReflect ConsumableEffect = "reflect"
|
||||
EffectAutoCrit ConsumableEffect = "auto_crit"
|
||||
)
|
||||
|
||||
type ConsumableDef struct {
|
||||
Name string
|
||||
Effect ConsumableEffect
|
||||
Value float64 // meaning depends on effect
|
||||
Tier int
|
||||
Buyable bool
|
||||
Price int64
|
||||
Slot string // "offensive" or "defensive"
|
||||
}
|
||||
|
||||
// ConsumableItem is an AdvItem that has consumable properties.
|
||||
type ConsumableItem struct {
|
||||
InventoryID int64
|
||||
Def *ConsumableDef
|
||||
}
|
||||
|
||||
var consumableDefs = []ConsumableDef{
|
||||
// T1
|
||||
{Name: "Berry Poultice", Effect: EffectHeal, Value: 12, Tier: 1, Buyable: true, Price: 400, Slot: "defensive"},
|
||||
// T2 — drop only (forage, mine, dungeon)
|
||||
{Name: "Herb Salve", Effect: EffectHeal, Value: 20, Tier: 2, Slot: "defensive"},
|
||||
{Name: "Mushroom Brew", Effect: EffectDefBoost, Value: 0.20, Tier: 2, Slot: "defensive"},
|
||||
{Name: "Coal Bomb", Effect: EffectFlatDmg, Value: 8, Tier: 2, Slot: "offensive"},
|
||||
// T3 — drop only
|
||||
{Name: "Goblin Grease", Effect: EffectAtkBoost, Value: 0.25, Tier: 3, Slot: "offensive"},
|
||||
{Name: "Quartz Ward", Effect: EffectWard, Value: 1, Tier: 3, Slot: "defensive"},
|
||||
{Name: "Blooper Ink Vial", Effect: EffectSpeedBoost, Value: 0.30, Tier: 3, Slot: "offensive"},
|
||||
{Name: "Spore Cloud", Effect: EffectSpore, Value: 2, Tier: 3, Slot: "defensive"},
|
||||
// T4
|
||||
{Name: "Spirit Tonic", Effect: EffectHeal, Value: 40, Tier: 4, Buyable: false, Slot: "defensive"},
|
||||
{Name: "Sapphire Elixir", Effect: EffectCritBoost, Value: 0.15, Tier: 4, Buyable: false, Slot: "offensive"},
|
||||
// T5
|
||||
{Name: "Ancient Artifact Oil", Effect: EffectAutoCrit, Value: 0.35, Tier: 5, Buyable: false, Slot: "offensive"},
|
||||
{Name: "Voidstone Shard", Effect: EffectReflect, Value: 0.50, Tier: 5, Buyable: false, Slot: "defensive"},
|
||||
}
|
||||
|
||||
// consumableDefByName returns the definition for a consumable by name.
|
||||
func consumableDefByName(name string) *ConsumableDef {
|
||||
for i := range consumableDefs {
|
||||
if consumableDefs[i].Name == name {
|
||||
return &consumableDefs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Threat Assessment ────────────────────────────────────────────────────────
|
||||
|
||||
type threatLevel int
|
||||
|
||||
const (
|
||||
threatTrivial threatLevel = iota // < 0.4
|
||||
threatEasy // 0.4–0.7
|
||||
threatCompetitive // 0.7–1.0
|
||||
threatDangerous // > 1.0
|
||||
)
|
||||
|
||||
func assessThreat(player, enemy CombatStats) threatLevel {
|
||||
playerPower := float64(player.MaxHP + player.Attack*3)
|
||||
enemyPower := float64(enemy.MaxHP + enemy.Attack*3)
|
||||
if playerPower == 0 {
|
||||
return threatDangerous
|
||||
}
|
||||
ratio := enemyPower / playerPower
|
||||
switch {
|
||||
case ratio < 0.4:
|
||||
return threatTrivial
|
||||
case ratio < 0.7:
|
||||
return threatEasy
|
||||
case ratio < 1.0:
|
||||
return threatCompetitive
|
||||
default:
|
||||
return threatDangerous
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection Logic ──────────────────────────────────────────────────────────
|
||||
|
||||
// SelectConsumables picks up to 2 items (1 offensive + 1 defensive) from inventory.
|
||||
// contentTier caps consumable tier to the content being fought (0 = no cap).
|
||||
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem {
|
||||
threat := assessThreat(playerStats, enemyStats)
|
||||
if threat == threatTrivial {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxTier := maxConsumableTier(threat, arenaRound)
|
||||
if contentTier > 0 && contentTier < maxTier {
|
||||
maxTier = contentTier
|
||||
}
|
||||
|
||||
var bestOffensive, bestDefensive *ConsumableItem
|
||||
|
||||
for i := range inventory {
|
||||
item := &inventory[i]
|
||||
if item.Def.Tier > maxTier {
|
||||
continue
|
||||
}
|
||||
|
||||
switch item.Def.Slot {
|
||||
case "offensive":
|
||||
if bestOffensive == nil || betterOffensive(item, bestOffensive, threat) {
|
||||
bestOffensive = item
|
||||
}
|
||||
case "defensive":
|
||||
if bestDefensive == nil || betterDefensive(item, bestDefensive, threat, playerStats, enemyStats) {
|
||||
bestDefensive = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selected []ConsumableItem
|
||||
if bestOffensive != nil {
|
||||
selected = append(selected, *bestOffensive)
|
||||
}
|
||||
if bestDefensive != nil {
|
||||
selected = append(selected, *bestDefensive)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func maxConsumableTier(threat threatLevel, arenaRound int) int {
|
||||
base := 1
|
||||
switch threat {
|
||||
case threatEasy:
|
||||
base = 2
|
||||
case threatCompetitive:
|
||||
base = 3
|
||||
case threatDangerous:
|
||||
base = 5
|
||||
}
|
||||
// Arena: spend freely on later rounds
|
||||
if arenaRound >= 3 {
|
||||
base = min(5, base+1)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// betterOffensive prefers lowest tier that's appropriate for the threat.
|
||||
func betterOffensive(candidate, current *ConsumableItem, threat threatLevel) bool {
|
||||
if threat == threatDangerous {
|
||||
return candidate.Def.Tier > current.Def.Tier
|
||||
}
|
||||
return candidate.Def.Tier < current.Def.Tier
|
||||
}
|
||||
|
||||
// betterDefensive picks heal items for longer fights, wards for burst threats.
|
||||
func betterDefensive(candidate, current *ConsumableItem, threat threatLevel, player, enemy CombatStats) bool {
|
||||
if threat == threatDangerous {
|
||||
return candidate.Def.Tier > current.Def.Tier
|
||||
}
|
||||
// Prefer wards against high-attack enemies
|
||||
if enemy.Attack > player.MaxHP/4 && candidate.Def.Effect == EffectWard {
|
||||
return true
|
||||
}
|
||||
return candidate.Def.Tier < current.Def.Tier
|
||||
}
|
||||
|
||||
// ── Modifier Application ─────────────────────────────────────────────────────
|
||||
|
||||
// consumableDropTable maps activity types to consumable names available at each tier.
|
||||
// Drop chance is 15% per activity completion at T2+.
|
||||
var consumableDropTable = map[AdvActivityType]map[int][]string{
|
||||
AdvActivityForaging: {
|
||||
2: {"Herb Salve"},
|
||||
3: {"Spore Cloud"},
|
||||
4: {"Spirit Tonic"},
|
||||
5: {"Spirit Tonic", "Voidstone Shard"},
|
||||
},
|
||||
AdvActivityMining: {
|
||||
2: {"Coal Bomb"},
|
||||
3: {"Quartz Ward"},
|
||||
4: {"Sapphire Elixir"},
|
||||
5: {"Voidstone Shard"},
|
||||
},
|
||||
AdvActivityFishing: {
|
||||
2: {"Herb Salve"},
|
||||
3: {"Blooper Ink Vial"},
|
||||
4: {"Blooper Ink Vial", "Spirit Tonic"},
|
||||
5: {"Blooper Ink Vial", "Voidstone Shard"},
|
||||
},
|
||||
AdvActivityDungeon: {
|
||||
2: {"Herb Salve", "Coal Bomb"},
|
||||
3: {"Goblin Grease", "Quartz Ward"},
|
||||
4: {"Sapphire Elixir", "Spirit Tonic"},
|
||||
5: {"Ancient Artifact Oil", "Voidstone Shard"},
|
||||
},
|
||||
}
|
||||
|
||||
// RollConsumableDrop checks whether a consumable item drops from an activity.
|
||||
// Returns a consumable AdvItem or nil.
|
||||
func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
|
||||
actTable, ok := consumableDropTable[activity]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
names, ok := actTable[tier]
|
||||
if !ok || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
if rand.Float64() >= 0.15 {
|
||||
return nil
|
||||
}
|
||||
name := names[rand.IntN(len(names))]
|
||||
def := consumableDefByName(name)
|
||||
if def == nil {
|
||||
return nil
|
||||
}
|
||||
sellValue := def.Price / 2
|
||||
if sellValue == 0 {
|
||||
tierSellValues := map[int]int64{1: 200, 2: 600, 3: 1500, 4: 3000, 5: 6000}
|
||||
sellValue = tierSellValues[def.Tier]
|
||||
}
|
||||
return &AdvItem{
|
||||
Name: def.Name,
|
||||
Type: "consumable",
|
||||
Tier: def.Tier,
|
||||
Value: sellValue,
|
||||
}
|
||||
}
|
||||
|
||||
// BuyableConsumables returns all consumable defs that can be purchased from the shop.
|
||||
func BuyableConsumables() []ConsumableDef {
|
||||
var result []ConsumableDef
|
||||
for _, def := range consumableDefs {
|
||||
if def.Buyable {
|
||||
result = append(result, def)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ApplyConsumableMods adjusts the CombatStats and CombatModifiers for selected consumables.
|
||||
func ApplyConsumableMods(stats *CombatStats, mods *CombatModifiers, items []ConsumableItem) {
|
||||
for _, item := range items {
|
||||
switch item.Def.Effect {
|
||||
case EffectHeal:
|
||||
mods.HealItem = int(item.Def.Value)
|
||||
case EffectDefBoost:
|
||||
mods.DamageReduct *= (1 - item.Def.Value) // 0.20 → 0.80x damage taken
|
||||
case EffectAtkBoost:
|
||||
mods.DamageBonus += item.Def.Value
|
||||
case EffectWard:
|
||||
mods.WardCharges = int(item.Def.Value)
|
||||
case EffectSpeedBoost:
|
||||
stats.Speed = int(float64(stats.Speed) * (1 + item.Def.Value))
|
||||
case EffectCritBoost:
|
||||
stats.CritRate += item.Def.Value
|
||||
case EffectFlatDmg:
|
||||
mods.FlatDmgStart = int(item.Def.Value)
|
||||
case EffectSpore:
|
||||
mods.SporeCloud = int(item.Def.Value)
|
||||
case EffectReflect:
|
||||
mods.ReflectNext = item.Def.Value
|
||||
case EffectAutoCrit:
|
||||
mods.AutoCritFirst = true
|
||||
mods.DamageBonus += item.Def.Value // +35% attack too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crafting System ─────────────────────────────────────────────────────────
|
||||
// Auto-crafting happens before combat. If the player has sufficient foraging
|
||||
// level and the right ingredients in inventory, consumables are assembled
|
||||
// automatically. Failed crafts consume one ingredient.
|
||||
|
||||
type CraftingRecipe struct {
|
||||
Result string // consumable name to produce
|
||||
Ingredients []string // required item names (matched by inventory Name)
|
||||
MinForaging int // foraging level required
|
||||
Tier int
|
||||
}
|
||||
|
||||
var craftingRecipes = []CraftingRecipe{
|
||||
// T1 — Foraging 10
|
||||
{Result: "Berry Poultice", Ingredients: []string{"Berries", "Common Herbs"}, MinForaging: 10, Tier: 1},
|
||||
// T2 — Foraging 15
|
||||
{Result: "Herb Salve", Ingredients: []string{"Rare Herbs", "Honey"}, MinForaging: 15, Tier: 2},
|
||||
{Result: "Mushroom Brew", Ingredients: []string{"Mushrooms", "Hardwood"}, MinForaging: 15, Tier: 2},
|
||||
{Result: "Coal Bomb", Ingredients: []string{"Coal", "Saltpetre"}, MinForaging: 15, Tier: 2},
|
||||
// T3 — Foraging 20
|
||||
{Result: "Goblin Grease", Ingredients: []string{"Goblin Trinket", "Honey"}, MinForaging: 20, Tier: 3},
|
||||
{Result: "Quartz Ward", Ingredients: []string{"Quartz", "Iron Ore"}, MinForaging: 20, Tier: 3},
|
||||
{Result: "Blooper Ink Vial", Ingredients: []string{"Blooper Ink", "River Pearl"}, MinForaging: 20, Tier: 3},
|
||||
{Result: "Spore Cloud", Ingredients: []string{"Spores", "Rare Herbs"}, MinForaging: 20, Tier: 3},
|
||||
// T4 — Foraging 25
|
||||
{Result: "Spirit Tonic", Ingredients: []string{"Spirit Herbs", "Starfruit"}, MinForaging: 25, Tier: 4},
|
||||
{Result: "Sapphire Elixir", Ingredients: []string{"Sapphire", "Dragon Crystal"}, MinForaging: 25, Tier: 4},
|
||||
// T5 — Foraging 30
|
||||
{Result: "Ancient Artifact Oil", Ingredients: []string{"Ancient Artifact", "Dragon Scale"}, MinForaging: 30, Tier: 5},
|
||||
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
|
||||
}
|
||||
|
||||
// craftingSuccessRate returns the success chance for a recipe given foraging level.
|
||||
// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
|
||||
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
|
||||
base := 0.50
|
||||
levelsAbove := foragingLevel - minForaging
|
||||
bonus := float64(levelsAbove) / 5.0 * 0.03
|
||||
rate := base + bonus
|
||||
if rate > 0.95 {
|
||||
return 0.95
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
// CraftResult records what happened during auto-crafting for narrative output.
|
||||
type CraftResult struct {
|
||||
Recipe *CraftingRecipe
|
||||
Success bool
|
||||
}
|
||||
|
||||
// autoCraftConsumables scans the player's inventory for craftable recipes and
|
||||
// attempts to craft the highest-tier recipe available. Consumes ingredients on
|
||||
// attempt; on failure one ingredient is lost. Returns crafted items and results.
|
||||
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) {
|
||||
if foragingLevel < 10 {
|
||||
return nil, items
|
||||
}
|
||||
|
||||
remaining := make([]AdvItem, len(items))
|
||||
copy(remaining, items)
|
||||
|
||||
var results []CraftResult
|
||||
crafted := 0
|
||||
maxCrafts := 1 + (foragingLevel-10)/10 // 1 at lv10, 2 at lv20, 3 at lv30
|
||||
|
||||
// Try recipes from highest tier down
|
||||
for attempt := 0; attempt < maxCrafts; attempt++ {
|
||||
bestRecipe, ingredientIDs := findBestCraftable(remaining, foragingLevel)
|
||||
if bestRecipe == nil {
|
||||
break
|
||||
}
|
||||
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
|
||||
success := rand.Float64() < rate
|
||||
|
||||
if success {
|
||||
// Remove all ingredients from inventory
|
||||
for _, id := range ingredientIDs {
|
||||
removeAdvInventoryItem(id)
|
||||
}
|
||||
remaining = removeItemsByIDs(remaining, ingredientIDs)
|
||||
|
||||
// Add crafted consumable to inventory
|
||||
def := consumableDefByName(bestRecipe.Result)
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
sellValue := def.Price / 2
|
||||
if sellValue == 0 {
|
||||
tierSellValues := map[int]int64{1: 200, 2: 600, 3: 1500, 4: 3000, 5: 6000}
|
||||
sellValue = tierSellValues[def.Tier]
|
||||
}
|
||||
craftedItem := AdvItem{
|
||||
Name: def.Name,
|
||||
Type: "consumable",
|
||||
Tier: def.Tier,
|
||||
Value: sellValue,
|
||||
}
|
||||
if err := addAdvInventoryItem(userID, craftedItem); err != nil {
|
||||
slog.Error("crafting: failed to add crafted item", "item", def.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
results = append(results, CraftResult{Recipe: bestRecipe, Success: true})
|
||||
crafted++
|
||||
} else {
|
||||
// Failure: all ingredients destroyed
|
||||
for _, id := range ingredientIDs {
|
||||
removeAdvInventoryItem(id)
|
||||
}
|
||||
remaining = removeItemsByIDs(remaining, ingredientIDs)
|
||||
results = append(results, CraftResult{Recipe: bestRecipe, Success: false})
|
||||
}
|
||||
}
|
||||
|
||||
// Reload remaining if we crafted anything (IDs changed)
|
||||
if crafted > 0 {
|
||||
reloaded, err := loadAdvInventory(userID)
|
||||
if err == nil {
|
||||
remaining = reloaded
|
||||
}
|
||||
}
|
||||
|
||||
return results, remaining
|
||||
}
|
||||
|
||||
// findBestCraftable returns the highest-tier recipe the player can craft
|
||||
// from their current inventory, along with the inventory IDs of the ingredients.
|
||||
func findBestCraftable(items []AdvItem, foragingLevel int) (*CraftingRecipe, []int64) {
|
||||
var bestRecipe *CraftingRecipe
|
||||
var bestIDs []int64
|
||||
|
||||
for i := range craftingRecipes {
|
||||
recipe := &craftingRecipes[i]
|
||||
if foragingLevel < recipe.MinForaging {
|
||||
continue
|
||||
}
|
||||
|
||||
ids := matchIngredients(items, recipe.Ingredients)
|
||||
if ids == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if bestRecipe == nil || recipe.Tier > bestRecipe.Tier {
|
||||
bestRecipe = recipe
|
||||
bestIDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
return bestRecipe, bestIDs
|
||||
}
|
||||
|
||||
// matchIngredients checks if inventory contains all ingredients for a recipe.
|
||||
// Returns the inventory item IDs to consume, or nil if not all found.
|
||||
func matchIngredients(items []AdvItem, ingredients []string) []int64 {
|
||||
used := make(map[int]bool)
|
||||
var ids []int64
|
||||
|
||||
for _, need := range ingredients {
|
||||
found := false
|
||||
for j, item := range items {
|
||||
if used[j] {
|
||||
continue
|
||||
}
|
||||
if item.Name == need && item.Type != "consumable" && item.Type != "MasterworkGear" && item.Type != "ArenaGear" && item.Type != "card" {
|
||||
used[j] = true
|
||||
ids = append(ids, item.ID)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func removeItemsByIDs(items []AdvItem, ids []int64) []AdvItem {
|
||||
idSet := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
idSet[id] = true
|
||||
}
|
||||
var result []AdvItem
|
||||
for _, item := range items {
|
||||
if !idSet[item.ID] {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
279
internal/plugin/adventure_consumables_test.go
Normal file
279
internal/plugin/adventure_consumables_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func makeInventory(names ...string) []ConsumableItem {
|
||||
var items []ConsumableItem
|
||||
for i, name := range names {
|
||||
def := consumableDefByName(name)
|
||||
if def == nil {
|
||||
panic("unknown consumable: " + name)
|
||||
}
|
||||
items = append(items, ConsumableItem{InventoryID: int64(i + 1), Def: def})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func TestSelectConsumables_TrivialFightSkips(t *testing.T) {
|
||||
strong := CombatStats{MaxHP: 200, Attack: 60}
|
||||
weak := CombatStats{MaxHP: 30, Attack: 5}
|
||||
inv := makeInventory("Berry Poultice", "Coal Bomb")
|
||||
|
||||
selected := SelectConsumables(inv, strong, weak, 0, 0)
|
||||
if len(selected) != 0 {
|
||||
t.Errorf("should skip consumables for trivial fight, got %d", len(selected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_DangerousUsesHighTier(t *testing.T) {
|
||||
weak := CombatStats{MaxHP: 80, Attack: 15}
|
||||
strong := CombatStats{MaxHP: 200, Attack: 50}
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
|
||||
|
||||
selected := SelectConsumables(inv, weak, strong, 0, 0)
|
||||
if len(selected) != 2 {
|
||||
t.Fatalf("expected 2 consumables, got %d", len(selected))
|
||||
}
|
||||
|
||||
hasHighTierOff, hasHighTierDef := false, false
|
||||
for _, s := range selected {
|
||||
if s.Def.Tier >= 4 && s.Def.Slot == "offensive" {
|
||||
hasHighTierOff = true
|
||||
}
|
||||
if s.Def.Tier >= 4 && s.Def.Slot == "defensive" {
|
||||
hasHighTierDef = true
|
||||
}
|
||||
}
|
||||
if !hasHighTierOff {
|
||||
t.Error("should pick high-tier offensive for dangerous fight")
|
||||
}
|
||||
if !hasHighTierDef {
|
||||
t.Error("should pick high-tier defensive for dangerous fight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_MaxTwo(t *testing.T) {
|
||||
player := CombatStats{MaxHP: 100, Attack: 30}
|
||||
enemy := CombatStats{MaxHP: 120, Attack: 35}
|
||||
inv := makeInventory(
|
||||
"Berry Poultice", "Herb Salve", "Mushroom Brew",
|
||||
"Coal Bomb", "Goblin Grease", "Blooper Ink Vial",
|
||||
)
|
||||
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0)
|
||||
if len(selected) > 2 {
|
||||
t.Errorf("max 2 consumables, got %d", len(selected))
|
||||
}
|
||||
|
||||
offCount, defCount := 0, 0
|
||||
for _, s := range selected {
|
||||
if s.Def.Slot == "offensive" {
|
||||
offCount++
|
||||
} else {
|
||||
defCount++
|
||||
}
|
||||
}
|
||||
if offCount > 1 {
|
||||
t.Errorf("max 1 offensive, got %d", offCount)
|
||||
}
|
||||
if defCount > 1 {
|
||||
t.Errorf("max 1 defensive, got %d", defCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_EasyUsesLowTier(t *testing.T) {
|
||||
player := CombatStats{MaxHP: 100, Attack: 30}
|
||||
enemy := CombatStats{MaxHP: 50, Attack: 15} // easy: ratio ~0.55
|
||||
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
|
||||
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0)
|
||||
for _, s := range selected {
|
||||
if s.Def.Tier > 2 {
|
||||
t.Errorf("easy fight should use T1-T2, got %s (T%d)", s.Def.Name, s.Def.Tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_ArenaRoundEscalation(t *testing.T) {
|
||||
player := CombatStats{MaxHP: 100, Attack: 30}
|
||||
enemy := CombatStats{MaxHP: 80, Attack: 25} // competitive
|
||||
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Sapphire Elixir")
|
||||
|
||||
r1 := SelectConsumables(inv, player, enemy, 1, 0)
|
||||
r4 := SelectConsumables(inv, player, enemy, 4, 0)
|
||||
|
||||
maxTierR1 := 0
|
||||
for _, s := range r1 {
|
||||
if s.Def.Tier > maxTierR1 {
|
||||
maxTierR1 = s.Def.Tier
|
||||
}
|
||||
}
|
||||
maxTierR4 := 0
|
||||
for _, s := range r4 {
|
||||
if s.Def.Tier > maxTierR4 {
|
||||
maxTierR4 = s.Def.Tier
|
||||
}
|
||||
}
|
||||
if maxTierR4 <= maxTierR1 && len(r4) > 0 && len(r1) > 0 {
|
||||
t.Logf("arena round escalation: R1 maxTier=%d, R4 maxTier=%d", maxTierR1, maxTierR4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Heal(t *testing.T) {
|
||||
stats := CombatStats{MaxHP: 100}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
items := makeInventory("Herb Salve")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.HealItem != 20 {
|
||||
t.Errorf("heal item = %d, want 20", mods.HealItem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_AtkBoost(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Goblin Grease")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.DamageBonus < 0.24 || mods.DamageBonus > 0.26 {
|
||||
t.Errorf("damage bonus = %f, want ~0.25", mods.DamageBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Ward(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Quartz Ward")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.WardCharges != 1 {
|
||||
t.Errorf("ward charges = %d, want 1", mods.WardCharges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_AutoCrit(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Ancient Artifact Oil")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if !mods.AutoCritFirst {
|
||||
t.Error("AutoCritFirst should be true")
|
||||
}
|
||||
if mods.DamageBonus < 0.34 {
|
||||
t.Errorf("damage bonus = %f, want ~0.35", mods.DamageBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_SporeCloud(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Spore Cloud")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.SporeCloud != 2 {
|
||||
t.Errorf("spore rounds = %d, want 2", mods.SporeCloud)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Reflect(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Voidstone Shard")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.ReflectNext < 0.49 {
|
||||
t.Errorf("reflect = %f, want ~0.50", mods.ReflectNext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_DefBoost(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
items := makeInventory("Mushroom Brew")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.DamageReduct < 0.79 || mods.DamageReduct > 0.81 {
|
||||
t.Errorf("damage reduct = %f, want ~0.80", mods.DamageReduct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_SpeedBoost(t *testing.T) {
|
||||
stats := CombatStats{Speed: 10}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Blooper Ink Vial")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if stats.Speed != 13 { // 10 * 1.30 = 13
|
||||
t.Errorf("speed = %d, want 13", stats.Speed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_CritBoost(t *testing.T) {
|
||||
stats := CombatStats{CritRate: 0.05}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Sapphire Elixir")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if stats.CritRate < 0.19 || stats.CritRate > 0.21 {
|
||||
t.Errorf("crit rate = %f, want ~0.20", stats.CritRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_FlatDmg(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Coal Bomb")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.FlatDmgStart != 8 {
|
||||
t.Errorf("flat damage = %d, want 8", mods.FlatDmgStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_ValidActivity(t *testing.T) {
|
||||
drops := 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
item := RollConsumableDrop(AdvActivityMining, 3)
|
||||
if item != nil {
|
||||
drops++
|
||||
if item.Type != "consumable" {
|
||||
t.Errorf("drop type = %q, want consumable", item.Type)
|
||||
}
|
||||
if item.Name != "Quartz Ward" {
|
||||
t.Errorf("mining T3 drop = %q, want Quartz Ward", item.Name)
|
||||
}
|
||||
if item.Value <= 0 {
|
||||
t.Errorf("drop sell value = %d, want > 0", item.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 15% chance → expect ~150 in 1000
|
||||
if drops < 100 || drops > 220 {
|
||||
t.Errorf("drop rate: %d/1000 (expect ~150 at 15%%)", drops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_InvalidActivity(t *testing.T) {
|
||||
item := RollConsumableDrop(AdvActivityRest, 3)
|
||||
if item != nil {
|
||||
t.Error("rest activity should not drop consumables")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_T1NoDrop(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
item := RollConsumableDrop(AdvActivityForaging, 1)
|
||||
if item != nil {
|
||||
t.Fatal("T1 should not drop consumables")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyableConsumables(t *testing.T) {
|
||||
buyable := BuyableConsumables()
|
||||
if len(buyable) == 0 {
|
||||
t.Fatal("should have buyable consumables")
|
||||
}
|
||||
for _, def := range buyable {
|
||||
if !def.Buyable {
|
||||
t.Errorf("%s marked buyable=false but returned by BuyableConsumables", def.Name)
|
||||
}
|
||||
if def.Price <= 0 {
|
||||
t.Errorf("%s has no price", def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,13 +367,16 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
||||
}
|
||||
|
||||
if len(result.LootItems) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("💰 Loot: €%d total\n", result.TotalLootValue))
|
||||
sb.WriteString(fmt.Sprintf("💰 Loot: %s total\n", fmtEuro(result.TotalLootValue)))
|
||||
for _, item := range result.LootItems {
|
||||
sb.WriteString(fmt.Sprintf(" • %s — €%d\n", item.Name, item.Value))
|
||||
sb.WriteString(fmt.Sprintf(" • %s — %s\n", item.Name, fmtEuro(item.Value)))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
|
||||
if result.XPBreakdown != "" {
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", result.XPBreakdown))
|
||||
}
|
||||
if result.LeveledUp {
|
||||
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", titleCase(result.XPSkill), result.NewLevel))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -152,7 +153,7 @@ func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
|
||||
sb.WriteString("⚔️ Weapons 🛡️ Armor\n")
|
||||
sb.WriteString("🪖 Helmets 👢 Boots\n")
|
||||
sb.WriteString("⛏️ Tools\n\n")
|
||||
sb.WriteString("⛏️ Tools 🧪 Supplies\n\n")
|
||||
|
||||
if showAll {
|
||||
flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
|
||||
@@ -324,11 +325,25 @@ func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interact
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
|
||||
}
|
||||
|
||||
// Check for supplies category first
|
||||
if reply == "supplies" || reply == "supply" || reply == "consumables" || reply == "potions" {
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
text := luigiSuppliesView(ctx.Sender, balance)
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_supply",
|
||||
Data: data,
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
p.shopScheduleBrowseNudge(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
slot := advParseShopCategory(reply)
|
||||
if slot == "" {
|
||||
// Re-store pending and reprompt.
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, or tools.")
|
||||
return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, or supplies.")
|
||||
}
|
||||
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
@@ -482,6 +497,12 @@ func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *ad
|
||||
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
|
||||
}
|
||||
|
||||
// Community contribution: 5% of purchase price.
|
||||
if potCut := int(def.Price * 0.05); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(ctx.Sender, potCut)
|
||||
}
|
||||
|
||||
// Move old gear to inventory before replacing.
|
||||
if current != nil && current.Tier > 0 {
|
||||
itemType := "ShopGear"
|
||||
@@ -681,6 +702,12 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
|
||||
return "Transaction failed. The economy is having a moment."
|
||||
}
|
||||
|
||||
// Community contribution: 5% of purchase price.
|
||||
if potCut := int(def.Price * 0.05); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(userID, potCut)
|
||||
}
|
||||
|
||||
// Move old gear to inventory.
|
||||
if current != nil && current.Tier > 0 {
|
||||
itemType := "ShopGear"
|
||||
@@ -767,11 +794,17 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||
return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
|
||||
}
|
||||
|
||||
p.euro.Credit(userID, total, "adventure_sell_all")
|
||||
potCut := math.Round(total * 0.05)
|
||||
payout := total - potCut
|
||||
p.euro.Credit(userID, payout, "adventure_sell_all")
|
||||
if int(potCut) > 0 {
|
||||
communityPotAdd(int(potCut))
|
||||
trackTaxPaid(userID, int(potCut))
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Sold %d items for **€%.0f**.\n\nThe merchant took everything without comment. "+
|
||||
msg := fmt.Sprintf("Sold %d items for **%s** (%s after 5%% broker fee → community pot).\n\nThe merchant took everything without comment. "+
|
||||
"This is the most respect anyone has shown your loot collection. Take the money.",
|
||||
sold, total)
|
||||
sold, fmtEuro(total), fmtEuro(payout))
|
||||
if keptSpecial > 0 {
|
||||
msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial)
|
||||
}
|
||||
@@ -793,8 +826,14 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string
|
||||
if err := removeAdvInventoryItem(item.ID); err != nil {
|
||||
return "Failed to sell that item."
|
||||
}
|
||||
p.euro.Credit(userID, float64(item.Value), "adventure_sell_"+item.Name)
|
||||
return fmt.Sprintf("Sold **%s** for **€%d**. The merchant nodded. That's it. That's the transaction.", item.Name, item.Value)
|
||||
potCut := int(math.Round(float64(item.Value) * 0.05))
|
||||
payout := int(item.Value) - potCut
|
||||
p.euro.Credit(userID, float64(payout), "adventure_sell_"+item.Name)
|
||||
if potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(userID, potCut)
|
||||
}
|
||||
return fmt.Sprintf("Sold **%s** for **%s** (%s after 5%% broker fee → community pot). The merchant nodded. That's it. That's the transaction.", item.Name, fmtEuro(item.Value), fmtEuro(payout))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,3 +872,116 @@ func advInventoryDisplay(userID id.UserID) string {
|
||||
sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Supplies (Consumables) ──────────────────────────────────────────────────
|
||||
|
||||
func luigiSuppliesView(_ id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🧪 **Supplies** — combat consumables (auto-used)\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
sb.WriteString("Items are used automatically during combat. The engine picks up to 2 per fight based on threat level — it won't waste them on easy fights.\n\n")
|
||||
|
||||
defs := BuyableConsumables()
|
||||
if len(defs) == 0 {
|
||||
sb.WriteString("_Nothing in stock right now._\n\n")
|
||||
} else {
|
||||
for _, def := range defs {
|
||||
effectDesc := consumableEffectDescription(def.Effect, def.Value)
|
||||
sb.WriteString(fmt.Sprintf("**%s** (T%d) — €%d\n %s\n\n", def.Name, def.Tier, def.Price, effectDesc))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
|
||||
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func consumableEffectDescription(effect ConsumableEffect, value float64) string {
|
||||
switch effect {
|
||||
case EffectHeal:
|
||||
return fmt.Sprintf("Restores %d HP mid-combat (triggers at <50%% HP)", int(value))
|
||||
case EffectDefBoost:
|
||||
return fmt.Sprintf("+%.0f%% Defense for the fight", value*100)
|
||||
case EffectAtkBoost:
|
||||
return fmt.Sprintf("+%.0f%% Attack for the fight", value*100)
|
||||
case EffectWard:
|
||||
return "Blocks one hit completely"
|
||||
case EffectSpeedBoost:
|
||||
return fmt.Sprintf("+%.0f%% Speed for the fight (evasion boost)", value*100)
|
||||
case EffectCritBoost:
|
||||
return fmt.Sprintf("+%.0f%% Crit Rate for the fight", value*100)
|
||||
case EffectFlatDmg:
|
||||
return fmt.Sprintf("Deals %d damage at fight start", int(value))
|
||||
case EffectSpore:
|
||||
return fmt.Sprintf("15%% enemy miss chance for %d rounds", int(value))
|
||||
case EffectReflect:
|
||||
return fmt.Sprintf("Reflects %.0f%% of next hit back at enemy", value*100)
|
||||
case EffectAutoCrit:
|
||||
return fmt.Sprintf("First hit is auto-crit + %.0f%% Attack", value*100)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply == "back" {
|
||||
data := interaction.Data.(*advPendingShopCategory)
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_category",
|
||||
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
if reply == "exit" || reply == "cancel" {
|
||||
p.shopSessionEnd(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
|
||||
}
|
||||
|
||||
// Find matching consumable
|
||||
var match *ConsumableDef
|
||||
for i := range consumableDefs {
|
||||
if consumableDefs[i].Buyable && containsFold(consumableDefs[i].Name, reply) {
|
||||
match = &consumableDefs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if match == nil {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "I don't have that. Reply with an item name from the list, or `back` to return.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(match.Price) {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d for %s but only have €%.0f.", match.Price, match.Name, balance))
|
||||
}
|
||||
|
||||
// Purchase the consumable
|
||||
p.euro.Debit(ctx.Sender, float64(match.Price), "shop_consumable")
|
||||
if potCut := int(math.Round(float64(match.Price) * 0.05)); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(ctx.Sender, potCut)
|
||||
}
|
||||
item := AdvItem{
|
||||
Name: match.Name,
|
||||
Type: "consumable",
|
||||
Tier: match.Tier,
|
||||
Value: match.Price / 2,
|
||||
}
|
||||
_ = addAdvInventoryItem(ctx.Sender, item)
|
||||
|
||||
// Stay in supplies view for more purchases
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
newBalance := p.euro.GetBalance(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%d. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
|
||||
match.Name, match.Price, newBalance))
|
||||
}
|
||||
|
||||
583
internal/plugin/combat_bridge.go
Normal file
583
internal/plugin/combat_bridge.go
Normal file
@@ -0,0 +1,583 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// runArenaCombat executes arena combat using the new simulation engine.
|
||||
// Returns the CombatResult and the post-combat Misty condition repair amount (if any).
|
||||
func (p *AdventurePlugin) runArenaCombat(
|
||||
userID id.UserID,
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
monster *ArenaMonster,
|
||||
arenaRound int,
|
||||
arenaTier int,
|
||||
) (CombatResult, int) {
|
||||
bonuses := p.loadCombatBonuses(userID, char)
|
||||
chatLvl := p.chatLevel(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
|
||||
|
||||
// Load consumables from inventory and auto-select
|
||||
consumables := p.loadConsumableInventory(userID)
|
||||
enemyStats, _ := DeriveArenaMonsterStats(monster)
|
||||
selected := SelectConsumables(consumables, playerStats, enemyStats, arenaRound, arenaTier)
|
||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||
|
||||
// Misty condition repair (post-combat, not part of engine)
|
||||
condRepair := 0
|
||||
now := time.Now().UTC()
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < 0.20 {
|
||||
condRepair = 5
|
||||
}
|
||||
}
|
||||
|
||||
player := Combatant{
|
||||
Name: char.DisplayName,
|
||||
Stats: playerStats,
|
||||
Mods: playerMods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: monster.Name,
|
||||
Stats: enemyStats,
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
Ability: monster.Ability,
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
||||
result = injectConsumableEvents(result, selected, len(consumables))
|
||||
|
||||
// Consume used items from inventory
|
||||
for _, c := range selected {
|
||||
if err := removeAdvInventoryItem(c.InventoryID); err != nil {
|
||||
slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
return result, condRepair
|
||||
}
|
||||
|
||||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||||
if p.achievements == nil {
|
||||
return
|
||||
}
|
||||
if result.PlayerWon && result.NearDeath {
|
||||
p.achievements.GrantAchievement(userID, "combat_near_death")
|
||||
}
|
||||
if result.SniperKilled {
|
||||
p.achievements.GrantAchievement(userID, "combat_sniper_kill")
|
||||
}
|
||||
if result.MistyHealed {
|
||||
p.achievements.GrantAchievement(userID, "combat_misty_clutch")
|
||||
}
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "pet_whiff" {
|
||||
p.achievements.GrantAchievement(userID, "combat_pet_save")
|
||||
break
|
||||
}
|
||||
}
|
||||
if checkDeathSaveEvent(result.Events) {
|
||||
p.achievements.GrantAchievement(userID, "combat_death_save")
|
||||
}
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "use_consumable" {
|
||||
p.achievements.GrantAchievement(userID, "combat_consumable_used")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runDungeonCombat executes dungeon combat using the new simulation engine.
|
||||
func (p *AdventurePlugin) runDungeonCombat(
|
||||
userID id.UserID,
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
loc *AdvLocation,
|
||||
bonuses *AdvBonusSummary,
|
||||
inPenaltyZone bool,
|
||||
) CombatResult {
|
||||
chatLvl := p.chatLevel(userID)
|
||||
hasGrudge := char.GrudgeLocation == loc.Name
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
|
||||
|
||||
// Penalty zone: player is weakened (mirrors +5% death, -15% success from old system)
|
||||
if inPenaltyZone {
|
||||
playerStats.Attack = int(float64(playerStats.Attack) * 0.85)
|
||||
playerStats.Defense = int(float64(playerStats.Defense) * 0.85)
|
||||
playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90)
|
||||
}
|
||||
|
||||
// Load consumables from inventory and auto-select
|
||||
consumables := p.loadConsumableInventory(userID)
|
||||
enemyStats, enemyMods := DeriveDungeonMonsterStats(loc)
|
||||
selected := SelectConsumables(consumables, playerStats, enemyStats, 0, loc.Tier)
|
||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||
|
||||
// Dungeon monsters T3+ can have abilities
|
||||
var ability *MonsterAbility
|
||||
switch {
|
||||
case loc.Tier >= 5:
|
||||
ability = &MonsterAbility{Name: "Abyssal Wrath", Phase: "any", ProcChance: 0.30, Effect: "enrage"}
|
||||
case loc.Tier == 4:
|
||||
ability = &MonsterAbility{Name: "Dark Curse", Phase: "clash", ProcChance: 0.25, Effect: "poison"}
|
||||
case loc.Tier == 3:
|
||||
ability = &MonsterAbility{Name: "Stone Skin", Phase: "opening", ProcChance: 0.20, Effect: "armor_break"}
|
||||
}
|
||||
|
||||
player := Combatant{
|
||||
Name: char.DisplayName,
|
||||
Stats: playerStats,
|
||||
Mods: playerMods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: loc.Denizens,
|
||||
Stats: enemyStats,
|
||||
Mods: enemyMods,
|
||||
Ability: ability,
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
result = injectConsumableEvents(result, selected, len(consumables))
|
||||
|
||||
// Consume used items from inventory
|
||||
for _, c := range selected {
|
||||
if err := removeAdvInventoryItem(c.InventoryID); err != nil {
|
||||
slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// loadCombatBonuses computes the AdvBonusSummary for combat stat derivation.
|
||||
func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCharacter) *AdvBonusSummary {
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
// If the player has sufficient foraging level, auto-crafts consumables from ingredients first.
|
||||
func (p *AdventurePlugin) loadConsumableInventory(userID id.UserID) []ConsumableItem {
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Auto-craft if player has foraging level 10+
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil && char.ForagingSkill >= 10 {
|
||||
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill)
|
||||
if len(craftResults) > 0 {
|
||||
items = updatedItems
|
||||
for _, cr := range craftResults {
|
||||
if cr.Success {
|
||||
slog.Info("crafting: auto-crafted", "user", userID, "item", cr.Recipe.Result)
|
||||
if p.achievements != nil {
|
||||
p.achievements.GrantAchievement(userID, "adv_first_craft")
|
||||
if cr.Recipe.Tier >= 5 {
|
||||
p.achievements.GrantAchievement(userID, "adv_craft_t5")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Info("crafting: failed", "user", userID, "item", cr.Recipe.Result)
|
||||
}
|
||||
}
|
||||
// Store results for narrative rendering
|
||||
p.storeCraftResults(userID, craftResults)
|
||||
}
|
||||
}
|
||||
|
||||
var consumables []ConsumableItem
|
||||
for _, item := range items {
|
||||
if item.Type != "consumable" {
|
||||
continue
|
||||
}
|
||||
def := consumableDefByName(item.Name)
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
consumables = append(consumables, ConsumableItem{
|
||||
InventoryID: item.ID,
|
||||
Def: def,
|
||||
})
|
||||
}
|
||||
return consumables
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) storeCraftResults(userID id.UserID, results []CraftResult) {
|
||||
p.craftResults.Store(string(userID), results)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) popCraftResults(userID id.UserID) []CraftResult {
|
||||
val, ok := p.craftResults.LoadAndDelete(string(userID))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return val.([]CraftResult)
|
||||
}
|
||||
|
||||
// prependCraftNarrative adds crafting results to the first combat phase message.
|
||||
func (p *AdventurePlugin) prependCraftNarrative(userID id.UserID, messages []string) []string {
|
||||
results := p.popCraftResults(userID)
|
||||
if len(results) == 0 || len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, cr := range results {
|
||||
if cr.Success {
|
||||
lines = append(lines, fmt.Sprintf("🧪 _Crafted **%s** from foraged ingredients._", cr.Recipe.Result))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("💨 _Failed to craft %s — ingredients lost._", cr.Recipe.Result))
|
||||
}
|
||||
}
|
||||
|
||||
prefix := strings.Join(lines, "\n") + "\n\n"
|
||||
messages[0] = prefix + messages[0]
|
||||
return messages
|
||||
}
|
||||
|
||||
// sendCombatMessages sends phase messages with delays, then a final message.
|
||||
// Runs in a goroutine so it doesn't block the message handler.
|
||||
// Returns a channel that is closed when all messages have been sent.
|
||||
func (p *AdventurePlugin) sendCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for _, msg := range phaseMessages {
|
||||
_ = p.SendDM(userID, msg)
|
||||
delay := 5 + rand.IntN(4) // 5-8 seconds
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
}
|
||||
_ = p.SendDM(userID, finalMessage)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
// XPBonusParams holds inputs for the shared XP bonus pipeline.
|
||||
type XPBonusParams struct {
|
||||
BaseXP int
|
||||
NearDeath bool
|
||||
BonusMult float64 // from AdvBonusSummary.XPMultiplier (percentage, e.g. 15 = +15%)
|
||||
Ironclad bool
|
||||
OverlevelMult float64 // 1.0 = no penalty
|
||||
}
|
||||
|
||||
// XPResult holds the final XP and a human-readable breakdown of bonuses applied.
|
||||
type XPResult struct {
|
||||
Total int
|
||||
Breakdown string // e.g. "+15% near-death, +5% ironclad" — empty if no bonuses
|
||||
}
|
||||
|
||||
// applyXPBonuses applies near-death, bonus multiplier, ironclad, and overlevel
|
||||
// penalties to a base XP value. Used by both dungeon and adventure paths.
|
||||
func applyXPBonuses(p XPBonusParams) XPResult {
|
||||
xp := p.BaseXP
|
||||
var parts []string
|
||||
if p.NearDeath {
|
||||
xp = int(float64(xp) * 1.15)
|
||||
parts = append(parts, "+15% near-death")
|
||||
}
|
||||
if p.BonusMult != 0 {
|
||||
xp = int(float64(xp) * (1 + p.BonusMult/100))
|
||||
parts = append(parts, fmt.Sprintf("+%.0f%% bonus", p.BonusMult))
|
||||
}
|
||||
if p.Ironclad {
|
||||
xp = int(float64(xp) * 1.05)
|
||||
parts = append(parts, "+5% ironclad")
|
||||
}
|
||||
if p.OverlevelMult < 1.0 {
|
||||
xp = max(1, int(float64(xp)*p.OverlevelMult))
|
||||
penalty := int((1.0 - p.OverlevelMult) * 100)
|
||||
parts = append(parts, fmt.Sprintf("-%d%% overlevel", penalty))
|
||||
}
|
||||
breakdown := ""
|
||||
if len(parts) > 0 {
|
||||
breakdown = strings.Join(parts, ", ")
|
||||
}
|
||||
return XPResult{Total: xp, Breakdown: breakdown}
|
||||
}
|
||||
|
||||
// DeathTransitionParams holds inputs for the shared death state machine.
|
||||
type DeathTransitionParams struct {
|
||||
Char *AdventureCharacter
|
||||
Equip map[EquipmentSlot]*AdvEquipment
|
||||
ChatLevel int
|
||||
Location string // set as GrudgeLocation; empty = don't set
|
||||
AllowPardon bool // chat level pardon (adventure only)
|
||||
AllowSovereign bool // probability-band Sovereign reprieve (non-engine path)
|
||||
EngineSaved bool // combat engine used Sovereign death save
|
||||
}
|
||||
|
||||
// DeathTransitionResult describes what happened during the death transition.
|
||||
type DeathTransitionResult struct {
|
||||
Pardoned bool
|
||||
Reprieved bool // Sovereign reprieve (non-engine)
|
||||
PetRecovered bool
|
||||
Died bool
|
||||
}
|
||||
|
||||
// transitionDeath runs the shared death state machine: pardon → engine cooldown →
|
||||
// Sovereign reprieve → kill + pet ditch recovery.
|
||||
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
||||
var r DeathTransitionResult
|
||||
|
||||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 {
|
||||
r.Pardoned = true
|
||||
now := time.Now().UTC()
|
||||
p.Char.LastPardonUsed = &now
|
||||
if p.Location != "" {
|
||||
p.Char.GrudgeLocation = p.Location
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if p.EngineSaved {
|
||||
now := time.Now().UTC()
|
||||
p.Char.DeathReprieveLast = &now
|
||||
}
|
||||
|
||||
if p.AllowSovereign && advEquippedArenaSets(p.Equip)["sovereign"] && p.Char.DeathReprieveAvailable() {
|
||||
r.Reprieved = true
|
||||
now := time.Now().UTC()
|
||||
p.Char.DeathReprieveLast = &now
|
||||
if p.Location != "" {
|
||||
p.Char.GrudgeLocation = p.Location
|
||||
}
|
||||
for _, slot := range allSlots {
|
||||
if eq, ok := p.Equip[slot]; ok {
|
||||
eq.Condition = 1
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
p.Char.Kill()
|
||||
if p.Location != "" {
|
||||
p.Char.GrudgeLocation = p.Location
|
||||
}
|
||||
r.Died = true
|
||||
|
||||
if petRollDitchRecovery(p.Char) && p.Char.DeadUntil != nil {
|
||||
reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Char.PetLevel))
|
||||
p.Char.DeadUntil = &reduced
|
||||
r.PetRecovered = true
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// checkDeathSaveEvent scans combat events for a Sovereign death save.
|
||||
func checkDeathSaveEvent(events []CombatEvent) bool {
|
||||
for _, ev := range events {
|
||||
if ev.Action == "death_save" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// injectConsumableEvents prepends use_consumable events so the narrative shows which items were consumed.
|
||||
// If items were available but skipped (trivial threat), a skip event is injected instead.
|
||||
func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inventorySize int) CombatResult {
|
||||
if len(selected) == 0 {
|
||||
if inventorySize > 0 {
|
||||
result.Events = append([]CombatEvent{{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "consumable_skip",
|
||||
PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP,
|
||||
}}, result.Events...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
var events []CombatEvent
|
||||
for _, c := range selected {
|
||||
events = append(events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable",
|
||||
PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP,
|
||||
Desc: c.Def.Name,
|
||||
})
|
||||
}
|
||||
result.Events = append(events, result.Events...)
|
||||
return result
|
||||
}
|
||||
|
||||
// combatResultToOutcome maps a CombatResult to an AdvOutcomeType for dungeon integration.
|
||||
func combatResultToOutcome(result CombatResult) AdvOutcomeType {
|
||||
if !result.PlayerWon {
|
||||
return AdvOutcomeDeath
|
||||
}
|
||||
// Won with >85% HP remaining → exceptional
|
||||
remainingPct := float64(result.PlayerEndHP) / float64(max(1, result.PlayerStartHP))
|
||||
if remainingPct > 0.85 {
|
||||
return AdvOutcomeExceptional
|
||||
}
|
||||
return AdvOutcomeSuccess
|
||||
}
|
||||
|
||||
// combatDegradation derives equipment damage from actual combat events.
|
||||
func combatDegradation(result CombatResult, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
|
||||
damage := make(map[EquipmentSlot]int)
|
||||
|
||||
if !result.PlayerWon {
|
||||
// Death: heavy degradation
|
||||
for _, slot := range allSlots {
|
||||
damage[slot] = 20
|
||||
}
|
||||
damage[SlotWeapon] = 30
|
||||
damage[SlotArmor] = 30
|
||||
return applyDegradationModifiers(damage, equip)
|
||||
}
|
||||
|
||||
// Derive from events: each enemy hit on player → armor/helmet wear
|
||||
// Each player hit → weapon wear, environmental → boots
|
||||
for _, ev := range result.Events {
|
||||
switch {
|
||||
case ev.Actor == "enemy" && (ev.Action == "hit" || ev.Action == "crit"):
|
||||
damage[SlotArmor] += 1 + rand.IntN(3)
|
||||
damage[SlotHelmet] += 1 + rand.IntN(2)
|
||||
case ev.Actor == "enemy" && ev.Action == "cleave":
|
||||
damage[SlotArmor] += 2 + rand.IntN(3)
|
||||
case ev.Actor == "enemy" && ev.Action == "lifesteal":
|
||||
damage[SlotArmor] += 1 + rand.IntN(2)
|
||||
case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"):
|
||||
damage[SlotWeapon] += 1
|
||||
case ev.Actor == "environment":
|
||||
damage[SlotBoots] += 1 + rand.IntN(2)
|
||||
case ev.Action == "armor_break":
|
||||
damage[SlotArmor] += 3
|
||||
}
|
||||
}
|
||||
|
||||
return applyDegradationModifiers(damage, equip)
|
||||
}
|
||||
|
||||
func applyDegradationModifiers(damage map[EquipmentSlot]int, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
|
||||
tempered := advEquippedArenaSets(equip)["tempered"]
|
||||
for slot, dmg := range damage {
|
||||
eq, ok := equip[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tempered {
|
||||
dmg = int(float64(dmg) * 0.75)
|
||||
}
|
||||
if eq.ActionsUsed >= 20 {
|
||||
dmg = int(float64(dmg) * 0.8)
|
||||
}
|
||||
if dmg < 0 {
|
||||
dmg = 0
|
||||
}
|
||||
damage[slot] = dmg
|
||||
eq.Condition -= dmg
|
||||
if eq.Condition < 0 {
|
||||
eq.Condition = 0
|
||||
}
|
||||
}
|
||||
return damage
|
||||
}
|
||||
|
||||
// resolveDungeonAction runs a dungeon encounter through the combat engine
|
||||
// and returns an AdvActionResult compatible with the existing adventure flow.
|
||||
func (p *AdventurePlugin) resolveDungeonAction(
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
loc *AdvLocation,
|
||||
bonuses *AdvBonusSummary,
|
||||
inPenaltyZone bool,
|
||||
) *AdvActionResult {
|
||||
result := &AdvActionResult{
|
||||
Location: loc,
|
||||
XPSkill: "combat",
|
||||
}
|
||||
|
||||
combat := p.runDungeonCombat(char.UserID, char, equip, loc, bonuses, inPenaltyZone)
|
||||
result.CombatLog = &combat
|
||||
result.Outcome = combatResultToOutcome(combat)
|
||||
|
||||
// Misty condition repair (post-combat, same as arena)
|
||||
now := time.Now().UTC()
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < 0.20 {
|
||||
npcRepairMostDamaged(char.UserID, equip, 5)
|
||||
}
|
||||
}
|
||||
result.NearDeath = combat.NearDeath
|
||||
|
||||
// Overlevel penalty
|
||||
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
||||
overlevelMult := advOverlevelMultiplier(skillLevel, loc.MinLevel)
|
||||
|
||||
// Loot on success/exceptional
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
|
||||
if overlevelMult < 1.0 {
|
||||
for i := range result.LootItems {
|
||||
result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
|
||||
}
|
||||
}
|
||||
for _, item := range result.LootItems {
|
||||
result.TotalLootValue += item.Value
|
||||
}
|
||||
}
|
||||
|
||||
// XP calculation
|
||||
xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
xp = advXPTable[loc.Activity][loc.Tier].Success
|
||||
if result.Outcome == AdvOutcomeExceptional {
|
||||
xp = advXPTable[loc.Activity][loc.Tier].Exceptional
|
||||
}
|
||||
}
|
||||
xpResult := applyXPBonuses(XPBonusParams{
|
||||
BaseXP: xp,
|
||||
NearDeath: result.NearDeath,
|
||||
BonusMult: bonuses.XPMultiplier,
|
||||
Ironclad: advEquippedArenaSets(equip)["ironclad"],
|
||||
OverlevelMult: overlevelMult,
|
||||
})
|
||||
result.XPGained = xpResult.Total
|
||||
result.XPBreakdown = xpResult.Breakdown
|
||||
|
||||
// Equipment degradation from combat events
|
||||
result.EquipDamage = combatDegradation(combat, equip)
|
||||
result.EquipBroken = advCheckBrokenEquipment(equip)
|
||||
|
||||
// Increment actions_used for equipment mastery
|
||||
for _, eq := range equip {
|
||||
eq.ActionsUsed++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// renderArenaCombatFinalMessage builds the post-combat text (rewards, tier progress, etc.)
|
||||
// This is sent as the last message after the phase-by-phase combat messages.
|
||||
func renderArenaCombatFinalMessage(result CombatResult, monster *ArenaMonster, reward int64, battleXP int, round int) string {
|
||||
if result.PlayerWon {
|
||||
closer := arenaWinCloser(monster.Name, round)
|
||||
return fmt.Sprintf("💀 **%s** has been defeated.\n%s\n🏆 +%d XP | €%d earned",
|
||||
monster.Name, closer, battleXP, reward)
|
||||
}
|
||||
closer := arenaLoseCloser(monster.Name, round)
|
||||
return fmt.Sprintf("The healers are already moving.\n💀 **Defeated.**\n%s\n+%d XP (participation) | Back tomorrow.",
|
||||
closer, arenaParticipationXP)
|
||||
}
|
||||
276
internal/plugin/combat_bridge_test.go
Normal file
276
internal/plugin/combat_bridge_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckDeathSaveEvent_Found(t *testing.T) {
|
||||
events := []CombatEvent{
|
||||
{Action: "hit"}, {Action: "crit"}, {Action: "death_save"}, {Action: "hit"},
|
||||
}
|
||||
if !checkDeathSaveEvent(events) {
|
||||
t.Error("should find death_save event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeathSaveEvent_NotFound(t *testing.T) {
|
||||
events := []CombatEvent{
|
||||
{Action: "hit"}, {Action: "crit"}, {Action: "block"},
|
||||
}
|
||||
if checkDeathSaveEvent(events) {
|
||||
t.Error("should not find death_save event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeathSaveEvent_Empty(t *testing.T) {
|
||||
if checkDeathSaveEvent(nil) {
|
||||
t.Error("nil events should return false")
|
||||
}
|
||||
if checkDeathSaveEvent([]CombatEvent{}) {
|
||||
t.Error("empty events should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_AllNeutral(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, OverlevelMult: 1.0})
|
||||
if r.Total != 100 {
|
||||
t.Errorf("neutral bonuses: got %d, want 100", r.Total)
|
||||
}
|
||||
if r.Breakdown != "" {
|
||||
t.Errorf("neutral breakdown should be empty, got %q", r.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_NearDeath(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, NearDeath: true, OverlevelMult: 1.0})
|
||||
if r.Total != 114 {
|
||||
t.Errorf("near-death: got %d, want 114", r.Total)
|
||||
}
|
||||
if r.Breakdown != "+15% near-death" {
|
||||
t.Errorf("breakdown = %q", r.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_BonusMult(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, BonusMult: 20, OverlevelMult: 1.0})
|
||||
if r.Total != 120 {
|
||||
t.Errorf("bonus mult 20%%: got %d, want 120", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_Ironclad(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, Ironclad: true, OverlevelMult: 1.0})
|
||||
if r.Total != 105 {
|
||||
t.Errorf("ironclad: got %d, want 105", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_OverlevelPenalty(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, OverlevelMult: 0.5})
|
||||
if r.Total != 50 {
|
||||
t.Errorf("overlevel 0.5x: got %d, want 50", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_OverlevelFloor(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 1, OverlevelMult: 0.01})
|
||||
if r.Total < 1 {
|
||||
t.Errorf("overlevel floor: got %d, want >= 1", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_AllStacked(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{
|
||||
BaseXP: 100, NearDeath: true, BonusMult: 20, Ironclad: true, OverlevelMult: 0.8,
|
||||
})
|
||||
if r.Total != 113 {
|
||||
t.Errorf("all stacked: got %d, want 113", r.Total)
|
||||
}
|
||||
if r.Breakdown == "" {
|
||||
t.Error("stacked breakdown should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_PardonFires(t *testing.T) {
|
||||
_ = transitionDeath(DeathTransitionParams{
|
||||
Char: &AdventureCharacter{Alive: true},
|
||||
ChatLevel: 25,
|
||||
Location: "Dark Cave",
|
||||
AllowPardon: true,
|
||||
})
|
||||
// Pardon is 33% chance — run enough times to confirm it can fire
|
||||
pardoned := 0
|
||||
for i := 0; i < 300; i++ {
|
||||
c := &AdventureCharacter{Alive: true}
|
||||
res := transitionDeath(DeathTransitionParams{
|
||||
Char: c, ChatLevel: 25, Location: "Dark Cave", AllowPardon: true,
|
||||
})
|
||||
if res.Pardoned {
|
||||
pardoned++
|
||||
if c.LastPardonUsed == nil {
|
||||
t.Fatal("pardon should set LastPardonUsed")
|
||||
}
|
||||
if c.GrudgeLocation != "Dark Cave" {
|
||||
t.Fatalf("pardon should set GrudgeLocation, got %q", c.GrudgeLocation)
|
||||
}
|
||||
if res.Died || res.Reprieved {
|
||||
t.Fatal("pardoned should not also be died/reprieved")
|
||||
}
|
||||
}
|
||||
}
|
||||
if pardoned == 0 {
|
||||
t.Error("pardon should fire at least once in 300 trials (p≈33%)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_PardonCooldown(t *testing.T) {
|
||||
recent := time.Now().UTC().Add(-1 * time.Hour)
|
||||
char := &AdventureCharacter{Alive: true, LastPardonUsed: &recent}
|
||||
r := transitionDeath(DeathTransitionParams{
|
||||
Char: char, ChatLevel: 25, Location: "Cave", AllowPardon: true,
|
||||
})
|
||||
if r.Pardoned {
|
||||
t.Error("pardon should not fire during cooldown")
|
||||
}
|
||||
if !r.Died {
|
||||
t.Error("should die when pardon on cooldown and no sovereign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_SovereignReprieve(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 80, ArenaSet: "sovereign"},
|
||||
SlotArmor: {Condition: 90, ArenaSet: "sovereign"},
|
||||
SlotHelmet: {Condition: 70, ArenaSet: "sovereign"},
|
||||
SlotBoots: {Condition: 60, ArenaSet: "sovereign"},
|
||||
SlotTool: {Condition: 50, ArenaSet: "sovereign"},
|
||||
}
|
||||
char := &AdventureCharacter{Alive: true}
|
||||
r := transitionDeath(DeathTransitionParams{
|
||||
Char: char, Equip: equip, Location: "Abyss",
|
||||
AllowSovereign: true,
|
||||
})
|
||||
if !r.Reprieved {
|
||||
t.Fatal("sovereign should reprieve")
|
||||
}
|
||||
if r.Died || r.Pardoned {
|
||||
t.Error("reprieved should not also be died/pardoned")
|
||||
}
|
||||
if char.GrudgeLocation != "Abyss" {
|
||||
t.Errorf("grudge location = %q, want Abyss", char.GrudgeLocation)
|
||||
}
|
||||
for slot, eq := range equip {
|
||||
if eq.Condition != 1 {
|
||||
t.Errorf("slot %s condition = %d, want 1", slot, eq.Condition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_KillAndPetRecovery(t *testing.T) {
|
||||
deaths, petRecoveries := 0, 0
|
||||
for i := 0; i < 500; i++ {
|
||||
char := &AdventureCharacter{
|
||||
Alive: true, PetType: "cat", PetArrived: true, PetLevel: 5,
|
||||
}
|
||||
r := transitionDeath(DeathTransitionParams{Char: char, Location: "Mine"})
|
||||
if !r.Died {
|
||||
t.Fatal("should die with no saves available")
|
||||
}
|
||||
if !char.Alive == false {
|
||||
t.Fatal("character should not be alive")
|
||||
}
|
||||
if char.GrudgeLocation != "Mine" {
|
||||
t.Fatalf("grudge = %q, want Mine", char.GrudgeLocation)
|
||||
}
|
||||
deaths++
|
||||
if r.PetRecovered {
|
||||
petRecoveries++
|
||||
if char.DeadUntil == nil {
|
||||
t.Fatal("dead until should be set")
|
||||
}
|
||||
}
|
||||
}
|
||||
if petRecoveries == 0 {
|
||||
t.Error("pet recovery should fire at least once in 500 deaths")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_EngineSaveBurnsCooldown(t *testing.T) {
|
||||
char := &AdventureCharacter{Alive: true}
|
||||
r := transitionDeath(DeathTransitionParams{
|
||||
Char: char, EngineSaved: true,
|
||||
})
|
||||
if !r.Died {
|
||||
t.Error("should die (no sovereign/pardon)")
|
||||
}
|
||||
if char.DeathReprieveLast == nil {
|
||||
t.Error("engine save should burn DeathReprieveLast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_NoLocation(t *testing.T) {
|
||||
char := &AdventureCharacter{Alive: true, GrudgeLocation: "old"}
|
||||
transitionDeath(DeathTransitionParams{Char: char})
|
||||
if char.GrudgeLocation != "old" {
|
||||
t.Errorf("empty location should not change grudge, got %q", char.GrudgeLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_Basic(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 100},
|
||||
SlotArmor: {Condition: 100},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{
|
||||
SlotWeapon: 10,
|
||||
SlotArmor: 20,
|
||||
}
|
||||
result := applyDegradationModifiers(damage, equip)
|
||||
if equip[SlotWeapon].Condition != 90 {
|
||||
t.Errorf("weapon condition = %d, want 90", equip[SlotWeapon].Condition)
|
||||
}
|
||||
if equip[SlotArmor].Condition != 80 {
|
||||
t.Errorf("armor condition = %d, want 80", equip[SlotArmor].Condition)
|
||||
}
|
||||
if result[SlotWeapon] != 10 || result[SlotArmor] != 20 {
|
||||
t.Errorf("returned damage should match applied: weapon=%d, armor=%d", result[SlotWeapon], result[SlotArmor])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_Tempered(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotArmor: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotHelmet: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotBoots: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotTool: {Condition: 100, ArenaSet: "tempered"},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{SlotWeapon: 20}
|
||||
result := applyDegradationModifiers(damage, equip)
|
||||
if result[SlotWeapon] != 15 { // 20 * 0.75 = 15
|
||||
t.Errorf("tempered weapon damage = %d, want 15", result[SlotWeapon])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_Mastery(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 100, ActionsUsed: 25},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{SlotWeapon: 10}
|
||||
result := applyDegradationModifiers(damage, equip)
|
||||
if result[SlotWeapon] != 8 { // 10 * 0.8 = 8
|
||||
t.Errorf("mastery weapon damage = %d, want 8", result[SlotWeapon])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_ConditionFloor(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 5},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{SlotWeapon: 20}
|
||||
applyDegradationModifiers(damage, equip)
|
||||
if equip[SlotWeapon].Condition != 0 {
|
||||
t.Errorf("condition should floor at 0, got %d", equip[SlotWeapon].Condition)
|
||||
}
|
||||
}
|
||||
700
internal/plugin/combat_engine.go
Normal file
700
internal/plugin/combat_engine.go
Normal file
@@ -0,0 +1,700 @@
|
||||
package plugin
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// ── Core Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type CombatStats struct {
|
||||
MaxHP int
|
||||
Attack int
|
||||
Defense int
|
||||
Speed int
|
||||
CritRate float64
|
||||
DodgeRate float64
|
||||
BlockRate float64
|
||||
}
|
||||
|
||||
type CombatModifiers struct {
|
||||
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
|
||||
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
|
||||
DeathSave bool // Sovereign reprieve — survive one lethal hit
|
||||
PetAttackProc float64
|
||||
PetAttackDmg int
|
||||
PetDeflectProc float64
|
||||
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
|
||||
SniperKillProc float64 // Arina instant-kill
|
||||
MistyHealProc float64
|
||||
MistyHealAmt int
|
||||
CrowdRevengeProc float64 // Misty debuff: chance per round of crowd damage
|
||||
CrowdRevengeDmg int // Misty debuff: damage per proc
|
||||
HealItem int // consumable: HP restored at <50% HP (one-shot)
|
||||
WardCharges int // consumable: hits fully absorbed
|
||||
SporeCloud int // consumable: rounds of 15% enemy miss chance
|
||||
ReflectNext float64 // consumable: fraction of next hit reflected
|
||||
AutoCritFirst bool // consumable: first player hit is auto-crit
|
||||
FlatDmgStart int // consumable: flat damage to enemy pre-combat
|
||||
}
|
||||
|
||||
type Combatant struct {
|
||||
Name string
|
||||
Stats CombatStats
|
||||
Mods CombatModifiers
|
||||
IsPlayer bool
|
||||
Ability *MonsterAbility // non-nil for monsters with a special ability
|
||||
}
|
||||
|
||||
type CombatPhase struct {
|
||||
Name string
|
||||
Rounds int
|
||||
AttackWeight float64
|
||||
DefenseWeight float64
|
||||
SpeedWeight float64
|
||||
EnvironmentProc float64
|
||||
}
|
||||
|
||||
type CombatEvent struct {
|
||||
Round int
|
||||
Phase string
|
||||
Actor string // "player", "enemy", "pet", "environment", "npc", "consumable"
|
||||
Action string
|
||||
Damage int
|
||||
PlayerHP int
|
||||
EnemyHP int
|
||||
Desc string // optional flavor (item name, ability name)
|
||||
}
|
||||
|
||||
type CombatResult struct {
|
||||
PlayerWon bool
|
||||
Events []CombatEvent
|
||||
PlayerStartHP int
|
||||
EnemyStartHP int
|
||||
PlayerEndHP int
|
||||
EnemyEndHP int
|
||||
TotalRounds int
|
||||
Closeness float64
|
||||
NearDeath bool
|
||||
PetAttacked bool
|
||||
PetDeflected bool
|
||||
SniperKilled bool
|
||||
MistyHealed bool
|
||||
}
|
||||
|
||||
// ── Monster Abilities ────────────────────────────────────────────────────────
|
||||
|
||||
type MonsterAbility struct {
|
||||
Name string
|
||||
Phase string // "opening", "clash", "decisive", "any"
|
||||
ProcChance float64
|
||||
Effect string // "poison", "enrage", "armor_break", "stun", "lifesteal", "cleave"
|
||||
}
|
||||
|
||||
// ── Default Phase Definitions ────────────────────────────────────────────────
|
||||
|
||||
var defaultCombatPhases = []CombatPhase{
|
||||
{"Opening", 2, 0.6, 0.8, 1.5, 0.15},
|
||||
{"Clash", 3, 1.2, 1.0, 0.8, 0.08},
|
||||
{"Decisive", 1, 1.0, 0.7, 1.0, 0.05},
|
||||
}
|
||||
|
||||
var dungeonCombatPhases = []CombatPhase{
|
||||
{"Opening", 1, 0.8, 0.8, 1.2, 0.10},
|
||||
{"Clash", 2, 1.0, 1.0, 0.8, 0.08},
|
||||
{"Decisive", 1, 1.0, 0.7, 1.0, 0.05},
|
||||
}
|
||||
|
||||
// ── Simulation ───────────────────────────────────────────────────────────────
|
||||
|
||||
// combatState tracks mutable state during the simulation.
|
||||
type combatState struct {
|
||||
playerHP int
|
||||
enemyHP int
|
||||
|
||||
// Consumable one-shots
|
||||
healUsed bool
|
||||
wardCharges int
|
||||
sporeRounds int
|
||||
reflectFrac float64
|
||||
autoCrit bool
|
||||
|
||||
// Monster ability effects
|
||||
poisonTicks int
|
||||
poisonDmg int
|
||||
stunPlayer bool
|
||||
enraged bool
|
||||
armorBroken bool
|
||||
armorBreakAmt float64
|
||||
|
||||
// Sovereign reprieve
|
||||
deathSaveUsed bool
|
||||
|
||||
round int
|
||||
events []CombatEvent
|
||||
}
|
||||
|
||||
func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult {
|
||||
st := &combatState{
|
||||
playerHP: player.Stats.MaxHP,
|
||||
enemyHP: enemy.Stats.MaxHP,
|
||||
wardCharges: player.Mods.WardCharges,
|
||||
sporeRounds: player.Mods.SporeCloud,
|
||||
reflectFrac: player.Mods.ReflectNext,
|
||||
autoCrit: player.Mods.AutoCritFirst,
|
||||
}
|
||||
|
||||
result := CombatResult{
|
||||
PlayerStartHP: player.Stats.MaxHP,
|
||||
EnemyStartHP: enemy.Stats.MaxHP,
|
||||
}
|
||||
|
||||
// Pre-combat: Arina sniper check
|
||||
if player.Mods.SniperKillProc > 0 && rand.Float64() < player.Mods.SniperKillProc {
|
||||
st.enemyHP = 0
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
|
||||
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
|
||||
Desc: "Arina",
|
||||
})
|
||||
result.SniperKilled = true
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
|
||||
// Pre-combat: Coal Bomb / flat start damage
|
||||
if player.Mods.FlatDmgStart > 0 {
|
||||
dmg := player.Mods.FlatDmgStart
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
||||
|
||||
// Main simulation loop
|
||||
for _, phase := range phases {
|
||||
roundsThisPhase := phase.Rounds
|
||||
// Add slight variance: ±1 round for non-Decisive phases
|
||||
if phase.Name != "Decisive" && roundsThisPhase > 1 {
|
||||
roundsThisPhase += rand.IntN(2) // 0 or +1
|
||||
}
|
||||
for r := 0; r < roundsThisPhase; r++ {
|
||||
st.round++
|
||||
if simulateRound(st, &player, &enemy, &phase, &result) {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhaust all phases without a kill, the side with more HP% wins.
|
||||
playerMax := max(1, player.Stats.MaxHP)
|
||||
enemyMax := max(1, enemy.Stats.MaxHP)
|
||||
playerPct := float64(st.playerHP) / float64(playerMax)
|
||||
enemyPct := float64(st.enemyHP) / float64(enemyMax)
|
||||
if playerPct < enemyPct {
|
||||
st.playerHP = 0
|
||||
} else {
|
||||
st.enemyHP = 0
|
||||
}
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
|
||||
// simulateRound runs one round. Returns true if combat is over.
|
||||
func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Monster ability: check at round start
|
||||
abilityDealtDamage := false
|
||||
if enemy.Ability != nil {
|
||||
if abilityFires(enemy.Ability, phaseName, st) {
|
||||
if applyAbility(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
// Cleave and lifesteal deal damage — skip normal enemy attack this round
|
||||
switch enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poison tick from previous round
|
||||
if st.poisonTicks > 0 {
|
||||
st.playerHP = max(0, st.playerHP-st.poisonDmg)
|
||||
st.poisonTicks--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
|
||||
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if trySave(st, player, phaseName) {
|
||||
// survived
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
|
||||
petWhiff := player.Mods.PetWhiffProc > 0 && rand.Float64() < player.Mods.PetWhiffProc
|
||||
// Pet deflect: halves incoming damage to player this round
|
||||
petDeflect := player.Mods.PetDeflectProc > 0 && rand.Float64() < player.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
result.PetDeflected = true
|
||||
}
|
||||
|
||||
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
|
||||
sporeMiss := st.sporeRounds > 0 && rand.Float64() < 0.15
|
||||
|
||||
// Determine initiative
|
||||
playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight
|
||||
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
|
||||
playerInit := playerSpeed + rand.Float64()*10
|
||||
enemyInit := enemySpeed + rand.Float64()*10
|
||||
playerFirst := playerInit >= enemyInit
|
||||
|
||||
if playerFirst {
|
||||
if resolvePlayerAttack(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if resolvePlayerAttack(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Environmental hazard
|
||||
if phase.EnvironmentProc > 0 && rand.Float64() < phase.EnvironmentProc {
|
||||
envDmg := 2 + rand.IntN(5)
|
||||
st.playerHP = max(0, st.playerHP-envDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
|
||||
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Misty crowd revenge (debuff for declining Misty)
|
||||
if player.Mods.CrowdRevengeProc > 0 && rand.Float64() < player.Mods.CrowdRevengeProc {
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pet attack
|
||||
if player.Mods.PetAttackProc > 0 && rand.Float64() < player.Mods.PetAttackProc {
|
||||
petDmg := player.Mods.PetAttackDmg + rand.IntN(5)
|
||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||
result.PetAttacked = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
|
||||
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Misty heal
|
||||
if player.Mods.MistyHealProc > 0 && rand.Float64() < player.Mods.MistyHealProc {
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
|
||||
// Consumable heal: triggers once when player drops below 50%
|
||||
if !st.healUsed && player.Mods.HealItem > 0 &&
|
||||
st.playerHP > 0 && st.playerHP < player.Stats.MaxHP/2 {
|
||||
st.healUsed = true
|
||||
healAmt := player.Mods.HealItem
|
||||
st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Attack Resolution ────────────────────────────────────────────────────────
|
||||
|
||||
func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Stun: player skips attack
|
||||
if st.stunPlayer {
|
||||
st.stunPlayer = false
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: "stunned",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Enemy dodge
|
||||
enemyDodge := enemy.Stats.DodgeRate * phase.SpeedWeight
|
||||
if enemyDodge > 0 && rand.Float64() < enemyDodge {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: "miss",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate damage
|
||||
dmg := calcDamage(player.Stats.Attack, phase.AttackWeight, player.Mods.DamageBonus,
|
||||
enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct)
|
||||
|
||||
// Block: half damage
|
||||
blocked := enemy.Stats.BlockRate > 0 && rand.Float64() < enemy.Stats.BlockRate
|
||||
if blocked {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
|
||||
// Crit check
|
||||
critRate := player.Stats.CritRate
|
||||
if phaseName == "Decisive" {
|
||||
critRate *= 2
|
||||
}
|
||||
isCrit := false
|
||||
if st.autoCrit {
|
||||
isCrit = true
|
||||
st.autoCrit = false
|
||||
dmg *= 2
|
||||
} else if critRate > 0 && rand.Float64() < critRate {
|
||||
isCrit = true
|
||||
dmg *= 2
|
||||
}
|
||||
|
||||
dmg = max(1, dmg)
|
||||
|
||||
// Cleave handling for enemy is in resolveEnemyAttack
|
||||
action := "hit"
|
||||
if isCrit {
|
||||
action = "crit"
|
||||
} else if blocked {
|
||||
action = "block"
|
||||
}
|
||||
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: action,
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
return st.enemyHP <= 0
|
||||
}
|
||||
|
||||
func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult, petWhiff, petDeflect, sporeMiss bool) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Spore cloud round consumed when enemy attacks (even if whiffed/missed)
|
||||
if st.sporeRounds > 0 {
|
||||
st.sporeRounds--
|
||||
}
|
||||
|
||||
// Pet whiff → guaranteed miss
|
||||
if petWhiff {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_whiff",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Spore cloud miss
|
||||
if sporeMiss {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "spore_miss",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Player dodge
|
||||
playerDodge := player.Stats.DodgeRate * phase.SpeedWeight
|
||||
if playerDodge > 0 && rand.Float64() < playerDodge {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "miss",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Ward: absorb full hit
|
||||
if st.wardCharges > 0 {
|
||||
st.wardCharges--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "ward_absorb",
|
||||
Damage: 0, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
atkMult := 1.0
|
||||
if st.enraged {
|
||||
atkMult = 1.5
|
||||
}
|
||||
dmg := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||
|
||||
// Player block
|
||||
blocked := player.Stats.BlockRate > 0 && rand.Float64() < player.Stats.BlockRate
|
||||
if blocked {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
|
||||
// Crit
|
||||
critRate := enemy.Stats.CritRate
|
||||
if phaseName == "Decisive" {
|
||||
critRate *= 2
|
||||
}
|
||||
isCrit := critRate > 0 && rand.Float64() < critRate
|
||||
if isCrit {
|
||||
dmg *= 2
|
||||
}
|
||||
|
||||
dmg = max(1, dmg)
|
||||
|
||||
// Pet deflect: halve damage
|
||||
if petDeflect {
|
||||
dmg = max(1, dmg/2)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_deflect",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
action := "hit"
|
||||
if isCrit {
|
||||
action = "crit"
|
||||
} else if blocked {
|
||||
action = "block"
|
||||
}
|
||||
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: action,
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
// Reflect: bounce portion back (after player takes the hit)
|
||||
if st.reflectFrac > 0 {
|
||||
reflected := max(1, int(float64(dmg)*st.reflectFrac))
|
||||
st.reflectFrac = 0
|
||||
st.enemyHP = max(0, st.enemyHP-reflected)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "reflect_damage",
|
||||
Damage: reflected, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if st.playerHP <= 0 {
|
||||
return !trySave(st, player, phaseName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Monster Ability Logic ────────────────────────────────────────────────────
|
||||
|
||||
func abilityFires(ability *MonsterAbility, phaseName string, st *combatState) bool {
|
||||
phaseMatch := ability.Phase == "any" ||
|
||||
(ability.Phase == "opening" && phaseName == "Opening") ||
|
||||
(ability.Phase == "clash" && phaseName == "Clash") ||
|
||||
(ability.Phase == "decisive" && phaseName == "Decisive")
|
||||
if !phaseMatch {
|
||||
return false
|
||||
}
|
||||
return rand.Float64() < ability.ProcChance
|
||||
}
|
||||
|
||||
func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
ab := enemy.Ability
|
||||
phaseName := phase.Name
|
||||
|
||||
switch ab.Effect {
|
||||
case "poison":
|
||||
st.poisonTicks = 2
|
||||
st.poisonDmg = 3 + rand.IntN(3)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
case "enrage":
|
||||
if !st.enraged && st.enemyHP < int(float64(enemy.Stats.MaxHP)*0.4) {
|
||||
st.enraged = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "enrage",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
case "armor_break":
|
||||
if !st.armorBroken {
|
||||
st.armorBroken = true
|
||||
st.armorBreakAmt = 0.30
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "armor_break",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
case "stun":
|
||||
st.stunPlayer = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "stun",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
case "lifesteal":
|
||||
atkMult := 1.0
|
||||
if st.enraged {
|
||||
atkMult = 1.5
|
||||
}
|
||||
dmg := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||
dmg = max(1, dmg)
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
heal := dmg / 2
|
||||
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+heal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "lifesteal",
|
||||
Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
return !trySave(st, player, phaseName)
|
||||
}
|
||||
|
||||
case "cleave":
|
||||
atkMult := 1.0
|
||||
if st.enraged {
|
||||
atkMult = 1.5
|
||||
}
|
||||
dmg1 := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||
dmg1 = max(1, dmg1)
|
||||
st.playerHP = max(0, st.playerHP-dmg1)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "cleave",
|
||||
Damage: dmg1, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
// Death save fired — skip follow-up hit (survived the first blow barely)
|
||||
} else {
|
||||
dmg2 := max(1, dmg1/2)
|
||||
st.playerHP = max(0, st.playerHP-dmg2)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "cleave",
|
||||
Damage: dmg2, Desc: ab.Name + " (follow-up)", PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
return !trySave(st, player, phaseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// calcDamage uses a penetration model: defense provides diminishing returns
|
||||
// reduction rather than flat subtraction, so damage is never fully negated.
|
||||
// Formula: rawAtk * reduction where reduction = K / (K + effectiveDef).
|
||||
// K=40 means 40 defense halves incoming damage; 80 defense reduces by 67%.
|
||||
func calcDamage(attack int, atkWeight, dmgBonus float64, defense int, defWeight, dmgReduct float64) int {
|
||||
const K = 40.0
|
||||
rawAtk := float64(attack) * atkWeight * (1 + dmgBonus)
|
||||
effectiveDef := float64(defense) * defWeight * dmgReduct
|
||||
reduction := K / (K + effectiveDef)
|
||||
dmg := rawAtk * reduction
|
||||
if dmg < 1 {
|
||||
return 1
|
||||
}
|
||||
return int(dmg)
|
||||
}
|
||||
|
||||
func playerDefense(player *Combatant, st *combatState) int {
|
||||
def := player.Stats.Defense
|
||||
if st.armorBroken {
|
||||
def = int(float64(def) * (1 - st.armorBreakAmt))
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func trySave(st *combatState, player *Combatant, phaseName string) bool {
|
||||
if player.Mods.DeathSave && !st.deathSaveUsed {
|
||||
st.deathSaveUsed = true
|
||||
st.playerHP = 1
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: "death_save",
|
||||
PlayerHP: 1, EnemyHP: st.enemyHP, Desc: "Sovereign",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func finalize(result CombatResult, st *combatState, player, enemy Combatant) CombatResult {
|
||||
result.Events = st.events
|
||||
result.PlayerEndHP = st.playerHP
|
||||
result.EnemyEndHP = st.enemyHP
|
||||
result.TotalRounds = st.round
|
||||
result.PlayerWon = st.enemyHP <= 0
|
||||
|
||||
playerMax := max(1, player.Stats.MaxHP)
|
||||
enemyMax := max(1, enemy.Stats.MaxHP)
|
||||
if result.PlayerWon && st.playerHP > 0 {
|
||||
result.NearDeath = float64(st.playerHP) < float64(playerMax)*0.15
|
||||
winnerRemaining := float64(st.playerHP) / float64(playerMax)
|
||||
result.Closeness = 1.0 - winnerRemaining
|
||||
} else if !result.PlayerWon {
|
||||
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
|
||||
result.NearDeath = enemyRemaining < 0.15
|
||||
result.Closeness = 1.0 - enemyRemaining
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
579
internal/plugin/combat_engine_test.go
Normal file
579
internal/plugin/combat_engine_test.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func basePlayer() Combatant {
|
||||
return Combatant{
|
||||
Name: "Hero",
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 100,
|
||||
Attack: 15,
|
||||
Defense: 8,
|
||||
Speed: 10,
|
||||
CritRate: 0.05,
|
||||
DodgeRate: 0.05,
|
||||
BlockRate: 0.05,
|
||||
},
|
||||
Mods: CombatModifiers{DamageBonus: 0, DamageReduct: 1.0},
|
||||
}
|
||||
}
|
||||
|
||||
func baseEnemy() Combatant {
|
||||
return Combatant{
|
||||
Name: "Rat",
|
||||
Stats: CombatStats{
|
||||
MaxHP: 60,
|
||||
Attack: 10,
|
||||
Defense: 4,
|
||||
Speed: 6,
|
||||
CritRate: 0.03,
|
||||
DodgeRate: 0.02,
|
||||
BlockRate: 0.02,
|
||||
},
|
||||
Mods: CombatModifiers{DamageBonus: 0, DamageReduct: 1.0},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_BasicResolves(t *testing.T) {
|
||||
wins, losses := 0, 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
if r.PlayerWon {
|
||||
wins++
|
||||
} else {
|
||||
losses++
|
||||
}
|
||||
if r.TotalRounds == 0 && !r.SniperKilled {
|
||||
t.Fatal("combat resolved in 0 rounds without sniper")
|
||||
}
|
||||
if len(r.Events) == 0 {
|
||||
t.Fatal("no events generated")
|
||||
}
|
||||
}
|
||||
if wins == 0 {
|
||||
t.Error("player never won in 1000 fights against a weaker enemy")
|
||||
}
|
||||
if wins < 700 {
|
||||
t.Errorf("player should strongly favor wins vs weaker enemy, got %d/1000", wins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_HPTracking(t *testing.T) {
|
||||
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
if r.PlayerStartHP != 100 {
|
||||
t.Errorf("player start HP = %d, want 100", r.PlayerStartHP)
|
||||
}
|
||||
if r.EnemyStartHP != 60 {
|
||||
t.Errorf("enemy start HP = %d, want 60", r.EnemyStartHP)
|
||||
}
|
||||
if r.PlayerWon && r.EnemyEndHP != 0 {
|
||||
t.Errorf("player won but enemy HP = %d", r.EnemyEndHP)
|
||||
}
|
||||
if !r.PlayerWon && r.PlayerEndHP != 0 {
|
||||
t.Errorf("player lost but player HP = %d", r.PlayerEndHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_SniperKill(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.SniperKillProc = 1.0 // guaranteed
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
t.Error("guaranteed sniper should always win")
|
||||
}
|
||||
if !r.SniperKilled {
|
||||
t.Error("SniperKilled flag not set")
|
||||
}
|
||||
if len(r.Events) != 1 || r.Events[0].Action != "sniper_kill" {
|
||||
t.Error("expected exactly 1 sniper_kill event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_DeathSave(t *testing.T) {
|
||||
// Player with death save against a very strong enemy
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 30
|
||||
p.Mods.DeathSave = true
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 40
|
||||
e.Stats.MaxHP = 200
|
||||
|
||||
saves := 0
|
||||
for i := 0; i < 500; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "death_save" {
|
||||
saves++
|
||||
if ev.PlayerHP != 1 {
|
||||
t.Errorf("death save should set HP to 1, got %d", ev.PlayerHP)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if saves == 0 {
|
||||
t.Error("death save never triggered in 500 fights against strong enemy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_PetAttack(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.PetAttackProc = 1.0 // guaranteed every round
|
||||
p.Mods.PetAttackDmg = 10
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
if !r.PetAttacked {
|
||||
t.Error("pet attack should have triggered")
|
||||
}
|
||||
petHits := 0
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "pet_attack" {
|
||||
petHits++
|
||||
}
|
||||
}
|
||||
if petHits == 0 {
|
||||
t.Error("no pet_attack events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_PetWhiff(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.PetWhiffProc = 1.0 // guaranteed
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 5
|
||||
|
||||
whiffs := 0
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "pet_whiff" {
|
||||
whiffs++
|
||||
}
|
||||
}
|
||||
if whiffs == 0 {
|
||||
t.Error("pet whiff never triggered with 100% proc rate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_PetDeflect(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.PetDeflectProc = 1.0
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
if !r.PetDeflected {
|
||||
t.Error("pet deflect should have triggered")
|
||||
}
|
||||
deflects := 0
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "pet_deflect" {
|
||||
deflects++
|
||||
}
|
||||
}
|
||||
if deflects == 0 {
|
||||
t.Error("no pet_deflect events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_MistyHeal(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.MistyHealProc = 1.0
|
||||
p.Mods.MistyHealAmt = 10
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 15 // enough to do some damage
|
||||
|
||||
heals := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "misty_heal" {
|
||||
heals++
|
||||
}
|
||||
}
|
||||
}
|
||||
if heals == 0 {
|
||||
t.Error("Misty heal never triggered with 100% proc rate across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_FlatDmgStart(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.FlatDmgStart = 20
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 60
|
||||
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if len(r.Events) == 0 {
|
||||
t.Fatal("no events")
|
||||
}
|
||||
first := r.Events[0]
|
||||
if first.Action != "flat_damage" {
|
||||
t.Errorf("first event should be flat_damage, got %s", first.Action)
|
||||
}
|
||||
if first.Damage != 20 {
|
||||
t.Errorf("flat damage = %d, want 20", first.Damage)
|
||||
}
|
||||
if first.EnemyHP != 40 {
|
||||
t.Errorf("enemy HP after flat damage = %d, want 40", first.EnemyHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_FlatDmgKill(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.FlatDmgStart = 999
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 10
|
||||
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
t.Error("flat damage should kill a 10HP enemy")
|
||||
}
|
||||
if r.EnemyEndHP != 0 {
|
||||
t.Errorf("enemy HP = %d, want 0", r.EnemyEndHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_HealItem(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 100
|
||||
p.Mods.HealItem = 30
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 20 // will deal solid damage
|
||||
|
||||
heals := 0
|
||||
for i := 0; i < 200; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "heal_item" {
|
||||
heals++
|
||||
if ev.Damage != 30 {
|
||||
t.Errorf("heal amount = %d, want 30", ev.Damage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if heals == 0 {
|
||||
t.Error("heal item never triggered across 200 fights")
|
||||
}
|
||||
// Should trigger at most once per fight
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
count := 0
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "heal_item" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Errorf("heal item triggered %d times in one fight, should be at most 1", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_WardAbsorb(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.WardCharges = 1
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 30
|
||||
|
||||
wards := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "ward_absorb" {
|
||||
wards++
|
||||
if ev.Damage != 0 {
|
||||
t.Error("ward absorb should deal 0 damage")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if wards == 0 {
|
||||
t.Error("ward never activated across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_SporeCloud(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.SporeCloud = 10 // many rounds worth
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 15
|
||||
|
||||
spores := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "spore_miss" {
|
||||
spores++
|
||||
}
|
||||
}
|
||||
}
|
||||
if spores == 0 {
|
||||
t.Error("spore cloud never caused a miss across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_ReflectDamage(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.ReflectNext = 0.5
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 20
|
||||
|
||||
reflects := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "reflect_damage" {
|
||||
reflects++
|
||||
if ev.Damage <= 0 {
|
||||
t.Error("reflected damage should be positive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if reflects == 0 {
|
||||
t.Error("reflect never triggered across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_AutoCritFirst(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.AutoCritFirst = true
|
||||
p.Stats.CritRate = 0 // disable normal crits to isolate auto-crit
|
||||
|
||||
crits := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Actor == "player" && ev.Action == "crit" {
|
||||
crits++
|
||||
break // only count first per fight
|
||||
}
|
||||
}
|
||||
}
|
||||
if crits < 90 {
|
||||
t.Errorf("auto-crit should fire on first player hit in nearly every fight, got %d/100", crits)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monster Ability Tests ────────────────────────────────────────────────────
|
||||
|
||||
func TestSimulateCombat_Poison(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Venom Bite", Phase: "any", ProcChance: 1.0, Effect: "poison",
|
||||
}
|
||||
|
||||
ticks := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "poison_tick" {
|
||||
ticks++
|
||||
}
|
||||
}
|
||||
}
|
||||
if ticks == 0 {
|
||||
t.Error("poison ticks never occurred with guaranteed proc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Enrage(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Stats.Attack = 12
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 50 // 40% threshold = 20 HP, reachable before death
|
||||
e.Stats.Attack = 6 // weak so fights last long enough
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Berserk", Phase: "any", ProcChance: 1.0, Effect: "enrage",
|
||||
}
|
||||
|
||||
enrages := 0
|
||||
for i := 0; i < 500; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "enrage" {
|
||||
enrages++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if enrages == 0 {
|
||||
t.Error("enrage never triggered across 500 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_ArmorBreak(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Rend", Phase: "opening", ProcChance: 1.0, Effect: "armor_break",
|
||||
}
|
||||
|
||||
breaks := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "armor_break" {
|
||||
breaks++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if breaks == 0 {
|
||||
t.Error("armor break never triggered with guaranteed proc in opening")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Stun(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Bash", Phase: "any", ProcChance: 1.0, Effect: "stun",
|
||||
}
|
||||
|
||||
stuns := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "stun" {
|
||||
stuns++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if stuns == 0 {
|
||||
t.Error("stun never triggered")
|
||||
}
|
||||
// Check that "stunned" event follows a "stun"
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for i, ev := range r.Events {
|
||||
if ev.Action == "stun" {
|
||||
for j := i + 1; j < len(r.Events); j++ {
|
||||
if r.Events[j].Actor == "player" && r.Events[j].Action == "stunned" {
|
||||
return // found it
|
||||
}
|
||||
if r.Events[j].Actor == "player" && (r.Events[j].Action == "hit" || r.Events[j].Action == "crit" || r.Events[j].Action == "miss") {
|
||||
break // player acted before being stunned — could happen if stun fires and then round ends
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Lifesteal(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 100
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Drain", Phase: "any", ProcChance: 1.0, Effect: "lifesteal",
|
||||
}
|
||||
|
||||
steals := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "lifesteal" {
|
||||
steals++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if steals == 0 {
|
||||
t.Error("lifesteal never triggered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Cleave(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Cleave", Phase: "clash", ProcChance: 1.0, Effect: "cleave",
|
||||
}
|
||||
|
||||
cleaves := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "cleave" {
|
||||
cleaves++
|
||||
}
|
||||
}
|
||||
}
|
||||
// Should see pairs of cleave events (main + follow-up)
|
||||
if cleaves == 0 {
|
||||
t.Error("cleave never triggered")
|
||||
}
|
||||
if cleaves%2 != 0 {
|
||||
// Not strictly required since fights can end mid-cleave, but check it's usually paired
|
||||
t.Logf("cleave events = %d (odd count is possible if fight ends mid-cleave)", cleaves)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strong vs Weak ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestSimulateCombat_StrongEnemyWinsMostly(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 50
|
||||
p.Stats.Attack = 8
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 200
|
||||
e.Stats.Attack = 30
|
||||
e.Stats.Defense = 12
|
||||
|
||||
wins := 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if r.PlayerWon {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins > 150 {
|
||||
t.Errorf("player won %d/1000 against much stronger enemy — too high", wins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_DungeonPhasesShorter(t *testing.T) {
|
||||
totalDefault, totalDungeon := 0, 0
|
||||
for i := 0; i < 500; i++ {
|
||||
r1 := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
r2 := SimulateCombat(basePlayer(), baseEnemy(), dungeonCombatPhases)
|
||||
totalDefault += r1.TotalRounds
|
||||
totalDungeon += r2.TotalRounds
|
||||
}
|
||||
avgDefault := float64(totalDefault) / 500
|
||||
avgDungeon := float64(totalDungeon) / 500
|
||||
if avgDungeon >= avgDefault {
|
||||
t.Errorf("dungeon phases should produce fewer rounds on average: dungeon=%.1f, default=%.1f", avgDungeon, avgDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_ClosenessRange(t *testing.T) {
|
||||
for i := 0; i < 500; i++ {
|
||||
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
if r.Closeness < 0 || r.Closeness > 1.0 {
|
||||
t.Errorf("closeness out of range: %f", r.Closeness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_NearDeath(t *testing.T) {
|
||||
// Enemy strong enough to bring player near death regularly
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 80
|
||||
p.Stats.Attack = 12
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 60
|
||||
e.Stats.Attack = 18
|
||||
|
||||
nearDeaths := 0
|
||||
for i := 0; i < 2000; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if r.NearDeath {
|
||||
nearDeaths++
|
||||
}
|
||||
}
|
||||
if nearDeaths == 0 {
|
||||
t.Error("near death never occurred in 2000 fights")
|
||||
}
|
||||
}
|
||||
581
internal/plugin/combat_narrative.go
Normal file
581
internal/plugin/combat_narrative.go
Normal file
@@ -0,0 +1,581 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// actionPicker tracks used indices per pool to avoid repeats within a fight.
|
||||
type actionPicker struct {
|
||||
enemy map[int]bool
|
||||
player map[int]bool
|
||||
playerMiss map[int]bool
|
||||
block map[int]bool
|
||||
environment map[int]bool
|
||||
}
|
||||
|
||||
func newActionPicker() *actionPicker {
|
||||
return &actionPicker{
|
||||
enemy: make(map[int]bool),
|
||||
player: make(map[int]bool),
|
||||
playerMiss: make(map[int]bool),
|
||||
block: make(map[int]bool),
|
||||
environment: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func pickFrom(pool []string, used map[int]bool, damage int) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return fmt.Sprintf(pool[idx], damage)
|
||||
}
|
||||
|
||||
func pickFromNoFmt(pool []string, used map[int]bool) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return pool[idx]
|
||||
}
|
||||
|
||||
// RenderCombatLog converts a CombatResult into a slice of messages — one per
|
||||
// phase plus a final outcome message. The caller sends them with 5-8 second
|
||||
// delays between messages to create an unfolding fight feel.
|
||||
func RenderCombatLog(result CombatResult, playerName, enemyName string) []string {
|
||||
picker := newActionPicker()
|
||||
phases := groupEventsByPhase(result.Events)
|
||||
|
||||
var msgs []string
|
||||
for _, pg := range phases {
|
||||
msg := renderPhaseBlock(pg, playerName, enemyName, result, picker)
|
||||
if msg != "" {
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// RenderCombatLogArena is RenderCombatLog for arena fights.
|
||||
func RenderCombatLogArena(result CombatResult, playerName, enemyName string) []string {
|
||||
return RenderCombatLog(result, playerName, enemyName)
|
||||
}
|
||||
|
||||
type phaseGroup struct {
|
||||
Name string
|
||||
Events []CombatEvent
|
||||
}
|
||||
|
||||
func groupEventsByPhase(events []CombatEvent) []phaseGroup {
|
||||
var groups []phaseGroup
|
||||
var current *phaseGroup
|
||||
|
||||
for _, e := range events {
|
||||
if current == nil || current.Name != e.Phase {
|
||||
groups = append(groups, phaseGroup{Name: e.Phase})
|
||||
current = &groups[len(groups)-1]
|
||||
}
|
||||
current.Events = append(current.Events, e)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result CombatResult, picker *actionPicker) string {
|
||||
var sb strings.Builder
|
||||
|
||||
header := phaseHeader(pg.Name)
|
||||
sb.WriteString(header + "\n")
|
||||
|
||||
for _, e := range pg.Events {
|
||||
line := renderEvent(e, playerName, enemyName, result, picker)
|
||||
if line != "" {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(pg.Events) == 0 {
|
||||
return ""
|
||||
}
|
||||
lastEvent := pg.Events[len(pg.Events)-1]
|
||||
sb.WriteString(fmt.Sprintf(" [%s: %d/%d | %s: %d/%d]",
|
||||
playerName, clampHP(lastEvent.PlayerHP), result.PlayerStartHP,
|
||||
enemyName, clampHP(lastEvent.EnemyHP), result.EnemyStartHP))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func clampHP(hp int) int {
|
||||
if hp < 0 {
|
||||
return 0
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
func phaseHeader(name string) string {
|
||||
switch name {
|
||||
case "Opening", "opening":
|
||||
return pickRand(openingHeaders)
|
||||
case "Clash", "clash":
|
||||
return pickRand(clashHeaders)
|
||||
case "Decisive", "decisive":
|
||||
return pickRand(decisiveHeaders)
|
||||
case "pre_combat", "pre":
|
||||
return "⚔️ **The fight begins.**"
|
||||
case "exhaust":
|
||||
return "⚔️ **Exhaustion — Time Runs Out**"
|
||||
default:
|
||||
return "⚔️ **" + name + "**"
|
||||
}
|
||||
}
|
||||
|
||||
func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResult, picker *actionPicker) string {
|
||||
switch e.Action {
|
||||
case "hit":
|
||||
if e.Actor == "player" {
|
||||
return pickFrom(narrativePlayerHit, picker.player, e.Damage)
|
||||
}
|
||||
return pickFrom(narrativeEnemyHit, picker.enemy, e.Damage)
|
||||
|
||||
case "crit":
|
||||
if e.Actor == "player" {
|
||||
return pickFrom(narrativePlayerCrit, picker.player, e.Damage)
|
||||
}
|
||||
return pickFrom(narrativeEnemyCrit, picker.enemy, e.Damage)
|
||||
|
||||
case "block":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf(pickRand(narrativeEnemyBlock), e.Damage)
|
||||
}
|
||||
return fmt.Sprintf(pickRand(narrativePlayerBlock), e.Damage)
|
||||
|
||||
case "miss":
|
||||
return pickFromNoFmt(narrativeMiss, picker.playerMiss)
|
||||
|
||||
case "pet_attack":
|
||||
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
||||
|
||||
case "pet_deflect":
|
||||
return pickRand(narrativePetDeflect)
|
||||
|
||||
case "pet_whiff":
|
||||
return pickRand(narrativePetWhiff)
|
||||
|
||||
case "sniper_kill":
|
||||
return pickRand(narrativeSniperKill)
|
||||
|
||||
case "misty_heal":
|
||||
return fmt.Sprintf(pickRand(narrativeMistyHeal), e.Damage)
|
||||
|
||||
case "death_save":
|
||||
return pickRand(narrativeDeathSave)
|
||||
|
||||
case "environmental":
|
||||
return pickFrom(narrativeEnvironmental, picker.environment, e.Damage)
|
||||
|
||||
case "use_consumable":
|
||||
return renderConsumableUse(e.Desc)
|
||||
|
||||
case "consumable_skip":
|
||||
return "_Supplies conserved — threat assessed as manageable._"
|
||||
|
||||
case "ward_absorb":
|
||||
return pickRand(narrativeWardAbsorb)
|
||||
|
||||
case "spore_miss":
|
||||
return pickRand(narrativeSporeCloud)
|
||||
|
||||
case "reflect_damage":
|
||||
return fmt.Sprintf(pickRand(narrativeReflect), e.Damage)
|
||||
|
||||
case "crowd_revenge":
|
||||
return fmt.Sprintf(pickRand(narrativeCrowdRevenge), e.Damage)
|
||||
|
||||
case "flat_damage":
|
||||
return fmt.Sprintf(pickRand(narrativeFlatDmg), e.Damage)
|
||||
|
||||
case "heal_item":
|
||||
return fmt.Sprintf(pickRand(narrativeHealItem), e.Damage)
|
||||
|
||||
// Monster abilities
|
||||
case "poison_tick":
|
||||
return fmt.Sprintf(pickRand(narrativePoisonTick), e.Damage)
|
||||
case "enrage":
|
||||
return pickRand(narrativeEnrage)
|
||||
case "armor_break":
|
||||
return pickRand(narrativeArmorBreak)
|
||||
case "stun":
|
||||
return pickRand(narrativeStun)
|
||||
case "stunned":
|
||||
return pickRand(narrativeStunned)
|
||||
case "poison":
|
||||
return pickRand(narrativePoisonApply)
|
||||
case "lifesteal":
|
||||
return fmt.Sprintf(pickRand(narrativeLifesteal), e.Damage)
|
||||
case "cleave":
|
||||
return fmt.Sprintf(pickRand(narrativeCleave), e.Damage)
|
||||
|
||||
case "timeout":
|
||||
return pickRand(narrativeTimeout)
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderConsumableUse(itemName string) string {
|
||||
templates := []string{
|
||||
"You crack open a **%s**. The fight just got interesting.",
|
||||
"**%s** consumed. Its effects take hold immediately.",
|
||||
"You down a **%s** before things get worse.",
|
||||
"The **%s** does its work. You feel the difference.",
|
||||
}
|
||||
return fmt.Sprintf(templates[rand.IntN(len(templates))], itemName)
|
||||
}
|
||||
|
||||
func renderOutcome(result CombatResult, playerName, enemyName string, reward int64, xp int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if result.PlayerWon {
|
||||
sb.WriteString(fmt.Sprintf("💀 **%s** has been defeated.\n", enemyName))
|
||||
if result.NearDeath {
|
||||
sb.WriteString(pickRand(narrativeNearDeathWin) + "\n")
|
||||
} else if result.Closeness < 0.3 {
|
||||
sb.WriteString(pickRand(narrativeDominantWin) + "\n")
|
||||
} else {
|
||||
sb.WriteString(pickRand(narrativeCleanWin) + "\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned", xp, reward))
|
||||
} else {
|
||||
sb.WriteString("💀 **Defeated.**\n")
|
||||
if result.NearDeath {
|
||||
sb.WriteString(pickRand(narrativeNearDeathLoss) + "\n")
|
||||
} else if result.Closeness < 0.3 {
|
||||
sb.WriteString(pickRand(narrativeDominantLoss) + "\n")
|
||||
} else {
|
||||
sb.WriteString(pickRand(narrativeCloseLoss) + "\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("+%d XP (participation)", xp))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderArenaOutcome(result CombatResult, playerName, enemyName string, reward int64, xp int, tier, round int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if result.PlayerWon {
|
||||
sb.WriteString(fmt.Sprintf("💀 **%s** has been defeated.\n", enemyName))
|
||||
closer := arenaWinCloser(enemyName, round)
|
||||
sb.WriteString(closer + "\n")
|
||||
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned", xp, reward))
|
||||
} else {
|
||||
sb.WriteString("The healers are already moving.\n")
|
||||
sb.WriteString("💀 **Defeated.**\n")
|
||||
closer := arenaLoseCloser(enemyName, round)
|
||||
sb.WriteString(closer + "\n")
|
||||
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.", arenaParticipationXP))
|
||||
}
|
||||
|
||||
if result.SniperKilled {
|
||||
sb.WriteString("\n" + pickRand(narrativeSniperKill))
|
||||
}
|
||||
if result.MistyHealed {
|
||||
sb.WriteString("\n🌿 Misty's healing made the difference.")
|
||||
}
|
||||
if result.PetAttacked {
|
||||
sb.WriteString("\n🐾 Your pet contributed to the fight.")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func pickRand(pool []string) string {
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
// ── Phase Headers ────────────────────────────────────────────────────────────
|
||||
|
||||
var openingHeaders = []string{
|
||||
"⚔️ **Opening — The First Exchange**",
|
||||
"⚔️ **Opening — Testing the Waters**",
|
||||
"⚔️ **Opening — Both Sides Size Each Other Up**",
|
||||
"⚔️ **Opening — The Fight Begins**",
|
||||
}
|
||||
|
||||
var clashHeaders = []string{
|
||||
"⚔️ **Clash — The Real Fight**",
|
||||
"⚔️ **Clash — No More Warm-Ups**",
|
||||
"⚔️ **Clash — Steel Meets Steel**",
|
||||
"⚔️ **Clash — The Exchange Intensifies**",
|
||||
}
|
||||
|
||||
var decisiveHeaders = []string{
|
||||
"⚔️ **Decisive — One of You Isn't Walking Away**",
|
||||
"⚔️ **Decisive — The Final Blow**",
|
||||
"⚔️ **Decisive — Everything on the Line**",
|
||||
"⚔️ **Decisive — This Ends Now**",
|
||||
}
|
||||
|
||||
// ── Narrative Pools ──────────────────────────────────────────────────────────
|
||||
|
||||
var narrativePlayerHit = []string{
|
||||
"You connect cleanly. %d damage. Nothing fancy. Functional violence.",
|
||||
"A solid strike lands true. %d damage. The enemy reconsiders their career choices.",
|
||||
"You find an opening and take it. %d damage. The opening did not volunteer.",
|
||||
"Your weapon finds its mark. %d damage. Your weapon has better aim than you do most days.",
|
||||
"You press the advantage. %d damage. The advantage was already pressed. You pressed it further.",
|
||||
"A measured strike. %d damage. Nothing flashy. Just effective. The crowd is politely impressed.",
|
||||
"You swing and it connects where it matters. %d damage. Where it matters is the enemy's face.",
|
||||
"You hit them with a move you've been mentally rehearsing for weeks. %d damage. It worked. You're as surprised as they are.",
|
||||
"You land a clean hit for %d damage and immediately start explaining to no one how you did that.",
|
||||
"You score %d damage. The enemy seems fine. You are less fine about this than they are.",
|
||||
}
|
||||
|
||||
var narrativeEnemyHit = []string{
|
||||
"The enemy strikes back. %d damage. They were not asking permission.",
|
||||
"A hit gets through your guard. %d damage. Your guard had one job.",
|
||||
"The enemy finds a gap in your defense. %d damage. The gap is now a feature.",
|
||||
"You take a hit. %d damage. It stings. Your pride stings more.",
|
||||
"The enemy's weapon connects with something important. %d damage. It was you. You were the important thing.",
|
||||
"A blow you didn't see coming. %d damage. In fairness, you weren't looking.",
|
||||
"The enemy is faster than expected. %d damage. Your expectations need recalibrating.",
|
||||
"The enemy questions your life choices mid-swing. You pause to reflect. %d damage during the pause.",
|
||||
"The enemy yawns before hitting you. Not performatively. Genuinely. %d damage while you process the disrespect.",
|
||||
"The enemy hits you with what is technically the bare minimum of effort. %d damage. You gave it everything. They did not.",
|
||||
}
|
||||
|
||||
var narrativePlayerCrit = []string{
|
||||
"💥 **CRITICAL HIT.** You drive the blow home. %d damage. The crowd notices. The enemy notices more.",
|
||||
"💥 **CRIT!** Everything lined up perfectly. %d damage. You will never reproduce this.",
|
||||
"💥 A devastating strike. %d damage. You didn't know you had that in you. Neither did they.",
|
||||
"💥 You put everything behind this one. %d damage. It shows. It shows on their face specifically.",
|
||||
"💥 **CRIT!** %d damage. You look at your weapon like it just got a promotion.",
|
||||
}
|
||||
|
||||
var narrativeEnemyCrit = []string{
|
||||
"💥 **CRITICAL HIT** from the enemy. %d damage. That one rearranged something.",
|
||||
"💥 The enemy finds a weak point you didn't know you had. %d damage. Now you know.",
|
||||
"💥 A brutal strike. %d damage. You feel that one in places you didn't know could feel things.",
|
||||
"💥 The enemy's eyes narrow before a devastating blow. %d damage. The narrowing was a warning. You missed it.",
|
||||
"💥 **CRIT.** %d damage. The enemy doesn't even look impressed with themselves. That's worse.",
|
||||
}
|
||||
|
||||
var narrativeDodge = []string{
|
||||
"You sidestep at the last moment. Nothing lands. You style it out. Nobody is convinced.",
|
||||
"The attack passes through empty air. Clean dodge. You had no idea you could move like that.",
|
||||
"You read the movement and step aside. The enemy's weapon finds nothing but disappointment.",
|
||||
"Too slow. The strike finds nothing. The enemy retrieves their dignity from the floor.",
|
||||
"A dodge so clean it almost looks rehearsed. It was not. You were running away and it happened to work.",
|
||||
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was.",
|
||||
}
|
||||
|
||||
var narrativePlayerBlock = []string{
|
||||
"You brace and absorb the impact. Reduced to %d damage. Your arms disagree with this strategy.",
|
||||
"Your guard holds. Only %d damage gets through. The rest is your problem later.",
|
||||
"A solid block, but some force still connects. %d damage. Physics remains undefeated.",
|
||||
"You block it. %d damage still gets through. Blocking is more of a suggestion than a solution.",
|
||||
}
|
||||
|
||||
var narrativeEnemyBlock = []string{
|
||||
"The enemy blocks your strike. Only %d damage penetrates. They seem unimpressed.",
|
||||
"A partial block. %d damage — less than you wanted. Less than they deserved.",
|
||||
"The enemy's defense absorbs most of it. %d damage. The enemy's forearm speaks to the pathetic nature of your striking.",
|
||||
}
|
||||
|
||||
var narrativeMiss = []string{
|
||||
"Your swing goes wide. Nothing connects. The air you hit had it coming.",
|
||||
"The enemy anticipated that one. Complete miss. They're already bored.",
|
||||
"You overcommit and hit nothing but air. The air files no complaint.",
|
||||
"A whiff. It happens. It happens to you more than average.",
|
||||
"You attempt a move you saw in a film once. It does not work like in the film.",
|
||||
"You close your eyes for the strike because it feels more dramatic. You miss. Everything about this was predictable.",
|
||||
}
|
||||
|
||||
var narrativePetAttack = []string{
|
||||
"🐾 Your pet lunges at the enemy from absolutely nowhere. %d damage. Your pet has been waiting for this.",
|
||||
"🐾 Your pet strikes from the side with zero hesitation. %d additional damage. The enemy was not monitoring the pet. Mistake.",
|
||||
"🐾 Your pet joins the fray with a well-timed attack. %d damage. The timing was suspicious. Your pet may be smarter than you.",
|
||||
"🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.",
|
||||
}
|
||||
|
||||
var narrativePetDeflect = []string{
|
||||
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
||||
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
||||
"🐾 Your companion takes part of the hit for you. This is more loyalty than you deserve.",
|
||||
}
|
||||
|
||||
var narrativePetWhiff = []string{
|
||||
"🐾 Your pet distracts the enemy completely. Their attack goes wide. Your pet is not sorry.",
|
||||
"🐾 Your pet startles the enemy mid-swing. Complete miss. The enemy glares at the pet. The pet does not care.",
|
||||
"🐾 The enemy flinches at your pet's sudden movement. Missed entirely. Your pet sits back down like nothing happened.",
|
||||
}
|
||||
|
||||
var narrativeSniperKill = []string{
|
||||
"🎯 A shot rings out from the shadows. The enemy drops before the fight even starts. Arina collects her fee. She was leaving anyway.",
|
||||
"🎯 Arina's shot finds its mark. The enemy never knew what happened. Arina is already gone.",
|
||||
"🎯 One shot. One kill. Arina waves from somewhere you can't see. You wave back. She's already somewhere else.",
|
||||
}
|
||||
|
||||
var narrativeMistyHeal = []string{
|
||||
"🌿 Misty's presence soothes your wounds. +%d HP. She makes it look effortless because for her it is.",
|
||||
"🌿 A warm glow surrounds you. Misty restores %d HP. She doesn't explain how. You don't ask.",
|
||||
"🌿 Misty whispers something. You feel %d HP return. Whatever she said, it worked.",
|
||||
"🌿 The air shimmers. Misty heals you for %d HP. The enemy watches this happen and seems annoyed about it.",
|
||||
}
|
||||
|
||||
var narrativeDeathSave = []string{
|
||||
"👑 The Sovereign set flares with light. You refuse to fall. 1 HP remains. You should be dead. You are not. The set has opinions about your mortality.",
|
||||
"👑 Something keeps you standing. The Sovereign set burns bright. 1 HP. The enemy is visibly upset about this.",
|
||||
"👑 You should be dead. The Sovereign set disagrees. 1 HP. The disagreement is final.",
|
||||
}
|
||||
|
||||
var narrativeEnvironmental = []string{
|
||||
"The arena floor shifts beneath you. %d damage. The floor has no allegiance.",
|
||||
"A loose stone catches you off-balance. %d damage. The arena's maintenance budget is your problem now.",
|
||||
"Something in the environment works against you. %d damage. The environment was never on your side.",
|
||||
"The terrain betrays you. %d damage. It was never loyal to begin with.",
|
||||
"A hazard you didn't notice. %d damage. In fairness, you were busy being hit by other things.",
|
||||
"A bird lands between you and the enemy. Both combatants stop. The bird leaves. The enemy recovers first. %d damage.",
|
||||
"Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.",
|
||||
}
|
||||
|
||||
var narrativeWardAbsorb = []string{
|
||||
"✨ The Quartz Ward flares. The hit is absorbed completely. Money well spent.",
|
||||
"✨ Your ward shatters — but the damage doesn't reach you. One and done. Worth it.",
|
||||
"✨ The ward takes the hit. You don't. The ward has no opinions about this arrangement.",
|
||||
}
|
||||
|
||||
var narrativeSporeCloud = []string{
|
||||
"🍄 The spore cloud confuses the enemy. They swing at nothing. The nothing is grateful.",
|
||||
"🍄 Spores fill the air. The enemy can't find you. You're right here. They still can't find you.",
|
||||
"🍄 The enemy chokes on spores and misses entirely. They will have questions about this later.",
|
||||
}
|
||||
|
||||
var narrativeReflect = []string{
|
||||
"💎 The Voidstone Shard activates. %d damage reflected back. The enemy hit themselves, technically.",
|
||||
"💎 The enemy's own force is turned against them. %d reflected. They did this to themselves.",
|
||||
"💎 Half the blow bounces back. %d damage to the enemy. Karma. Immediate karma.",
|
||||
}
|
||||
|
||||
var narrativeTimeout = []string{
|
||||
"The fight drags on. Neither side will yield. The judges decide on points.",
|
||||
"Exhaustion sets in. Both fighters are spent. The one still standing taller takes it.",
|
||||
"Time runs out. The crowd is restless. The decision falls to whoever bled less.",
|
||||
}
|
||||
|
||||
var narrativeCrowdRevenge = []string{
|
||||
"🪨 The crowd throws something at you. %d damage. Misty's fans hold grudges.",
|
||||
"🪨 Someone in the stands got a clear shot. %d damage. You should have tipped better.",
|
||||
"🪨 The crowd is not on your side today. %d damage. Misty sends her regards.",
|
||||
}
|
||||
|
||||
var narrativeFlatDmg = []string{
|
||||
"💣 The Coal Bomb detonates. %d damage before the fight even starts. The enemy was not ready. That was the point.",
|
||||
"💣 An explosion kicks things off. %d damage to the enemy. First impressions matter.",
|
||||
}
|
||||
|
||||
var narrativeHealItem = []string{
|
||||
"🧪 You feel the potion take effect. +%d HP restored. Tastes terrible. Works perfectly.",
|
||||
"🧪 The salve kicks in. +%d HP. You don't know what's in it. You don't want to know.",
|
||||
"🧪 Your consumable heals you for %d HP mid-fight. The enemy watches you heal and seems personally offended.",
|
||||
}
|
||||
|
||||
// Monster abilities
|
||||
var narrativePoisonApply = []string{
|
||||
"☠️ The enemy's attack carries something extra. You feel it immediately. Poison.",
|
||||
"☠️ A venomous strike. The wound burns differently than it should. That's not good.",
|
||||
"☠️ Something coats the enemy's weapon. It's in you now. The burning starts.",
|
||||
}
|
||||
|
||||
var narrativePoisonTick = []string{
|
||||
"☠️ Venom courses through you. %d poison damage. Your blood has opinions about this.",
|
||||
"☠️ The poison burns. %d damage. It's not getting better on its own.",
|
||||
"☠️ You feel the toxin working. %d damage this round. Time is not on your side.",
|
||||
}
|
||||
|
||||
var narrativeEnrage = []string{
|
||||
"🔥 The enemy's eyes go red. Something has changed. They're stronger now. You preferred them before.",
|
||||
"🔥 **ENRAGE.** The enemy is wounded and furious. Attack increased. This was not the plan.",
|
||||
"🔥 Cornered and desperate, the enemy unleashes everything. Desperate enemies are the most dangerous kind.",
|
||||
}
|
||||
|
||||
var narrativeArmorBreak = []string{
|
||||
"🛡️💥 Your armor cracks under the impact. Defense reduced. That sound was expensive.",
|
||||
"🛡️💥 The enemy shatters your guard. Your defense won't hold like it did. Neither will your confidence.",
|
||||
"🛡️💥 **Armor Break.** You feel exposed. Because you are.",
|
||||
}
|
||||
|
||||
var narrativeStun = []string{
|
||||
"⚡ The enemy winds up something that looks bad. It is bad.",
|
||||
"⚡ The enemy prepares a stunning blow. Your immediate future just got complicated.",
|
||||
"⚡ The enemy's weapon crackles with intent. This is going to hurt differently.",
|
||||
}
|
||||
|
||||
var narrativeStunned = []string{
|
||||
"⚡ You're stunned. Can't move. Can't attack. Can barely think.",
|
||||
"⚡ The blow leaves you reeling. You lose your turn. You lose some dignity too.",
|
||||
"⚡ Everything goes white for a moment. You skip your attack. The moment passes. The opportunity does not return.",
|
||||
}
|
||||
|
||||
var narrativeLifesteal = []string{
|
||||
"🩸 The enemy drains your vitality. %d damage — and they heal. This is deeply unfair.",
|
||||
"🩸 You feel your strength being siphoned. %d damage, enemy heals. They're taking what's yours.",
|
||||
"🩸 **Lifesteal.** The enemy grows stronger as you weaken. %d damage. The math is going in the wrong direction.",
|
||||
}
|
||||
|
||||
var narrativeCleave = []string{
|
||||
"⚔️⚔️ The enemy strikes twice in rapid succession. %d damage from the double blow. That should not be legal.",
|
||||
"⚔️⚔️ **Cleave!** Two strikes before you can react. %d damage total. One hit was enough. They did two.",
|
||||
"⚔️⚔️ A devastating combo. %d damage from both hits. The second one was personal.",
|
||||
}
|
||||
|
||||
// Outcome flavor
|
||||
var narrativeNearDeathWin = []string{
|
||||
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
||||
"That was closer than anyone should be comfortable with. But you won. Technically. Medically questionable.",
|
||||
"The healers rush over. You wave them off. Mostly because you can't lift your arm to do anything else.",
|
||||
}
|
||||
|
||||
var narrativeDominantWin = []string{
|
||||
"Decisive. The enemy never had a chance. They knew it. You knew it. The crowd knew it.",
|
||||
"That wasn't a fight. That was a demonstration. The enemy was the visual aid.",
|
||||
"You barely broke a sweat. The enemy cannot say the same. The enemy cannot say much of anything right now.",
|
||||
}
|
||||
|
||||
var narrativeCleanWin = []string{
|
||||
"A solid fight. You came out on top. The enemy came out on a stretcher.",
|
||||
"Well fought. The outcome was never really in doubt. Just in question.",
|
||||
"The enemy put up a fight. You put up a better one. The scoreboard reflects this.",
|
||||
}
|
||||
|
||||
var narrativeNearDeathLoss = []string{
|
||||
"So close. One more hit and it would've gone the other way. One more hit didn't come.",
|
||||
"You almost had it. Almost doesn't count here. Almost doesn't count anywhere.",
|
||||
"A razor-thin margin. The wrong side of it. The margin doesn't care whose side it was.",
|
||||
}
|
||||
|
||||
var narrativeDominantLoss = []string{
|
||||
"That was over before it started. The starting was a formality.",
|
||||
"Outclassed. Completely. There's no gentle way to say it so here it is.",
|
||||
"Some fights aren't fights. This was one of those. You were present. That's about all.",
|
||||
}
|
||||
|
||||
var narrativeCloseLoss = []string{
|
||||
"A good fight. Not good enough. The scoreboard is indifferent to effort.",
|
||||
"You gave it everything. It wasn't quite sufficient. Sufficiency is the enemy's department today.",
|
||||
"The enemy earned that one. So did you. Only one of you gets paid.",
|
||||
}
|
||||
192
internal/plugin/combat_narrative_test.go
Normal file
192
internal/plugin/combat_narrative_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderCombatLog_ProducesMessages(t *testing.T) {
|
||||
player := Combatant{
|
||||
Name: "Hero",
|
||||
Stats: CombatStats{MaxHP: 100, Attack: 20, Defense: 10, Speed: 8, CritRate: 0.05},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: "Goblin",
|
||||
Stats: CombatStats{MaxHP: 60, Attack: 12, Defense: 5, Speed: 6, CritRate: 0.03},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
||||
msgs := RenderCombatLog(result, "Hero", "Goblin")
|
||||
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_PhaseHeaders(t *testing.T) {
|
||||
player := Combatant{
|
||||
Name: "Hero",
|
||||
Stats: CombatStats{MaxHP: 100, Attack: 20, Defense: 10, Speed: 8, CritRate: 0.05},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: "Rat",
|
||||
Stats: CombatStats{MaxHP: 40, Attack: 8, Defense: 3, Speed: 4},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
||||
msgs := RenderCombatLog(result, "Hero", "Rat")
|
||||
|
||||
hasOpening := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "Opening") {
|
||||
hasOpening = true
|
||||
}
|
||||
}
|
||||
if !hasOpening {
|
||||
t.Error("should have an Opening phase header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_WinPhaseMessages(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 50,
|
||||
PlayerEndHP: 80,
|
||||
EnemyEndHP: 0,
|
||||
TotalRounds: 3,
|
||||
Closeness: 0.2,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 25, EnemyHP: 25, PlayerHP: 100},
|
||||
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 25, EnemyHP: 0, PlayerHP: 80},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Slime")
|
||||
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
// Phase messages should contain damage numbers
|
||||
found := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "25") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("phase messages should contain damage values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_LossPhaseMessages(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: false,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 80,
|
||||
PlayerEndHP: 0,
|
||||
EnemyEndHP: 40,
|
||||
TotalRounds: 4,
|
||||
Closeness: 0.5,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "enemy", Action: "hit", Damage: 50, PlayerHP: 50, EnemyHP: 80},
|
||||
{Round: 2, Phase: "clash", Actor: "enemy", Action: "hit", Damage: 50, PlayerHP: 0, EnemyHP: 80},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Dragon")
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLogArena_ProducesPhaseMessages(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 60,
|
||||
PlayerEndHP: 70,
|
||||
EnemyEndHP: 0,
|
||||
TotalRounds: 3,
|
||||
Closeness: 0.3,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 30, PlayerHP: 100},
|
||||
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 0, PlayerHP: 70},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLogArena(result, "Hero", "Ratticus")
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_ConsumableEvents(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 60,
|
||||
PlayerEndHP: 80,
|
||||
EnemyEndHP: 0,
|
||||
Events: []CombatEvent{
|
||||
{Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable", Desc: "Goblin Grease"},
|
||||
{Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage", Damage: 8, PlayerHP: 100, EnemyHP: 52},
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, PlayerHP: 100, EnemyHP: 22},
|
||||
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 22, PlayerHP: 80, EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Imp")
|
||||
|
||||
found := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "Goblin Grease") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("should render consumable use event mentioning item name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_MonsterAbilityEvents(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 60,
|
||||
PlayerEndHP: 50,
|
||||
EnemyEndHP: 0,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "enemy", Action: "armor_break", Damage: 0, PlayerHP: 100, EnemyHP: 60},
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, PlayerHP: 100, EnemyHP: 30},
|
||||
{Round: 2, Phase: "clash", Actor: "enemy", Action: "hit", Damage: 25, PlayerHP: 75, EnemyHP: 30},
|
||||
{Round: 2, Phase: "clash", Actor: "enemy", Action: "poison_tick", Damage: 5, PlayerHP: 70, EnemyHP: 30},
|
||||
{Round: 3, Phase: "decisive", Actor: "player", Action: "crit", Damage: 30, PlayerHP: 50, EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Wyrm")
|
||||
|
||||
hasArmorBreak := false
|
||||
hasPoison := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "armor") || strings.Contains(m, "Armor") || strings.Contains(m, "defense") || strings.Contains(m, "Defense") {
|
||||
hasArmorBreak = true
|
||||
}
|
||||
if strings.Contains(m, "poison") || strings.Contains(m, "Poison") || strings.Contains(m, "toxin") || strings.Contains(m, "Venom") || strings.Contains(m, "venom") {
|
||||
hasPoison = true
|
||||
}
|
||||
}
|
||||
if !hasArmorBreak {
|
||||
t.Error("should render armor_break event")
|
||||
}
|
||||
if !hasPoison {
|
||||
t.Error("should render poison_tick event")
|
||||
}
|
||||
}
|
||||
222
internal/plugin/combat_stats.go
Normal file
222
internal/plugin/combat_stats.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DerivePlayerStats converts game-layer objects into the combat engine's stat model.
|
||||
func DerivePlayerStats(
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
bonuses *AdvBonusSummary,
|
||||
chatLevel int,
|
||||
streak int,
|
||||
hasGrudge bool,
|
||||
) (CombatStats, CombatModifiers) {
|
||||
arenaSets := advEquippedArenaSets(equip)
|
||||
|
||||
// Base stats from combat level
|
||||
stats := CombatStats{
|
||||
MaxHP: 50 + char.CombatLevel*2,
|
||||
Attack: 5 + char.CombatLevel + bonuses.CombatBonus,
|
||||
Defense: 3 + char.CombatLevel/2 + bonuses.CombatBonus/2,
|
||||
Speed: 5 + char.CombatLevel/3,
|
||||
CritRate: 0.03,
|
||||
DodgeRate: 0.02,
|
||||
BlockRate: 0.02,
|
||||
}
|
||||
|
||||
// Equipment contributions
|
||||
for _, slot := range allSlots {
|
||||
eq, ok := equip[slot]
|
||||
if !ok || eq == nil {
|
||||
continue
|
||||
}
|
||||
eTier := advEffectiveTier(eq)
|
||||
cond := 0.3 + 0.7*(float64(eq.Condition)/100.0) // smooth degradation curve
|
||||
effective := eTier * cond
|
||||
|
||||
switch slot {
|
||||
case SlotWeapon:
|
||||
stats.Attack += int(effective * 2)
|
||||
stats.CritRate += eTier * 0.005
|
||||
case SlotArmor:
|
||||
stats.Defense += int(effective * 1.5)
|
||||
stats.MaxHP += int(float64(stats.MaxHP) * eTier * 0.03)
|
||||
case SlotHelmet:
|
||||
stats.Defense += int(effective * 0.5)
|
||||
stats.DodgeRate += eTier * 0.01
|
||||
case SlotBoots:
|
||||
stats.Speed += int(effective)
|
||||
stats.DodgeRate += eTier * 0.005
|
||||
case SlotTool:
|
||||
stats.Attack += int(effective * 0.5)
|
||||
stats.BlockRate += eTier * 0.01
|
||||
}
|
||||
}
|
||||
|
||||
// Arena set bonuses
|
||||
if arenaSets["champions"] {
|
||||
stats.MaxHP = int(float64(stats.MaxHP) * 1.10)
|
||||
stats.Attack = int(float64(stats.Attack) * 1.10)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.10)
|
||||
stats.Speed = int(float64(stats.Speed) * 1.10)
|
||||
}
|
||||
if arenaSets["bloodied"] {
|
||||
stats.CritRate += 0.03
|
||||
}
|
||||
if arenaSets["ironclad"] {
|
||||
stats.MaxHP = int(float64(stats.MaxHP) * 1.05)
|
||||
}
|
||||
// Sovereign: handled via DeathSave modifier
|
||||
// Tempered: handled post-combat in degradation
|
||||
|
||||
// Housing HP bonus
|
||||
stats.MaxHP += int(float64(stats.MaxHP) * char.HouseHPBonus())
|
||||
|
||||
// Streak bonuses
|
||||
switch {
|
||||
case streak >= 30:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.20)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.15)
|
||||
case streak >= 14:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.15)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.10)
|
||||
case streak >= 7:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.10)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.05)
|
||||
case streak >= 3:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.05)
|
||||
}
|
||||
|
||||
// Grudge bonus
|
||||
if hasGrudge {
|
||||
stats.Attack = int(float64(stats.Attack) * 1.10)
|
||||
}
|
||||
|
||||
// Treasure bonuses mapped to stats
|
||||
if bonuses.DeathModifier < 0 {
|
||||
stats.Defense += int(-bonuses.DeathModifier * 2)
|
||||
}
|
||||
if bonuses.SuccessBonus > 0 {
|
||||
stats.Attack += int(bonuses.SuccessBonus * 0.5)
|
||||
}
|
||||
if bonuses.ExceptionalBonus > 0 {
|
||||
stats.CritRate += bonuses.ExceptionalBonus / 100.0
|
||||
}
|
||||
|
||||
// Chat level perks
|
||||
chatTier := chatLevel / 10
|
||||
if chatTier > 5 {
|
||||
chatTier = 5
|
||||
}
|
||||
stats.CritRate += float64(chatTier) * 0.005
|
||||
|
||||
// Cap rates
|
||||
if stats.CritRate > 0.50 {
|
||||
stats.CritRate = 0.50
|
||||
}
|
||||
if stats.DodgeRate > 0.40 {
|
||||
stats.DodgeRate = 0.40
|
||||
}
|
||||
if stats.BlockRate > 0.40 {
|
||||
stats.BlockRate = 0.40
|
||||
}
|
||||
|
||||
// Modifiers
|
||||
mods := CombatModifiers{
|
||||
DamageBonus: 0,
|
||||
DamageReduct: 1.0,
|
||||
}
|
||||
|
||||
// Streak damage reduction
|
||||
switch {
|
||||
case streak >= 30:
|
||||
mods.DamageReduct = 0.95
|
||||
case streak >= 14:
|
||||
mods.DamageReduct = 0.97
|
||||
}
|
||||
|
||||
// Sovereign death save
|
||||
if arenaSets["sovereign"] {
|
||||
if char.DeathReprieveLast == nil || time.Since(*char.DeathReprieveLast) >= 168*time.Hour {
|
||||
mods.DeathSave = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pet modifiers
|
||||
if char.HasPet() {
|
||||
mods.PetAttackProc = petAttackChance(char.PetLevel)
|
||||
mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine
|
||||
mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier)
|
||||
mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005
|
||||
}
|
||||
if char.PetMorningDefense {
|
||||
mods.DamageReduct *= 0.95 // 5% less damage
|
||||
}
|
||||
|
||||
// NPC debuffs
|
||||
now := time.Now().UTC()
|
||||
if char.MistyDebuffExpires != nil && now.Before(*char.MistyDebuffExpires) {
|
||||
mods.CrowdRevengeProc = 0.20
|
||||
mods.CrowdRevengeDmg = 3 + rand.IntN(6) // 3-8 damage
|
||||
}
|
||||
|
||||
// NPC buffs
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
mods.MistyHealProc = 0.20
|
||||
mods.MistyHealAmt = 8 + char.CombatLevel/5 + rand.IntN(5)
|
||||
}
|
||||
if char.ArinaBuffExpires != nil && now.Before(*char.ArinaBuffExpires) {
|
||||
mods.SniperKillProc = 0.08
|
||||
}
|
||||
|
||||
return stats, mods
|
||||
}
|
||||
|
||||
// DeriveArenaMonsterStats converts an ArenaMonster to combat engine stats.
|
||||
// Arena monsters face players with high combat level and arena-tier gear,
|
||||
// so stats must scale hard with ThreatLevel.
|
||||
func DeriveArenaMonsterStats(monster *ArenaMonster) (CombatStats, CombatModifiers) {
|
||||
tl := float64(monster.ThreatLevel)
|
||||
bl := monster.BaseLethality
|
||||
|
||||
stats := CombatStats{
|
||||
MaxHP: 40 + int(tl*4+bl*60),
|
||||
Attack: 8 + int(bl*40) + int(tl*0.8),
|
||||
Defense: 3 + int(tl*0.5+bl*10),
|
||||
Speed: 5 + int(tl*0.3),
|
||||
CritRate: bl * 0.20,
|
||||
DodgeRate: 0.02 + tl*0.003,
|
||||
BlockRate: 0.01 + bl*0.03,
|
||||
}
|
||||
mods := CombatModifiers{DamageBonus: 0, DamageReduct: 1.0}
|
||||
return stats, mods
|
||||
}
|
||||
|
||||
// DeriveDungeonMonsterStats synthesizes monster stats from a dungeon location.
|
||||
// Target: the stat block should produce a death rate roughly matching BaseDeathPct
|
||||
// when the player is at the location's MinLevel with tier-appropriate equipment.
|
||||
// The player typically has higher base stats, so monsters compensate with HP bulk
|
||||
// and just enough Attack to be threatening over the fight's duration.
|
||||
func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers) {
|
||||
t := float64(loc.Tier)
|
||||
death := loc.BaseDeathPct // 8 to 60
|
||||
|
||||
// Well-equipped players at the right level should be dominant (win 85-95%+).
|
||||
// Deaths come from bad luck: enemy crits, environmental hazards, unlucky initiative.
|
||||
// Monster Attack needs to be high enough to threaten over multiple rounds via
|
||||
// penetration damage, but not enough to kill quickly.
|
||||
stats := CombatStats{
|
||||
MaxHP: 25 + int(t*t*6+death*0.6), // quadratic: T1=33, T5=187
|
||||
Attack: 3 + int(death*0.2) + int(t*t*1.5), // quadratic: T1=5, T5=49
|
||||
Defense: 2 + int(t*1.5),
|
||||
Speed: 4 + int(t*1.5),
|
||||
CritRate: 0.03 + death*0.003,
|
||||
DodgeRate: 0.02 + t*0.008,
|
||||
BlockRate: 0.01 + t*0.005,
|
||||
}
|
||||
mods := CombatModifiers{DamageBonus: 0, DamageReduct: 1.0}
|
||||
return stats, mods
|
||||
}
|
||||
333
internal/plugin/combat_stats_test.go
Normal file
333
internal/plugin/combat_stats_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testEquip(tier int) map[EquipmentSlot]*AdvEquipment {
|
||||
equip := make(map[EquipmentSlot]*AdvEquipment)
|
||||
for _, slot := range allSlots {
|
||||
equip[slot] = &AdvEquipment{
|
||||
Slot: slot,
|
||||
Tier: tier,
|
||||
Condition: 100,
|
||||
}
|
||||
}
|
||||
return equip
|
||||
}
|
||||
|
||||
func testChar(combatLevel int) *AdventureCharacter {
|
||||
return &AdventureCharacter{
|
||||
CombatLevel: combatLevel,
|
||||
Alive: true,
|
||||
}
|
||||
}
|
||||
|
||||
func zeroBonuses() *AdvBonusSummary {
|
||||
return &AdvBonusSummary{}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_BaseStats(t *testing.T) {
|
||||
char := testChar(10)
|
||||
equip := testEquip(0)
|
||||
stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.MaxHP < 70 {
|
||||
t.Errorf("level 10 base MaxHP = %d, want >= 70", stats.MaxHP)
|
||||
}
|
||||
if stats.Attack < 15 {
|
||||
t.Errorf("level 10 base Attack = %d, want >= 15", stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_EquipmentScales(t *testing.T) {
|
||||
char := testChar(10)
|
||||
statsT1, _ := DerivePlayerStats(char, testEquip(1), zeroBonuses(), 0, 0, false)
|
||||
statsT3, _ := DerivePlayerStats(char, testEquip(3), zeroBonuses(), 0, 0, false)
|
||||
statsT5, _ := DerivePlayerStats(char, testEquip(5), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if statsT3.Attack <= statsT1.Attack {
|
||||
t.Errorf("T3 Attack (%d) should exceed T1 (%d)", statsT3.Attack, statsT1.Attack)
|
||||
}
|
||||
if statsT5.Attack <= statsT3.Attack {
|
||||
t.Errorf("T5 Attack (%d) should exceed T3 (%d)", statsT5.Attack, statsT3.Attack)
|
||||
}
|
||||
if statsT5.Defense <= statsT1.Defense {
|
||||
t.Errorf("T5 Defense (%d) should exceed T1 (%d)", statsT5.Defense, statsT1.Defense)
|
||||
}
|
||||
if statsT5.Speed <= statsT1.Speed {
|
||||
t.Errorf("T5 Speed (%d) should exceed T1 (%d)", statsT5.Speed, statsT1.Speed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_ConditionDegrades(t *testing.T) {
|
||||
char := testChar(10)
|
||||
fullEquip := testEquip(3)
|
||||
damagedEquip := testEquip(3)
|
||||
for _, eq := range damagedEquip {
|
||||
eq.Condition = 20
|
||||
}
|
||||
|
||||
statsFull, _ := DerivePlayerStats(char, fullEquip, zeroBonuses(), 0, 0, false)
|
||||
statsDmg, _ := DerivePlayerStats(char, damagedEquip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if statsDmg.Attack >= statsFull.Attack {
|
||||
t.Errorf("damaged Attack (%d) should be less than full (%d)", statsDmg.Attack, statsFull.Attack)
|
||||
}
|
||||
if statsDmg.Defense >= statsFull.Defense {
|
||||
t.Errorf("damaged Defense (%d) should be less than full (%d)", statsDmg.Defense, statsFull.Defense)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_ChampionsSet(t *testing.T) {
|
||||
char := testChar(20)
|
||||
equip := testEquip(3)
|
||||
for _, eq := range equip {
|
||||
eq.ArenaTier = 3
|
||||
eq.ArenaSet = "champions"
|
||||
}
|
||||
|
||||
stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
// Compare against same tier without set
|
||||
equip2 := testEquip(3)
|
||||
for _, eq := range equip2 {
|
||||
eq.ArenaTier = 3
|
||||
}
|
||||
statsNoSet, _ := DerivePlayerStats(char, equip2, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.MaxHP <= statsNoSet.MaxHP {
|
||||
t.Errorf("champions MaxHP (%d) should exceed no-set (%d)", stats.MaxHP, statsNoSet.MaxHP)
|
||||
}
|
||||
if stats.Attack <= statsNoSet.Attack {
|
||||
t.Errorf("champions Attack (%d) should exceed no-set (%d)", stats.Attack, statsNoSet.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_StreakBonuses(t *testing.T) {
|
||||
char := testChar(15)
|
||||
equip := testEquip(2)
|
||||
|
||||
stats0, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
stats7, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 7, false)
|
||||
stats30, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 30, false)
|
||||
|
||||
if stats7.Attack <= stats0.Attack {
|
||||
t.Errorf("7-day streak Attack (%d) should exceed 0-streak (%d)", stats7.Attack, stats0.Attack)
|
||||
}
|
||||
if stats30.Attack <= stats7.Attack {
|
||||
t.Errorf("30-day streak Attack (%d) should exceed 7-streak (%d)", stats30.Attack, stats7.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_GrudgeBonus(t *testing.T) {
|
||||
char := testChar(15)
|
||||
equip := testEquip(2)
|
||||
|
||||
statsNo, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
statsYes, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, true)
|
||||
|
||||
if statsYes.Attack <= statsNo.Attack {
|
||||
t.Errorf("grudge Attack (%d) should exceed no-grudge (%d)", statsYes.Attack, statsNo.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_HousingHP(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.HouseTier = 4 // +20% HP
|
||||
equip := testEquip(2)
|
||||
|
||||
stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
charNoHouse := testChar(10)
|
||||
statsNoHouse, _ := DerivePlayerStats(charNoHouse, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.MaxHP <= statsNoHouse.MaxHP {
|
||||
t.Errorf("T4 housing MaxHP (%d) should exceed no-house (%d)", stats.MaxHP, statsNoHouse.MaxHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_PetModifiers(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType = "cat"
|
||||
char.PetArrived = true
|
||||
char.PetLevel = 5
|
||||
|
||||
_, mods := DerivePlayerStats(char, testEquip(2), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if mods.PetAttackProc == 0 {
|
||||
t.Error("pet attack proc should be non-zero")
|
||||
}
|
||||
if mods.PetDeflectProc == 0 {
|
||||
t.Error("pet deflect proc should be non-zero")
|
||||
}
|
||||
if mods.PetWhiffProc == 0 {
|
||||
t.Error("pet whiff proc should be non-zero")
|
||||
}
|
||||
if mods.PetAttackDmg == 0 {
|
||||
t.Error("pet attack damage should be non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_ChatPerks(t *testing.T) {
|
||||
char := testChar(10)
|
||||
equip := testEquip(2)
|
||||
|
||||
stats0, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
stats50, _ := DerivePlayerStats(char, equip, zeroBonuses(), 50, 0, false)
|
||||
|
||||
if stats50.CritRate <= stats0.CritRate {
|
||||
t.Errorf("L50 chat CritRate (%.3f) should exceed L0 (%.3f)", stats50.CritRate, stats0.CritRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_RateCaps(t *testing.T) {
|
||||
char := testChar(50)
|
||||
equip := testEquip(5)
|
||||
for _, eq := range equip {
|
||||
eq.ArenaTier = 5
|
||||
eq.ArenaSet = "bloodied"
|
||||
}
|
||||
bonuses := &AdvBonusSummary{ExceptionalBonus: 50}
|
||||
|
||||
stats, _ := DerivePlayerStats(char, equip, bonuses, 50, 0, false)
|
||||
|
||||
if stats.CritRate > 0.50 {
|
||||
t.Errorf("CritRate %f exceeds 50%% cap", stats.CritRate)
|
||||
}
|
||||
if stats.DodgeRate > 0.40 {
|
||||
t.Errorf("DodgeRate %f exceeds 40%% cap", stats.DodgeRate)
|
||||
}
|
||||
if stats.BlockRate > 0.40 {
|
||||
t.Errorf("BlockRate %f exceeds 40%% cap", stats.BlockRate)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monster Stat Tests ───────────────────────────────────────────────────────
|
||||
|
||||
func TestDeriveArenaMonsterStats_Scales(t *testing.T) {
|
||||
weak := &ArenaMonster{Name: "Rat", BaseLethality: 0.10, ThreatLevel: 3}
|
||||
strong := &ArenaMonster{Name: "Dragon", BaseLethality: 0.85, ThreatLevel: 70}
|
||||
|
||||
sW, _ := DeriveArenaMonsterStats(weak)
|
||||
sS, _ := DeriveArenaMonsterStats(strong)
|
||||
|
||||
if sS.MaxHP <= sW.MaxHP {
|
||||
t.Error("strong monster should have more HP")
|
||||
}
|
||||
if sS.Attack <= sW.Attack {
|
||||
t.Error("strong monster should have more Attack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveDungeonMonsterStats_TierScaling(t *testing.T) {
|
||||
t1 := &AdvLocation{Tier: 1, BaseDeathPct: 8}
|
||||
t5 := &AdvLocation{Tier: 5, BaseDeathPct: 60}
|
||||
|
||||
s1, _ := DeriveDungeonMonsterStats(t1)
|
||||
s5, _ := DeriveDungeonMonsterStats(t5)
|
||||
|
||||
if s5.MaxHP <= s1.MaxHP {
|
||||
t.Error("T5 dungeon monster should have more HP than T1")
|
||||
}
|
||||
if s5.Attack <= s1.Attack {
|
||||
t.Error("T5 dungeon monster should have more Attack than T1")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Balance Regression: 10k Sims ─────────────────────────────────────────────
|
||||
|
||||
func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
|
||||
// Players at the right level with tier-appropriate gear should be dominant.
|
||||
// Death comes from bad luck (crits, environmental hazards) — not raw stat disadvantage.
|
||||
// Underleveled/underequipped players face higher death rates (tested separately).
|
||||
// These are "well-equipped at tier" baselines.
|
||||
type tc struct {
|
||||
level int
|
||||
equipT int
|
||||
dungeon AdvLocation
|
||||
maxDeath float64
|
||||
minDeath float64
|
||||
}
|
||||
cases := []tc{
|
||||
{5, 1, advDungeons[0], 0.05, 0.0}, // T1: trivial with proper gear
|
||||
{12, 2, advDungeons[1], 0.10, 0.0}, // T2: very easy at level
|
||||
{25, 3, advDungeons[2], 0.15, 0.0}, // T3: low risk at level with T3 gear
|
||||
{38, 4, advDungeons[3], 0.25, 0.0}, // T4: some risk even geared
|
||||
{48, 5, advDungeons[4], 0.35, 0.02}, // T5: real danger — monster stats catch up
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
char := testChar(c.level)
|
||||
equip := testEquip(c.equipT)
|
||||
bonuses := zeroBonuses()
|
||||
stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false)
|
||||
eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon)
|
||||
|
||||
player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true}
|
||||
enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods}
|
||||
|
||||
deaths := 0
|
||||
const N = 10000
|
||||
for i := 0; i < N; i++ {
|
||||
r := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
deaths++
|
||||
}
|
||||
}
|
||||
rate := float64(deaths) / float64(N)
|
||||
t.Logf("%s (L%d T%d): P[HP=%d Atk=%d Def=%d Spd=%d] E[HP=%d Atk=%d Def=%d Spd=%d] → death=%.2f",
|
||||
c.dungeon.Name, c.level, c.equipT,
|
||||
stats.MaxHP, stats.Attack, stats.Defense, stats.Speed,
|
||||
eStats.MaxHP, eStats.Attack, eStats.Defense, eStats.Speed,
|
||||
rate)
|
||||
if rate < c.minDeath || rate > c.maxDeath {
|
||||
t.Errorf(" FAIL: death rate %.2f outside [%.2f, %.2f]",
|
||||
rate, c.minDeath, c.maxDeath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceRegression_UnderleveledPlayers(t *testing.T) {
|
||||
// Underleveled / undergeared players should face real danger.
|
||||
type tc struct {
|
||||
name string
|
||||
level int
|
||||
equipT int
|
||||
dungeon AdvLocation
|
||||
minDeath float64
|
||||
}
|
||||
cases := []tc{
|
||||
{"L1 in T2 dungeon", 1, 0, advDungeons[1], 0.40},
|
||||
{"L8 in T3 dungeon", 8, 1, advDungeons[2], 0.30},
|
||||
{"L20 in T4 dungeon, T2 gear", 20, 2, advDungeons[3], 0.25},
|
||||
{"L30 in T5 dungeon, T3 gear", 30, 3, advDungeons[4], 0.30},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
char := testChar(c.level)
|
||||
equip := testEquip(c.equipT)
|
||||
bonuses := zeroBonuses()
|
||||
stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false)
|
||||
eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon)
|
||||
|
||||
player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true}
|
||||
enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods}
|
||||
|
||||
deaths := 0
|
||||
const N = 5000
|
||||
for i := 0; i < N; i++ {
|
||||
r := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
deaths++
|
||||
}
|
||||
}
|
||||
rate := float64(deaths) / float64(N)
|
||||
t.Logf("%s: P[HP=%d Atk=%d Def=%d] E[HP=%d Atk=%d Def=%d] → death=%.2f (want ≥%.2f)",
|
||||
c.name, stats.MaxHP, stats.Attack, stats.Defense,
|
||||
eStats.MaxHP, eStats.Attack, eStats.Defense, rate, c.minDeath)
|
||||
if rate < c.minDeath {
|
||||
t.Errorf(" FAIL: death rate %.2f below minimum %.2f", rate, c.minDeath)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user