mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Reshapes the legacy ArenaMonster data into a boss-shaped arenaBosses map of DnDMonsterTemplate entries — the carrier the upcoming resolveArenaBoss (step 4) will hand to runZoneCombat + renderBossOutcome. IDs namespaced "arena_t<tier>_r<round>" so they don't collide with dndBestiary. HP/AC/Attack scale with tier via arenaTierBaseStats; within a tier, BaseLethality biases each monster by a ±30% band so round-1 anchors the bottom of the tier and round-4 the top. Stats are first-pass — final tuning happens during the ARENA_BOSS_FLOW flag soak. arenaBossPhaseTwoAt(tier) gates phase-two narration: T1–T2 = none, T3+ = 50% HP. The T4–T5 flavor barb piggybacks on the existing bossPhaseTwoPool path through ZoneArena. No callers yet — wiring lands in step 4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
Go
302 lines
12 KiB
Go
package plugin
|
||
|
||
import "fmt"
|
||
|
||
// ── Arena Tier & Monster Definitions ────────────────────────────────────────
|
||
//
|
||
// Five tiers, four monsters each. Death chance, rewards, and XP scale with tier.
|
||
// Monster data from gogobee-arena.md spec.
|
||
|
||
type ArenaTier struct {
|
||
Number int
|
||
Name string
|
||
MinLevel int
|
||
BasePayout int64
|
||
SkillMultiplier float64
|
||
CompletionBonus int64
|
||
BattleXP int
|
||
Monsters [4]ArenaMonster
|
||
}
|
||
|
||
type ArenaMonster struct {
|
||
Name string
|
||
Flavor string
|
||
BaseLethality float64
|
||
ThreatLevel int
|
||
Ability *MonsterAbility // nil = no special ability
|
||
}
|
||
|
||
var arenaTiers = [5]ArenaTier{
|
||
// Tier 1 — Scrubs
|
||
{
|
||
Number: 1, Name: "Scrubs", MinLevel: 1,
|
||
BasePayout: 150, SkillMultiplier: 1.0, CompletionBonus: 2500, BattleXP: 10,
|
||
Monsters: [4]ArenaMonster{
|
||
{
|
||
Name: "Gelatinous Homunculus",
|
||
Flavor: "A translucent blob of vague menace. Has been in every dungeon you've ever visited. Doesn't know why it's here.",
|
||
BaseLethality: 0.10, ThreatLevel: 2,
|
||
},
|
||
{
|
||
Name: "Ratticus the Persistent",
|
||
Flavor: "A rat of unusual confidence. Has survived seventeen previous adventurers through sheer stubbornness.",
|
||
BaseLethality: 0.18, ThreatLevel: 5,
|
||
},
|
||
{
|
||
Name: "Roadside Entrepreneur",
|
||
Flavor: "A bandit who has been robbing travelers at this exact location since approximately forever. Very committed to the bit.",
|
||
BaseLethality: 0.28, ThreatLevel: 8,
|
||
},
|
||
{
|
||
Name: "Leafhopper Exemplar",
|
||
Flavor: "An insect of improbable aggression. Scientifically should not be a threat. Is one anyway. Earthbound energy.",
|
||
BaseLethality: 0.38, ThreatLevel: 12,
|
||
},
|
||
},
|
||
},
|
||
// Tier 2 — Thugs
|
||
{
|
||
Number: 2, Name: "Thugs", MinLevel: 10,
|
||
BasePayout: 500, SkillMultiplier: 2.5, CompletionBonus: 10000, BattleXP: 25,
|
||
Monsters: [4]ArenaMonster{
|
||
{
|
||
Name: "The Maestro of the Underchamber",
|
||
Flavor: "An operatic entity of considerable mass and questionable composition who rules his domain with a baritone and an inexhaustible supply of sweet corn. Not technically an RPG villain. Here anyway.",
|
||
BaseLethality: 0.35, ThreatLevel: 16,
|
||
},
|
||
{
|
||
Name: "Lycanthropic Freelancer",
|
||
Flavor: "A werewolf between contracts. Takes the full moon very seriously. Will eat you on principle.",
|
||
BaseLethality: 0.45, ThreatLevel: 20,
|
||
},
|
||
{
|
||
Name: "The Impersonator",
|
||
Flavor: "A chest. Definitely a chest. Please open it. (Do not open it.)",
|
||
BaseLethality: 0.55, ThreatLevel: 25,
|
||
},
|
||
{
|
||
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"},
|
||
},
|
||
},
|
||
},
|
||
// Tier 3 — Brutes
|
||
{
|
||
Number: 3, Name: "Brutes", MinLevel: 25,
|
||
BasePayout: 1500, SkillMultiplier: 6.0, CompletionBonus: 30000, BattleXP: 60,
|
||
Monsters: [4]ArenaMonster{
|
||
{
|
||
Name: "Stonefist the Unconvinced",
|
||
Flavor: "A golem who has heard every argument for sparing adventurers and found all of them unpersuasive.",
|
||
BaseLethality: 0.55, ThreatLevel: 35,
|
||
},
|
||
{
|
||
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",
|
||
Flavor: "Not quite a Behemoth. Close enough to ruin your day. Appears to have escaped from a larger game.",
|
||
BaseLethality: 0.73, ThreatLevel: 48,
|
||
},
|
||
{
|
||
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"},
|
||
},
|
||
},
|
||
},
|
||
// Tier 4 — Horrors
|
||
{
|
||
Number: 4, Name: "Horrors", MinLevel: 45,
|
||
BasePayout: 5000, SkillMultiplier: 12.0, CompletionBonus: 100000, BattleXP: 120,
|
||
Monsters: [4]ArenaMonster{
|
||
{
|
||
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",
|
||
Flavor: "A daedric-adjacent entity that crossed over from a realm adjacent to yours and immediately became your problem. Very well dressed.",
|
||
BaseLethality: 0.80, ThreatLevel: 70,
|
||
},
|
||
{
|
||
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"},
|
||
},
|
||
},
|
||
},
|
||
// Tier 5 — World Eaters
|
||
{
|
||
Number: 5, Name: "World Eaters", MinLevel: 70,
|
||
BasePayout: 15000, SkillMultiplier: 25.0, CompletionBonus: 500000, BattleXP: 250,
|
||
Monsters: [4]ArenaMonster{
|
||
{
|
||
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"},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
// arenaGetTier returns the tier definition for a 1-indexed tier number.
|
||
// Returns nil if the tier number is out of range.
|
||
func arenaGetTier(tier int) *ArenaTier {
|
||
if tier < 1 || tier > 5 {
|
||
return nil
|
||
}
|
||
return &arenaTiers[tier-1]
|
||
}
|
||
|
||
// arenaGetMonster returns the monster for the given tier/round (both 1-indexed).
|
||
func arenaGetMonster(tier, round int) *ArenaMonster {
|
||
t := arenaGetTier(tier)
|
||
if t == nil || round < 1 || round > 4 {
|
||
return nil
|
||
}
|
||
return &t.Monsters[round-1]
|
||
}
|
||
|
||
// ── Adv 2.0 boss-flow bestiary (Phase L2 step 3) ────────────────────────────
|
||
//
|
||
// arenaBosses re-shapes the legacy arenaTiers data into boss-shaped
|
||
// DnDMonsterTemplate entries so resolveArenaBoss (Phase L2 step 4) can
|
||
// run an arena fight through the same runZoneCombat → renderBossOutcome
|
||
// flow that zone bosses use. Keyed by arenaBossID(tier, round) — IDs
|
||
// are namespaced "arena_t<tier>_r<round>" so they don't collide with
|
||
// dndBestiary keys.
|
||
//
|
||
// HP/AC/Attack are first-pass tier-banded values; BaseLethality biases
|
||
// each monster up or down within its tier band so round-1 is the
|
||
// weakest fight and round-4 is the cap. Final tuning happens during
|
||
// the ARENA_BOSS_FLOW flag soak (gogobee_legacy_migration.md §4 Risk).
|
||
var arenaBosses = map[string]DnDMonsterTemplate{}
|
||
|
||
// arenaBossID composes the canonical arena bestiary key.
|
||
func arenaBossID(tier, round int) string {
|
||
return fmt.Sprintf("arena_t%d_r%d", tier, round)
|
||
}
|
||
|
||
// arenaBossPhaseTwoAt returns the PhaseTwoAt fraction (of MaxHP) for an
|
||
// arena fight at the given tier. T1–T2 = no phase two; T3+ = 50% HP.
|
||
// The T4–T5 flavor barb piggybacks on the standard phase-two narration
|
||
// path through ZoneArena's bossPhaseTwoPool (populated alongside arena
|
||
// flavor when those lines land).
|
||
func arenaBossPhaseTwoAt(tier int) float64 {
|
||
if tier >= 3 {
|
||
return 0.5
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// arenaTierBaseStats returns the (HP, AC, Attack, AttackBonus, CR)
|
||
// baseline for a given arena tier. arenaMonsterToTemplate then biases
|
||
// HP/Attack by BaseLethality so within-tier ordering matches the legacy
|
||
// difficulty curve.
|
||
func arenaTierBaseStats(tier int) (hp, ac, atk, ab int, cr float32) {
|
||
switch tier {
|
||
case 1:
|
||
return 30, 13, 8, 4, 1
|
||
case 2:
|
||
return 80, 14, 14, 6, 4
|
||
case 3:
|
||
return 160, 15, 22, 7, 8
|
||
case 4:
|
||
return 260, 16, 32, 8, 13
|
||
case 5:
|
||
return 400, 18, 45, 10, 18
|
||
}
|
||
return 30, 13, 8, 4, 1
|
||
}
|
||
|
||
// arenaMonsterToTemplate maps a legacy ArenaMonster onto a
|
||
// DnDMonsterTemplate. HP/AC/Attack scale with tier; within a tier,
|
||
// BaseLethality biases the curve via a ±30% band so round-1 sits at
|
||
// the bottom of the tier and round-4 at the top.
|
||
func arenaMonsterToTemplate(id string, m ArenaMonster, tier int) DnDMonsterTemplate {
|
||
hp, ac, atk, ab, cr := arenaTierBaseStats(tier)
|
||
bias := (m.BaseLethality - 0.5) * 0.6 // ≈ -0.24 .. +0.29
|
||
hp = int(float64(hp) * (1 + bias))
|
||
atk = int(float64(atk) * (1 + bias))
|
||
if hp < 1 {
|
||
hp = 1
|
||
}
|
||
if atk < 1 {
|
||
atk = 1
|
||
}
|
||
return DnDMonsterTemplate{
|
||
ID: id, Name: m.Name, CR: cr,
|
||
HP: hp, AC: ac, Attack: atk, AttackBonus: ab, Speed: 12,
|
||
BlockRate: 0.05,
|
||
Ability: m.Ability,
|
||
XPValue: arenaTiers[tier-1].BattleXP * 4,
|
||
Notes: m.Flavor,
|
||
}
|
||
}
|
||
|
||
func init() {
|
||
for ti, t := range arenaTiers {
|
||
tier := ti + 1
|
||
for ri, m := range t.Monsters {
|
||
id := arenaBossID(tier, ri+1)
|
||
arenaBosses[id] = arenaMonsterToTemplate(id, m, tier)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Arena Death Flavor Text ─────────────────────────────────────────────────
|
||
|
||
var arenaDeathMessages = []string{
|
||
"The Arena has collected its fee.",
|
||
"GogoBee notes your performance for the record. The record is not flattering.",
|
||
"Your earnings have been redistributed to the house. The house always wins.",
|
||
"The crowd has already forgotten your name.",
|
||
"A brief silence. Then the next challenger is called.",
|
||
"The Arena floor is cleaned. It takes less time than expected.",
|
||
"Your equipment will be returned to your next of kin. They will be disappointed by it.",
|
||
"Statistics updated. Morale not applicable.",
|
||
}
|
||
|
||
// arenaMonsterDeathMessages are templates with {monster}, {round}, {tier} placeholders.
|
||
var arenaMonsterDeathMessages = []string{
|
||
"{monster} has ended your run. Your earnings have been redistributed to the house.",
|
||
"You made it to Round {round} of Tier {tier}. {monster} was not impressed.",
|
||
"{monster} didn't even use their best material. Round {round}. Tier {tier}. Done.",
|
||
"The last thing you see is {monster}. The last thing they see is lunch.",
|
||
}
|