mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint: Phases 1-8 (per gogobee_dnd_session_summary.md): - Race/class/stats system, !setup flow, !sheet, !respec - d20-vs-AC combat with race/class passives, auto-migration - XP/level-up, skill checks, NPC refund hooks - Equipment slot mapping, rarity, !rest short/long - Pre-arm active abilities, resource pools - Bestiary, !roll/!stats/!level, onboarding DM - Audit fixes A-I, flavor wiring under internal/flavor/ - Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix) Phase 9 SP1 — spell system foundations: - 79 SpellDefinitions (76 in-scope + 3 reaction-deferred) - dnd_known_spells, dnd_spell_slots tables - pending_cast / concentration_* columns on dnd_character - Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math - Long-rest refreshes slots; respec wipes spell state - Auto-grant default known list on !setup confirm and auto-migration Phase 9 SP2 — !cast / !spells / !prepare commands: - Out-of-combat HEAL and UTILITY resolve immediately - Damage/control/buff queue as pending_cast for next combat - Audit-style save-then-debit, slot refund on save failure - !cast --drop, concentration supersession, prep-cap enforcement - Reaction spells refused with Phase 11 note SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn tests) are next. Build clean, full test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
@@ -643,6 +644,139 @@ type AdvActionResult struct {
|
||||
NearDeath bool
|
||||
StreakBonus int
|
||||
CombatLog *CombatResult
|
||||
// MasteryCrossings records equipment slots whose ActionsUsed crossed a
|
||||
// mastery threshold (advMasteryThresholds) on this action. The caller
|
||||
// uses this to DM a celebration so the silent counter becomes visible.
|
||||
MasteryCrossings []AdvMasteryCrossing
|
||||
}
|
||||
|
||||
type AdvMasteryCrossing struct {
|
||||
Slot EquipmentSlot
|
||||
ItemName string
|
||||
Threshold int // 50, 100, or 250
|
||||
}
|
||||
|
||||
// advMasteryThresholds defines the action counts at which equipment mastery
|
||||
// is celebrated. Picked to span an early-game milestone (50), a mid-game
|
||||
// commitment (100), and a hard-to-reach veteran tier (250).
|
||||
var advMasteryThresholds = []int{50, 100, 250}
|
||||
|
||||
// advMasteryTier reports how many mastery thresholds a piece has crossed
|
||||
// (0–3) and the next threshold. nextThreshold is 0 when the piece has
|
||||
// already crossed every threshold.
|
||||
func advMasteryTier(actionsUsed int) (tier, nextThreshold int) {
|
||||
for _, t := range advMasteryThresholds {
|
||||
if actionsUsed >= t {
|
||||
tier++
|
||||
} else {
|
||||
nextThreshold = t
|
||||
break
|
||||
}
|
||||
}
|
||||
return tier, nextThreshold
|
||||
}
|
||||
|
||||
// advMasteryMarker returns the visual tier marker for a piece of equipment.
|
||||
// Empty for sub-50; ✦/✦✦/✦✦✦ at the 50/100/250 thresholds.
|
||||
func advMasteryMarker(actionsUsed int) string {
|
||||
tier, _ := advMasteryTier(actionsUsed)
|
||||
switch tier {
|
||||
case 1:
|
||||
return "✦"
|
||||
case 2:
|
||||
return "✦✦"
|
||||
case 3:
|
||||
return "✦✦✦"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// advSlotRelevantToActivity reports whether using a slot during the given
|
||||
// activity should count toward that slot's mastery. Combat actions exercise
|
||||
// the combat loadout (weapon/armor/helmet/boots); harvest actions exercise
|
||||
// only the tool. This is what makes mastery feel like "you used this
|
||||
// piece" rather than "you played for a while" — without this gate every
|
||||
// slot ticks in lockstep and the per-slot view is meaningless.
|
||||
func advSlotRelevantToActivity(slot EquipmentSlot, activity AdvActivityType) bool {
|
||||
if isCombatActivity(activity) {
|
||||
return slot == SlotWeapon || slot == SlotArmor || slot == SlotHelmet || slot == SlotBoots
|
||||
}
|
||||
if isHarvestActivity(activity) {
|
||||
return slot == SlotTool
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// advMasteryRowSegment formats the `23/50`, `73/100 ✦`, `142/250 ✦✦`, or
|
||||
// `250 ✦✦✦` segment shown inline on each equipment row in the character
|
||||
// sheet so progress is visible at a glance — not just a tier marker that
|
||||
// flips on at 50/100/250 with nothing in between.
|
||||
func advMasteryRowSegment(actionsUsed int) string {
|
||||
if actionsUsed <= 0 {
|
||||
return ""
|
||||
}
|
||||
_, next := advMasteryTier(actionsUsed)
|
||||
marker := advMasteryMarker(actionsUsed)
|
||||
if next == 0 {
|
||||
// At max — there's no "next" threshold to count toward.
|
||||
return fmt.Sprintf("%d %s", actionsUsed, marker)
|
||||
}
|
||||
if marker == "" {
|
||||
return fmt.Sprintf("%d/%d", actionsUsed, next)
|
||||
}
|
||||
return fmt.Sprintf("%d/%d %s", actionsUsed, next, marker)
|
||||
}
|
||||
|
||||
// AdvMasteryRollup summarizes mastery state across all equipped slots so the
|
||||
// character sheet can show one informative aggregate line without a
|
||||
// per-slot block. AnyProgress is true when at least one slot has any
|
||||
// ActionsUsed — used to decide whether to show a "building up" hint when
|
||||
// no thresholds have crossed yet.
|
||||
type AdvMasteryRollup struct {
|
||||
ThresholdsCrossed int // raw count, pre-cap (sum across all slots)
|
||||
MaxedSlots int // slots with all 3 thresholds crossed
|
||||
Bonus float64 // capped %, mirrors advEquipmentMasteryBonus
|
||||
AnyProgress bool
|
||||
}
|
||||
|
||||
func advMasteryRollup(equip map[EquipmentSlot]*AdvEquipment) AdvMasteryRollup {
|
||||
r := AdvMasteryRollup{}
|
||||
for _, eq := range equip {
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
if eq.ActionsUsed > 0 {
|
||||
r.AnyProgress = true
|
||||
}
|
||||
tier, _ := advMasteryTier(eq.ActionsUsed)
|
||||
r.ThresholdsCrossed += tier
|
||||
if tier == len(advMasteryThresholds) {
|
||||
r.MaxedSlots++
|
||||
}
|
||||
}
|
||||
r.Bonus = advEquipmentMasteryBonus(equip)
|
||||
return r
|
||||
}
|
||||
|
||||
// advEquipmentMasteryBonus returns the loot-quality bonus from accumulated
|
||||
// mastery across all equipped slots. Each crossed threshold grants +1% loot
|
||||
// quality on its slot, capped at +10% total to keep balance reasonable.
|
||||
func advEquipmentMasteryBonus(equip map[EquipmentSlot]*AdvEquipment) float64 {
|
||||
total := 0.0
|
||||
for _, eq := range equip {
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range advMasteryThresholds {
|
||||
if eq.ActionsUsed >= t {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
if total > 10 {
|
||||
total = 10
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
|
||||
@@ -651,6 +785,12 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
|
||||
XPSkill: advXPSkill(loc.Activity),
|
||||
}
|
||||
|
||||
// Apply equipment-mastery loot bonus on a local copy so we don't mutate
|
||||
// the caller's bonuses struct (it's reused across multiple actions).
|
||||
localBonuses := *bonuses
|
||||
localBonuses.LootQuality += advEquipmentMasteryBonus(equip)
|
||||
bonuses = &localBonuses
|
||||
|
||||
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
|
||||
|
||||
// Overlevel penalty — reduces loot and XP for farming low-tier content
|
||||
@@ -718,9 +858,28 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
|
||||
result.EquipBroken = advCheckBrokenEquipment(equip)
|
||||
}
|
||||
|
||||
// Increment actions_used for equipment mastery
|
||||
for _, eq := range equip {
|
||||
// Increment actions_used for equipment mastery only on slots relevant
|
||||
// to the activity — a foraging trip shouldn't master a sword, and a
|
||||
// dungeon run shouldn't master a pickaxe. Detect threshold crossings
|
||||
// so the caller can DM a celebration.
|
||||
for slot, eq := range equip {
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
if !advSlotRelevantToActivity(slot, loc.Activity) {
|
||||
continue
|
||||
}
|
||||
before := eq.ActionsUsed
|
||||
eq.ActionsUsed++
|
||||
for _, t := range advMasteryThresholds {
|
||||
if before < t && eq.ActionsUsed >= t {
|
||||
result.MasteryCrossings = append(result.MasteryCrossings, AdvMasteryCrossing{
|
||||
Slot: slot,
|
||||
ItemName: eq.Name,
|
||||
Threshold: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -762,9 +921,10 @@ func resolveAdvEmptyOutcome(loc *AdvLocation, _ float64) AdvOutcomeType {
|
||||
// ── Eligible Locations for DM Menu ───────────────────────────────────────────
|
||||
|
||||
type AdvEligibleLocation struct {
|
||||
Location *AdvLocation
|
||||
InPenaltyZone bool
|
||||
DeathPct float64
|
||||
Location *AdvLocation
|
||||
InPenaltyZone bool
|
||||
DeathPct float64
|
||||
ExceptionalPct float64
|
||||
}
|
||||
|
||||
func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) []AdvEligibleLocation {
|
||||
@@ -777,9 +937,10 @@ func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*Adv
|
||||
}
|
||||
probs := calculateAdvProbabilities(char, equip, &loc, bonuses, penalty)
|
||||
eligible = append(eligible, AdvEligibleLocation{
|
||||
Location: &loc,
|
||||
InPenaltyZone: penalty,
|
||||
DeathPct: probs.DeathPct,
|
||||
Location: &loc,
|
||||
InPenaltyZone: penalty,
|
||||
DeathPct: probs.DeathPct,
|
||||
ExceptionalPct: probs.ExceptionalPct,
|
||||
})
|
||||
}
|
||||
return eligible
|
||||
|
||||
Reference in New Issue
Block a user