mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
An Established (Tier-4) home can draw a second companion. Both pets show up on the sheet and the town showcase, level via babysitting, and wear their own barding (!thom pet2buy <tier>). Combat: both pets contribute their attack/deflect/whiff procs at half weight — DerivePlayerStats now averages over the active pets, so a pair reads as roughly one full pet (flavor-forward, not a stat spike; difficulty-neutral). The average reduces to identity over a single pet (x/1==x, 0.0+x==x, 3+(2L+1)/2==3+L), and the engines still roll one proc per channel per round, so the single-pet path and TestCombatCharacterization golden stay byte-identical. Pinned by TestDerivePlayerStats_OnePetIsByteIdentical + the golden. Scope kept deliberately flavor-forward: pet 2 carries identity/level/barding and the three combat procs only. The morning-defense buff, death/ditch-recovery save, and the level-10 supply-shop unlock stay pet-1 mechanics — no second defensive multiplier stacks, and the one-pet path is untouched by construction. Storage: parallel pet2_* columns on player_meta + Pet2* mirror on AdventureCharacter (absent == no second pet, DEFAULT '' correct for every pre-existing row, no backfill — the N2-temper / P4-party precedent). Pet-1 code paths are untouched. Arrival reuses the chase/feed/type/name DM flow, slot-aware, gated HouseTier>=4 with an established first pet. Review fixes folded in (high-effort /code-review, 3 angles): pet-2 barding block no longer hidden when pet-1 is chased away; showcase tie-break restored (level desc, then name); petGrantXP collapsed onto the shared level-up helper; dead-param armor-buy wrappers inlined; pet-2 barding tagged with its own euro ledger reason. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
253 lines
7.7 KiB
Go
253 lines
7.7 KiB
Go
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)
|
||
|
||
// Stat baselines. CombatLevel is dead — player power keys off gear here
|
||
// and dnd_level / ability scores via applyDnDPlayerLayer afterwards.
|
||
// MaxHP is computed below only so the armor% / arena% / housing% bonus
|
||
// formulas have something to scale against; the base is subtracted out
|
||
// when we capture stats.HPBonus, so it never reaches combat.
|
||
const legacyBase = 50
|
||
stats := CombatStats{
|
||
MaxHP: legacyBase,
|
||
Attack: 5 + bonuses.CombatBonus,
|
||
Defense: 3 + bonuses.CombatBonus/2,
|
||
Speed: 5,
|
||
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())
|
||
|
||
// Capture the equipment/arena/housing HP delta as fight-only headroom.
|
||
// applyDnDPlayerLayer adds this to c.HPMax to form combat MaxHP — gear
|
||
// power preserved, dnd_character.hp_current is the canonical wound store
|
||
// with no scale conversion. The legacy 50+CL*2 base is intentionally
|
||
// dropped; monster damage is tuned to the dnd HP scale.
|
||
stats.HPBonus = stats.MaxHP - legacyBase
|
||
|
||
// 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. Two pets each contribute at half weight — the combat mods
|
||
// are an average over the active pets, so a pair reads as roughly one full
|
||
// pet (flavor-forward, not a stat spike). A lone pet averages over one
|
||
// element, so x/1==x and 0.0+x==x reproduce the former values exactly,
|
||
// keeping the single-pet combat golden byte-identical. The engines roll one
|
||
// attack/deflect/whiff off these mods per round regardless of pet count, so
|
||
// the RNG draw order is unchanged too.
|
||
pets := make([]struct{ level, armor int }, 0, 2)
|
||
if char.HasPet() {
|
||
pets = append(pets, struct{ level, armor int }{char.PetLevel, char.PetArmorTier})
|
||
}
|
||
if char.HasPet2() {
|
||
pets = append(pets, struct{ level, armor int }{char.Pet2Level, char.Pet2ArmorTier})
|
||
}
|
||
if n := len(pets); n > 0 {
|
||
var atk, defl, whiff float64
|
||
levelSum := 0
|
||
for _, pt := range pets {
|
||
atk += petAttackChance(pt.level)
|
||
defl += petDeflectChance(pt.level, pt.armor)
|
||
whiff += 0.01 + float64(pt.level)*0.005
|
||
levelSum += pt.level
|
||
}
|
||
mods.PetAttackProc = atk / float64(n)
|
||
mods.PetDeflectProc = defl / float64(n)
|
||
mods.PetWhiffProc = whiff / float64(n)
|
||
mods.PetAttackDmg = 3 + (levelSum*2+n)/(2*n) // 3 + rounded average level
|
||
}
|
||
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.
|
||
// Tuned to the dnd HP scale (post HP-unification): well-equipped players at
|
||
// the location's MinLevel should win the vast majority of fights, with deaths
|
||
// coming from bad luck (crits, hazards, initiative). Old quadratic Attack
|
||
// formula assumed legacy ~100 HP players; the new linear scaling matches the
|
||
// ~12–80 HP range of dnd-scale fighters.
|
||
func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers) {
|
||
t := float64(loc.Tier)
|
||
death := loc.BaseDeathPct // 8 to 60
|
||
|
||
stats := CombatStats{
|
||
MaxHP: 12 + int(t*7+death*0.3), // T1≈22, T5≈65 — sized so dnd-scale players can finish a kill within the phase budget
|
||
Attack: int(t*1.5) + int(death*0.04), // T1=1, T5=9 — tuned to dnd HP pools (players have 13–80 HP, not 100+)
|
||
Defense: 2 + int(t*1.2),
|
||
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
|
||
}
|