diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index c29083c..8413c3b 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -48,7 +48,7 @@ func main() { userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)") realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.") - logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)") + logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)") matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs") classes = flag.String("classes", "", "comma-separated class ids (matrix mode)") @@ -62,6 +62,7 @@ func main() { party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies") partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)") + companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.") jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()") ) @@ -88,7 +89,7 @@ func main() { includeLog = *logFlag } }) - runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses) + runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion) return } @@ -103,7 +104,7 @@ func main() { return } - runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers) + runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers, *companion) } // runReal drives an existing character loaded from dataDir's gogobee.db through @@ -161,7 +162,7 @@ func followerClasses(leaderClass string, party int, spec string) []string { return out } -func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) { +func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string, companion string) { dir := dataDir if dir == "" { var err error @@ -172,7 +173,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa defer os.RemoveAll(dir) } - res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers) + res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers, companion) if err != nil { if res != nil { if !includeLog { @@ -199,7 +200,7 @@ type matrixJob struct { rep int } -func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses string) { +func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) { cs := splitNonEmpty(classes) ls := parseLevels(levels) zs := splitNonEmpty(zones) @@ -230,7 +231,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days var wg sync.WaitGroup for i := 0; i < jobs; i++ { wg.Add(1) - go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses) + go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion) } go func() { for _, j := range work { @@ -249,7 +250,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days } } -func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses string) { +func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string) { defer wg.Done() for j := range in { uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep) @@ -275,6 +276,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, if partyClasses != "" { args = append(args, "-party-classes", partyClasses) } + if companion != "" { + args = append(args, "-companion", companion) + } if trace { args = append(args, "-trace") } @@ -314,7 +318,7 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, } } -func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string) (*plugin.SimResult, error) { +func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string, companion string) (*plugin.SimResult, error) { runner, err := plugin.NewSimRunner(dataDir) if err != nil { return nil, fmt.Errorf("init runner: %w", err) @@ -338,6 +342,7 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon runner.Euro.Credit(muid, bank, "expedition-sim bankroll") members = append(members, muid) } + runner.Companion = companion return runner.RunPartyExpedition(uid, members, zone, cap, days) } diff --git a/internal/db/db.go b/internal/db/db.go index 7e0a893..326f154 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -401,6 +401,41 @@ func runMigrations(d *sql.DB) error { // there is no read-modify-write race and no second field to disagree. // DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap. `ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`, + + // The hireable companion (pete_adventure_news_plan.md, "Pete as a + // character"). Pete's roster seat records the class and level he was + // hired at, so a party that levels mid-expedition doesn't silently + // re-roll its hireling into a different class three rooms in. Both are + // empty/0 on every player row and are only ever read for the one seat + // where isCompanionSeat holds — a player's row never consults them. + `ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`, + + // The companion's spell-slot ledger, "used" per slot level, as a compact + // CSV of six ints (index 0 unused; cantrips cost nothing). + // + // It has to live on the *expedition*, next to the class and level it is a + // pool for. A human caster's slots are dnd_spell_slots rows that persist + // across every fight of the run and only come back at camp, so rationing a + // pool across a 30-room day IS the caster's game. The first cut of this + // parked the companion's ledger on his combat seat instead — and a seat is + // per-session, so he walked into every single fight with a full pool. The + // sim caught it: a level-penalized, gearless hireling out-cleared a human + // cleric of the leader's own level by 15pp. + `ALTER TABLE expedition_party ADD COLUMN companion_slots_used TEXT NOT NULL DEFAULT ''`, + + // The companion's body, carried across the run. -1 means "unset" — he is + // at full, which is what a fresh hire is. + // + // He used to re-seat at full max HP for *every* fight, because he has no + // dnd_character row for his HP to persist onto and the close-out skipped + // him ("he arrives fresh next time"). That is an infinite body: a player + // bleeds across a 30-room run and only heals at camp, while the hireling + // soaked half the incoming damage and reset. Measured, it is most of why a + // gearless, level-penalized hireling out-cleared a human cleric of the + // leader's own level — his party fled 5 runs out of 640 where the human + // party fled 56. + `ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure_companion.go b/internal/plugin/adventure_companion.go new file mode 100644 index 0000000..2f9fccb --- /dev/null +++ b/internal/plugin/adventure_companion.go @@ -0,0 +1,702 @@ +package plugin + +import ( + "database/sql" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// Pete, the realm's embedded correspondent — the hireable NPC companion. +// (pete_adventure_news_plan.md, "Pete as a character", surface 2.) +// +// A leader short a body can hire Pete into an expedition party. He fills the +// role the party is missing, fights on autopilot, and files a dispatch about it +// afterwards. +// +// The load-bearing rule, and the reason this file exists rather than a +// player_meta row for @pete: **Pete is not a player and must never become one.** +// He has no player_meta, no dnd_character, no inventory, no euros, and no DM +// room. Mint him a player_meta row and ensureDnDCharacterForCombat will happily +// auto-build him a real character on his first swing, at which point he shows up +// in the graveyard, the leaderboards, the news as a subject, and the daily event +// rolls — all of which would be a bot reporting on itself. +// +// So his seat is synthesized: companionCombatant builds a Combatant in memory +// from the same tuned layers a player's sheet goes through, and every seam that +// assumes a seat is a person is guarded by isCompanionSeat. The guards live at +// four chokepoints, which between them cover the whole blast radius: +// +// - expeditionAudience — he is never in the DM fan-out (and so never in the +// per-member pet-arrival rolls or the daily event rolls that ride it) +// - partySize — he is not a mouth: he doesn't inflate the supply +// burn he never bought packs for, and an NPC-only roster doesn't lock the +// leader out of their next expedition +// - partyCombatantsForSession / the seat builders — synthesize, don't load +// - the close-out loops — no XP, no loot, no death row, no achievements +// +// He does count toward the enemy-HP scalar, because he is a body in the fight +// and the boss can feel him. + +// companionUserIDDefault is Pete's real Matrix account. He is an independent bot +// (his own repo, his own voice); gogobee already ignores him as a sender via +// IGNORED_BOTS, so seating him here can never round-trip into command handling. +// PETE_USER_ID overrides for a differently-homed deployment. +const companionUserIDDefault = "@pete:parodia.dev" + +// companionDisplayName is what the party sees on his seat. gogobee names him but +// does NOT voice him: his hire banter and his dispatch are written by his own +// bot from the fact emitted below (project_pete_bot_architecture). +const companionDisplayName = "Pete" + +// Hire pricing. The plan files cost as an open tuning question; this is the +// first answer, not the final one. It scales with both the level he shows up at +// and the tier he's walking into, so hiring him for a T5 boss run is not the +// same 300 coins as a T1 stroll. Supply packs are 50/90 coins for reference — +// he is deliberately a real expense, not a rounding error. +// +// PROD WATCH: at endgame coin balances this is likely too cheap to be a sink. +// Raise companionHireCoinsPerLevel before raising the base — the base is what a +// low-level player short a friend has to find. +const ( + companionHireBaseCoins = 300 + companionHireCoinsPerLevel = 60 +) + +// companionLevelPenalty is what makes him help rather than carry. He arrives one +// level below the party's average — a competent below-median member, per the +// difficulty plan's standing rule: lift the trailing case, never nerf the +// leaders and never touch monster scaling. +const companionLevelPenalty = 1 + +var ( + ErrCompanionAlreadyHired = errors.New("pete is already with this party") + ErrCompanionOnAssignment = errors.New("pete is out on assignment") + ErrCompanionNotHired = errors.New("pete is not with this party") +) + +// companionUserID resolves Pete's Matrix id. +func companionUserID() id.UserID { + if v := strings.TrimSpace(os.Getenv("PETE_USER_ID")); v != "" { + return id.UserID(v) + } + return id.UserID(companionUserIDDefault) +} + +// isCompanionSeat reports whether a roster seat / combat seat is Pete rather +// than a player. This is the predicate every guard in the codebase keys on. +func isCompanionSeat(userID id.UserID) bool { return userID == companionUserID() } + +// isCompanionUser is the string-keyed form, for the many seams that carry a raw +// user_id out of the database. +func isCompanionUser(userID string) bool { return id.UserID(userID) == companionUserID() } + +// companionHireCost is what the leader pays to bring him along, in coins. +func companionHireCost(level int, tier ZoneTier) int { + if level < 1 { + level = 1 + } + t := int(tier) + if t < 1 { + t = 1 + } + return (companionHireBaseCoins + companionHireCoinsPerLevel*level) * t +} + +// ── who he shows up as ─────────────────────────────────────────────────────── + +// companionRoleFill picks the class Pete plays. He is role-fluid — that is the +// whole point of hiring him — so by default he fills the hole in the roster: no +// healer, he's a Cleric; no damage, he's a Mage; nobody up front, he's a +// Fighter. A leader who knows better can override. +// +// The order of the checks is the priority order: a party with neither a healer +// nor a front line gets the healer, because a party that cannot heal is the one +// that dies. +func companionRoleFill(partyClasses []DnDClass) DnDClass { + var hasHealer, hasFront, hasDamage bool + for _, c := range partyClasses { + switch c { + case ClassCleric, ClassDruid, ClassBard: + hasHealer = true + case ClassPaladin: + // The one chassis that answers two questions at once. + hasHealer, hasFront = true, true + case ClassFighter: + hasFront = true + case ClassMage, ClassSorcerer, ClassWarlock, ClassRogue, ClassRanger: + hasDamage = true + } + } + switch { + case !hasHealer: + return ClassCleric + case !hasFront: + return ClassFighter + case !hasDamage: + return ClassMage + default: + // A complete party that hires him anyway gets a second pair of hands up + // front — the least redundant thing he can be. + return ClassFighter + } +} + +// parseCompanionClass resolves an explicit `!expedition hire cleric` override. +// Empty (or unknown) means auto-fill. +func parseCompanionClass(arg string) (DnDClass, bool) { + arg = strings.ToLower(strings.TrimSpace(arg)) + if arg == "" || arg == "auto" { + return "", false + } + for _, ci := range dndClasses { + if !ci.Playable { + continue + } + if string(ci.Key) == arg || strings.EqualFold(ci.Display, arg) { + return ci.Key, true + } + } + return "", false +} + +// companionHumans is every *player* on the expedition — the roster if one exists, +// and otherwise the owner alone. +// +// The fallback is the whole point. A solo expedition has NO expedition_party rows +// (see partyMembers: absence means solo, and the roster only materializes on the +// first successful invite). Reading the roster alone therefore answers "nobody" for +// exactly the player this feature exists for: the one with no friends around, who +// is hiring Pete *because* they are alone. +// +// Getting this wrong is not a small error. It hired every solo player a **level-1** +// Pete — in a tier-4 zone, against a boss that had just gained 15% HP and a full +// extra set of actions to account for him. He died on contact and left the leader +// fighting an inflated boss alone. A 1500-run sweep measured it: solo 65% clear, +// two humans 87%, solo+Pete 33%. The companion was worse than no companion, and +// this line is why. +func companionHumans(expeditionID string) []*DnDCharacter { + var owner string + if err := db.Get().QueryRow( + `SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`, + expeditionID).Scan(&owner); err != nil { + return nil + } + seats, err := partyHumans(expeditionID, owner) + if err != nil { + return nil + } + out := make([]*DnDCharacter, 0, len(seats)) + for _, s := range seats { + if dc, _ := LoadDnDCharacter(s.UserID); dc != nil { + out = append(out, dc) + } + } + return out +} + +// companionPartyLevel is the level he arrives at: the party's average, less +// companionLevelPenalty, floored at 1. +func companionPartyLevel(expeditionID string) int { + chars := companionHumans(expeditionID) + if len(chars) == 0 { + return 1 + } + sum := 0 + for _, dc := range chars { + sum += dc.Level + } + lvl := sum/len(chars) - companionLevelPenalty + if lvl < 1 { + lvl = 1 + } + return lvl +} + +// companionPartyClasses reads the classes already on the expedition, for the role +// fill. Solo resolves to the owner's class, so a lone fighter gets a healer rather +// than the empty-party default. +func companionPartyClasses(expeditionID string) []DnDClass { + chars := companionHumans(expeditionID) + out := make([]DnDClass, 0, len(chars)) + for _, dc := range chars { + out = append(out, dc.Class) + } + return out +} + +// ── his sheet, which lives only in memory ──────────────────────────────────── + +// companionSheet synthesizes the DnDCharacter Pete fights as. It is built, used, +// and thrown away inside a single combat build — it is never saved, and +// SaveDnDCharacter must never be called on it. +// +// Stats come from the same class-priority + race-mod pipeline autoBuildCharacter +// uses for a real character, so he is statted like a player of his level rather +// than by a bespoke NPC table that would drift away from the tuned math. Human +// is deliberate: the +1-to-all is the most neutral race in the book, so his +// class is doing the work rather than a race pick nobody chose. +func companionSheet(class DnDClass, level int) *DnDCharacter { + if level < 1 { + level = 1 + } + scores := applyRaceMods(RaceHuman, classStatPriority(class)) + c := &DnDCharacter{ + UserID: companionUserID(), + Race: RaceHuman, + Class: class, + Level: level, + STR: scores[0], DEX: scores[1], CON: scores[2], + INT: scores[3], WIS: scores[4], CHA: scores[5], + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level) + c.HPCurrent = c.HPMax + return c +} + +// companionAdvCharacter is the AdventureCharacter half of the synthetic sheet: +// the shim DerivePlayerStats needs. CombatLevel round-trips back to the D&D +// level through dndLevelFromCombatLevel (level*5), so the two halves agree. +func companionAdvCharacter(level int) *AdventureCharacter { + return &AdventureCharacter{ + UserID: companionUserID(), + DisplayName: companionDisplayName, + CombatLevel: level * 5, + Alive: true, + } +} + +// companionGearTier maps his level onto the equipment tier a player of that +// level would plausibly be carrying: 1–4 → T1, 5–8 → T2, and so on to T5. +func companionGearTier(level int) int { + t := (level + 3) / 4 + if t < 1 { + t = 1 + } + if t > 5 { + t = 5 + } + return t +} + +// companionGear is the kit he walks in with — a working reporter's kit, bought +// with expenses and kept in serviceable shape. +// +// He has to have one. The gear layer is not decoration: unarmored, stats.AC is +// never set at all (computeArmorAC only fires when armor exists) and he walks in +// at AC 3, hit by everything; weaponless, stats.Weapon stays nil and he swings +// for a flat 5 at every level, which by L14 is nothing. "No gear" is not a +// below-median player, it is a broken one. +// +// The below-median comes from everywhere else: he is a level down, his gear is +// never Masterwork, and he carries no magic items, no subclass and no armed +// ability. The weapon names are chosen to hit the right branch of +// synthesizeWeaponProfile for the class — it best-fits off the name. +func companionGear(class DnDClass, level int) map[EquipmentSlot]*AdvEquipment { + tier := companionGearTier(level) + + weapon := "Service Mace" + switch class { + case ClassFighter, ClassPaladin: + weapon = "Service Sword" + case ClassRogue: + weapon = "Service Dagger" + case ClassRanger: + weapon = "Service Bow" + case ClassMage, ClassSorcerer, ClassWarlock, ClassDruid, ClassBard: + weapon = "Service Staff" + } + + return map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Slot: SlotWeapon, Tier: tier, Condition: 100, Name: weapon}, + SlotArmor: {Slot: SlotArmor, Tier: tier, Condition: 100, Name: "Service Kit"}, + } +} + +// companionCombatant builds Pete's seat for a fight, mirroring buildZoneCombatants +// but sourcing the sheet from memory instead of the database. He carries no +// equipment, no treasure bonuses, no magic items, no chat level and no streak — +// the three layers a player accumulates and he never will. That absence *is* the +// below-median: he is a bare class chassis at a level below yours, and the gap +// between him and a geared player of the same level is exactly the gear. +func (p *AdventurePlugin) companionCombatant( + class DnDClass, level int, monster DnDMonsterTemplate, tier int, dmMood int, +) (Combatant, Combatant, *DnDCharacter) { + tilt := dmMoodCombatTilt(dmMood) + char := companionAdvCharacter(level) + dc := companionSheet(class, level) + + // The layer order is buildZoneCombatants', deliberately — a companion statted + // by a different pipeline would drift away from the tuned math the moment + // anyone touched one and not the other. + // + // What he does NOT get is the subclass layer, magic items, and an armed + // ability: three of the things a player accumulates and a hireling never will. + // Those absences, plus the level penalty and gear that is never Masterwork, + // are the "below median" — see companionGear for why the gear itself is not + // one of the things we take away. + gear := companionGear(class, level) + stats, mods := DerivePlayerStats(char, gear, &AdvBonusSummary{}, 0, 0, false) + applyDnDPlayerLayer(&stats, dc) + applyDnDEquipmentLayer(&stats, dc, gear) + applyDnDHPScaling(&stats, dc) + applyClassPassives(&stats, &mods, dc) + applyRacePassives(&stats, &mods, dc) + + enemyStats, enemyMods := monster.toCombatStats() + if tier > 1 { + if floorAC := dndDungeonACBase + tier; enemyStats.AC < floorAC { + enemyStats.AC = floorAC + } + if floorAB := dndDungeonAtkBase + tier; enemyStats.AttackBonus < floorAB { + enemyStats.AttackBonus = floorAB + } + } + enemyStats.Attack += tilt.EnemyAttackDelta + if enemyStats.Attack < 1 { + enemyStats.Attack = 1 + } + mods.InitiativeBias += tilt.InitiativeBias + + player := Combatant{ + Name: companionDisplayName, + Stats: stats, + Mods: mods, + IsPlayer: true, + } + enemy := Combatant{ + Name: monster.Name, + Stats: enemyStats, + Mods: enemyMods, + Ability: monster.Ability, + } + return player, enemy, dc +} + +// companionRosterLine is how he reads on `!expedition party`. DisplayName would +// come back empty for him — he has no player_meta row to hold a name — so the +// roster names him here, along with what he is currently playing, because "Pete +// (member)" tells the leader nothing about the hole they paid to fill. +func companionRosterLine(expeditionID string) string { + class, level := companionLoadout(expeditionID) + ci, _ := classInfo(class) + return fmt.Sprintf("**%s** _(hired — level %d %s)_\n", companionDisplayName, level, ci.Display) +} + +// ── the hire, persisted on the roster ──────────────────────────────────────── + +// companionLoadout reads back the class and level he was hired at. It is stored +// on the roster row rather than re-derived per fight, so a party that levels +// mid-expedition doesn't quietly re-roll their hireling into a different class +// three rooms in. +func companionLoadout(expeditionID string) (DnDClass, int) { + var class string + var level int + err := db.Get().QueryRow(` + SELECT companion_class, companion_level + FROM expedition_party + WHERE expedition_id = ? AND user_id = ?`, + expeditionID, string(companionUserID())).Scan(&class, &level) + if err != nil || class == "" { + return ClassFighter, 1 + } + return DnDClass(class), level +} + +// ── his spell slots, which live on the expedition ──────────────────────────── +// +// A human caster's slots are dnd_spell_slots rows: one pool, spent across every +// fight of the run, refilled only at camp. Rationing it is the caster's game. The +// companion has no rows, so his pool lives on his roster row — the same row his +// class and level live on, and with the same lifetime. +// +// It must NOT live on his combat seat. A seat is per-session and every fight opens +// a new one, so a seat-scoped pool refills itself between fights: an infinite +// caster. That is not a theory — the first cut did exactly that, and the sim +// measured a gearless, level-penalized hireling out-clearing a human cleric of the +// leader's own level by 15pp. + +// companionSlotsCSV encodes/decodes the ledger. CSV of six ints rather than JSON +// because it is six ints. +func companionSlotsDecode(s string) [6]int { + var out [6]int + for i, f := range strings.Split(s, ",") { + if i >= len(out) { + break + } + n, err := strconv.Atoi(strings.TrimSpace(f)) + if err != nil || n < 0 { + continue + } + out[i] = n + } + return out +} + +func companionSlotsEncode(used [6]int) string { + parts := make([]string, len(used)) + for i, n := range used { + parts[i] = strconv.Itoa(n) + } + return strings.Join(parts, ",") +} + +// companionSlotsForRun reads the ledger for the companion on the expedition that +// owns runID. A run with no companion (or no expedition) reads as an empty pool, +// which is the correct answer: nobody spent anything. +func companionSlotsForRun(runID string) [6]int { + var raw string + err := db.Get().QueryRow(` + SELECT p.companion_slots_used + FROM expedition_party p + JOIN dnd_expedition e ON e.expedition_id = p.expedition_id + WHERE e.run_id = ? AND p.user_id = ?`, + runID, string(companionUserID())).Scan(&raw) + if err != nil { + return [6]int{} + } + return companionSlotsDecode(raw) +} + +// setCompanionSlotsForRun writes it back. +func setCompanionSlotsForRun(runID string, used [6]int) error { + _, err := db.Get().Exec(` + UPDATE expedition_party + SET companion_slots_used = ? + WHERE user_id = ? + AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`, + companionSlotsEncode(used), string(companionUserID()), runID) + return err +} + +// refreshCompanionSlots empties the ledger — his half of the camp rest that calls +// refreshSpellSlots for every human. Keyed by expedition, because camp is. +func refreshCompanionSlots(expeditionID string) error { + _, err := db.Get().Exec(` + UPDATE expedition_party SET companion_slots_used = '' + WHERE expedition_id = ? AND user_id = ?`, + expeditionID, string(companionUserID())) + return err +} + +// ── his body, which is also carried across the run ─────────────────────────── +// +// companionUnsetHP is "no wound recorded" — a fresh hire, or a companion who has +// just broken camp. Seating reads it as full. +const companionUnsetHP = -1 + +// companionHPFor reads the HP he carries into his next fight, or companionUnsetHP +// when he is unhurt. A run with no companion reads unset, which is harmless: there +// is nobody to seat. +func companionHPFor(expeditionID string) int { + hp := companionUnsetHP + err := db.Get().QueryRow(` + SELECT companion_hp FROM expedition_party + WHERE expedition_id = ? AND user_id = ?`, + expeditionID, string(companionUserID())).Scan(&hp) + if err != nil { + return companionUnsetHP + } + return hp +} + +// companionSeatHP is what he actually sits down with: his carried wound, clamped +// into [1, maxHP]. +// +// The floor of 1 is deliberate. He can be dropped *inside* a fight — the engine +// counts him out like any other seat — but he does not stay dead between them, +// because there is no companion-death mechanic and inventing one here would be a +// second feature smuggled into a bug fix. Coming back on 1 HP is a real penalty +// (one hit and he is down again) without pretending to be a corpse rule. +func companionSeatHP(expeditionID string, maxHP int) int { + hp := companionHPFor(expeditionID) + if hp == companionUnsetHP || hp > maxHP { + return maxHP + } + if hp < 1 { + return 1 + } + return hp +} + +// setCompanionHP records the HP he walked out of a fight with. +func setCompanionHP(expeditionID string, hp int) error { + _, err := db.Get().Exec(` + UPDATE expedition_party SET companion_hp = ? + WHERE expedition_id = ? AND user_id = ?`, + hp, expeditionID, string(companionUserID())) + return err +} + +// setCompanionHPForRun is setCompanionHP for the turn-based close-out, which +// holds a run id rather than an expedition id. +func setCompanionHPForRun(runID string, hp int) error { + _, err := db.Get().Exec(` + UPDATE expedition_party + SET companion_hp = ? + WHERE user_id = ? + AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`, + hp, string(companionUserID()), runID) + return err +} + +// refreshCompanionHP is his half of the camp heal: back to full, like every human +// at a standard rest. +func refreshCompanionHP(expeditionID string) error { + _, err := db.Get().Exec(` + UPDATE expedition_party SET companion_hp = ? + WHERE expedition_id = ? AND user_id = ?`, + companionUnsetHP, expeditionID, string(companionUserID())) + return err +} + +// companionLoadoutForRun is companionLoadout keyed by the zone run instead of +// the expedition. A CombatSession carries a RunID, not an expedition id, and the +// per-turn rebuild is the hottest caller — so the join lives here rather than +// making every caller resolve the expedition first. +// +// A run with no expedition (a standalone !zone run, which can have no companion) +// finds no row and falls back, which is the correct reading. +func companionLoadoutForRun(runID string) (DnDClass, int) { + var class string + var level int + err := db.Get().QueryRow(` + SELECT p.companion_class, p.companion_level + FROM expedition_party p + JOIN dnd_expedition e ON e.expedition_id = p.expedition_id + WHERE e.run_id = ? AND p.user_id = ?`, + runID, string(companionUserID())).Scan(&class, &level) + if err != nil || class == "" { + return ClassFighter, 1 + } + return DnDClass(class), level +} + +// companionExpeditionFor resolves the expedition a leader is running, for the +// seat builder — which has the roster but not the expedition id. Empty string +// when there is none, which companionLoadout reads as "no row" and falls back. +func companionExpeditionFor(leader id.UserID) string { + e, _, err := activeExpeditionFor(leader) + if err != nil || e == nil { + return "" + } + return e.ID +} + +// companionSeated reports whether Pete is on this roster. +func companionSeated(expeditionID string) bool { + var one int + err := db.Get().QueryRow(` + SELECT 1 FROM expedition_party WHERE expedition_id = ? AND user_id = ?`, + expeditionID, string(companionUserID())).Scan(&one) + return err == nil +} + +// hireCompanion seats Pete. The seat check, the availability check and the +// insert share a transaction, so two leaders racing for him cannot both win. +// +// He is globally exclusive — one party at a time. That is not a limitation to +// route around: "Pete is out on assignment" is the scarcity knob the plan asks +// for, and it is also why he cannot be the answer to every run. +func hireCompanion(expeditionID string, class DnDClass, level int) error { + tx, err := db.Get().Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if err := seatLeader(tx, expeditionID); err != nil { + return err + } + + // Is he already out with somebody? Scoped to live expeditions, so a roster + // row stranded by a crash cannot make him unhireable forever. + var busyOn string + err = tx.QueryRow(` + SELECT p.expedition_id + FROM expedition_party p + JOIN dnd_expedition e ON e.expedition_id = p.expedition_id + WHERE p.user_id = ? AND e.status IN ('active', 'extracting') + LIMIT 1`, string(companionUserID())).Scan(&busyOn) + switch { + case err == nil && busyOn == expeditionID: + return ErrCompanionAlreadyHired + case err == nil: + return ErrCompanionOnAssignment + case !errors.Is(err, sql.ErrNoRows): + return err + } + + var n int + if err := tx.QueryRow( + `SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`, + expeditionID).Scan(&n); err != nil { + return err + } + if n >= expeditionPartyMax { + return ErrPartyFull + } + + if _, err := tx.Exec(` + INSERT INTO expedition_party (expedition_id, user_id, role, companion_class, companion_level) + VALUES (?, ?, 'member', ?, ?)`, + expeditionID, string(companionUserID()), string(class), level); err != nil { + return fmt.Errorf("seat companion: %w", err) + } + return tx.Commit() +} + +// dismissCompanion sends him home. Unlike a player member he can be removed +// mid-run — he is staff, not a guest — but the fee is not refunded: he already +// walked in. +func dismissCompanion(expeditionID string) error { + res, err := db.Get().Exec(` + DELETE FROM expedition_party + WHERE expedition_id = ? AND user_id = ?`, + expeditionID, string(companionUserID())) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrCompanionNotHired + } + return nil +} + +// ── the dispatch ───────────────────────────────────────────────────────────── + +// emitCompanionHireFact tells Pete's bot he has been hired. gogobee states the +// fact; Pete writes the sentence. Bulletin, not priority: a hire is a diary +// entry, not a bulletin-interrupting event. +// +// The subject is the *leader*, so the leader's news opt-out is honoured — Pete +// reporting "filled in as cleric for " would +// be exactly the leak the opt-out exists to prevent. Pete himself is never a +// subject: he has no opt-out row and needs none. +func emitCompanionHireFact(leader id.UserID, class DnDClass, level int, zone ZoneDefinition) { + name := charName(leader) + if name == "" { + return + } + ci, _ := classInfo(class) + emitFact(peteclient.Fact{ + GUID: "companion_hire:" + eventToken(leader, "companion_hire") + ":" + fmt.Sprint(time.Now().UTC().Unix()), + EventType: "companion_hire", + Tier: "bulletin", + Subject: name, + Zone: zone.Display, + Level: level, + ClassRace: ci.Display, + OccurredAt: time.Now().UTC().Unix(), + }, leader, "") +} diff --git a/internal/plugin/adventure_companion_test.go b/internal/plugin/adventure_companion_test.go new file mode 100644 index 0000000..6a1c7ef --- /dev/null +++ b/internal/plugin/adventure_companion_test.go @@ -0,0 +1,309 @@ +package plugin + +import ( + "errors" + "testing" + + "maunium.net/go/mautrix/id" +) + +// The companion's whole contract is "he fights, and he is not a player". These +// tests pin both halves — and specifically the seams where an NPC seat would +// otherwise be silently treated as a person. + +func TestCompanion_HiredSeatIsNotAMouth(t *testing.T) { + setupEmptyTestDB(t) + owner := id.UserID("@leader:example.org") + seedExpedition(t, "exp-hire", owner, "active") + seatLeaderFixture(t, "exp-hire") + + if err := hireCompanion("exp-hire", ClassCleric, 4); err != nil { + t.Fatalf("hireCompanion: %v", err) + } + + // The roster holds two seats... + members, err := partyMembers("exp-hire") + if err != nil { + t.Fatal(err) + } + if len(members) != 2 { + t.Fatalf("roster has %d seats, want 2 (leader + companion)", len(members)) + } + + // ...but only one of them eats. partySize feeds the daily supply burn and the + // "your party is still waiting on you" lock-out; counting the companion would + // bill the leader for rations he never bought, and strand him out of his next + // expedition behind a party of one bot. + n, err := partySize("exp-hire") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("partySize = %d, want 1 — the companion is not a mouth", n) + } +} + +func TestCompanion_GetsNoMailButTakesASeat(t *testing.T) { + setupEmptyTestDB(t) + owner := id.UserID("@leader2:example.org") + seedExpedition(t, "exp-mail", owner, "active") + seatLeaderFixture(t, "exp-mail") + if err := hireCompanion("exp-mail", ClassFighter, 3); err != nil { + t.Fatalf("hireCompanion: %v", err) + } + exp, err := getExpedition("exp-mail") + if err != nil || exp == nil { + t.Fatalf("getExpedition: %v", err) + } + + // Mail and seats are different sets. Every DM seam reads the audience; the + // combat roster reads the seats. Getting this backwards either DMs a bot or + // charges a leader for a body that never sits down. + for _, uid := range expeditionAudience(exp) { + if isCompanionSeat(uid) { + t.Fatal("companion is in the DM audience — he does not get mail") + } + } + var seated bool + for _, uid := range expeditionSeats(exp) { + if isCompanionSeat(uid) { + seated = true + } + } + if !seated { + t.Fatal("companion is not in the fight roster — he was paid for and never sat down") + } +} + +func TestCompanion_IsGloballyExclusive(t *testing.T) { + setupEmptyTestDB(t) + for _, e := range []struct { + id string + owner id.UserID + }{{"exp-a", "@a:example.org"}, {"exp-b", "@b:example.org"}} { + seedExpedition(t, e.id, e.owner, "active") + seatLeaderFixture(t, e.id) + } + + if err := hireCompanion("exp-a", ClassMage, 5); err != nil { + t.Fatalf("first hire: %v", err) + } + // He is one person. A second party cannot have him — "out on assignment" is + // the scarcity knob, not a bug to route around. + if err := hireCompanion("exp-b", ClassMage, 5); !errors.Is(err, ErrCompanionOnAssignment) { + t.Errorf("second hire err = %v, want ErrCompanionOnAssignment", err) + } + // Re-hiring him into the party he's already with is its own answer. + if err := hireCompanion("exp-a", ClassMage, 5); !errors.Is(err, ErrCompanionAlreadyHired) { + t.Errorf("re-hire err = %v, want ErrCompanionAlreadyHired", err) + } + + // Dismissed, he's available again. + if err := dismissCompanion("exp-a"); err != nil { + t.Fatalf("dismiss: %v", err) + } + if err := hireCompanion("exp-b", ClassMage, 5); err != nil { + t.Errorf("hire after dismiss: %v, want success", err) + } +} + +// The bug that made the whole feature worse than useless: a SOLO expedition has +// no expedition_party rows at all (the roster only materializes on the first +// invite), so reading the roster to size the companion answered "nobody" for +// exactly the player who is hiring him — the one with no friends around. Every +// solo hire got a level-1 Pete, in whatever tier the leader was actually in. +func TestCompanion_SoloLeaderSizesHimCorrectly(t *testing.T) { + setupEmptyTestDB(t) + owner := id.UserID("@lonely:example.org") + seedExpedition(t, "exp-solo-hire", owner, "active") + + // A level-12 fighter, adventuring alone. No roster rows exist. + if err := SaveDnDCharacter(&DnDCharacter{ + UserID: owner, Race: RaceHuman, Class: ClassFighter, Level: 12, + STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10, + HPMax: 120, HPCurrent: 120, ArmorClass: 18, + }); err != nil { + t.Fatal(err) + } + + if got := companionPartyLevel("exp-solo-hire"); got != 11 { + t.Errorf("companionPartyLevel = %d, want 11 (the lone leader's 12, less the below-median step). "+ + "A level-1 companion in the leader's zone is worse than no companion at all.", got) + } + // And he fills the hole the lone fighter actually has, rather than defaulting + // as if the party were empty. + if got := companionRoleFill(companionPartyClasses("exp-solo-hire")); got != ClassCleric { + t.Errorf("role fill for a lone fighter = %v, want cleric", got) + } +} + +// §4 — asking for "the party" must never answer "nobody". +// +// A solo expedition has no expedition_party rows (absence means solo; the roster +// materializes on the first invite). Every consumer that read the roster table +// directly therefore got an empty list for a solo player and fell back to +// whatever looked reasonable locally. That is how the companion was hired at +// level 1 for exactly the player the feature exists for. +// +// expeditionParty always includes the owner. If this test ever fails, that +// guarantee is gone and the same class of bug is back. +func TestParty_SoloExpeditionStillHasAParty(t *testing.T) { + setupEmptyTestDB(t) + owner := id.UserID("@alone:example.org") + seedExpedition(t, "exp-alone", owner, "active") + + // No roster rows exist at all. + rows, err := partyMembers("exp-alone") + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("solo expedition has %d roster rows; the premise of this test is gone", len(rows)) + } + + // And yet the party is not empty. + seats, err := expeditionParty("exp-alone", string(owner)) + if err != nil { + t.Fatal(err) + } + if len(seats) != 1 || seats[0].UserID != owner || seats[0].Kind != SeatLeader { + t.Fatalf("expeditionParty on a solo run = %+v, want exactly the owner as leader. "+ + "An empty answer here is what hires a level-1 companion.", seats) + } + humans, err := partyHumans("exp-alone", string(owner)) + if err != nil || len(humans) != 1 { + t.Fatalf("partyHumans on a solo run = %+v (err %v), want the owner", humans, err) + } +} + +func TestCompanion_RoleFillTakesTheHole(t *testing.T) { + tests := []struct { + name string + party []DnDClass + want DnDClass + }{ + {"no healer", []DnDClass{ClassFighter, ClassRogue}, ClassCleric}, + {"no front line", []DnDClass{ClassCleric, ClassMage}, ClassFighter}, + {"no damage", []DnDClass{ClassCleric, ClassFighter}, ClassMage}, + // A paladin covers healer AND front line, so paladin+rogue has no hole at + // all — it falls through to the default, a second body up front. + {"a complete party gets the default", []DnDClass{ClassPaladin, ClassRogue}, ClassFighter}, + {"a paladin still leaves the damage hole", []DnDClass{ClassPaladin}, ClassMage}, + {"solo fighter needs the medic first", []DnDClass{ClassFighter}, ClassCleric}, + {"empty party", nil, ClassCleric}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := companionRoleFill(tc.party); got != tc.want { + t.Errorf("companionRoleFill(%v) = %v, want %v", tc.party, got, tc.want) + } + }) + } +} + +// §3 — an engine-driven seat's latch is permanent, and no command can take it. +// +// This is the invariant that, when it did not exist, made the companion stand in +// fights doing nothing. The expedition autopilot drives a party by dispatching +// each seat's turn AS that seat; his own auto-played move therefore arrived at +// beginCombatTurn looking exactly like a human returning to the keyboard, and the +// "they typed, so they're here" branch cleared the latch that was moving him. +// After round 1 he never acted again — while the boss he had inflated by 15% HP +// killed the party. The sweep measured it at -27pp clear rate. +// +// Autopilot is provisional (a human is away; a keystroke ends it). EngineDriven is +// not (there is nobody to come back). They must never collapse into one flag. +func TestCompanion_EngineSeatKeepsTheWheel(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + uid := id.UserID("@lead:example.org") + + monster := dndBestiary["goblin"] + c, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0) + enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}} + + sess, err := p.startPartyCombatSession("run-e", "enc", "goblin", &enemy, []CombatSeatSetup{ + {UserID: uid, HP: 200, HPMax: 200, Mods: c.Mods, C: &c}, + {UserID: companionUserID(), HP: 120, HPMax: 120, Mods: c.Mods, C: &c, EngineDriven: true}, + }) + if err != nil { + t.Fatal(err) + } + + if !sess.seatIsEngineDriven(1) { + t.Fatal("companion seat is not engine-driven") + } + if sess.seatIsEngineDriven(0) { + t.Fatal("the human's seat came out engine-driven") + } + // The engine drives it without waiting on anybody... + if !sess.seatIsAutopiloted(1) || !sess.seatNeedsNoHuman(1) { + t.Fatal("an engine seat must resolve without waiting for a human") + } + // ...and the human's seat still waits for its human. + if sess.seatIsAutopiloted(0) { + t.Fatal("the human's seat was latched onto autopilot at seating") + } + + // The wheel cannot be taken back, because there is nobody to take it. This is + // the exact line that used to strand him: a "keystroke" from his own id. + sess.actorStatusesPtr(1).Autopilot = false + if !sess.seatIsAutopiloted(1) { + t.Fatal("clearing Autopilot stranded the engine seat — EngineDriven must keep it moving") + } +} + +// The one that actually matters: he has to FIGHT. Every other guard in this +// file is about keeping him out of things, and a companion synthesized down to +// zeroed stats would pass all of them while standing in the fight doing nothing +// — a hire that silently buys the leader an empty chair. +func TestCompanion_ActuallySwings(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + + monster := dndBestiary["goblin"] + tank := monster + tank.HP = 5000 // outlast the phase clock, so a real swing has time to land + + pete, _, _ := p.companionCombatant(ClassFighter, 6, tank, 2, 0) + + if pete.Stats.MaxHP <= 0 || pete.Stats.Attack <= 0 || pete.Stats.AC <= 0 { + t.Fatalf("companion built with dead stats (%d HP / %d atk / %d AC) — he'd stand in the fight and do nothing", + pete.Stats.MaxHP, pete.Stats.Attack, pete.Stats.AC) + } + + enemy := Combatant{Name: tank.Name, Stats: CombatStats{MaxHP: 5000, AC: 10, Attack: 1, AttackBonus: 1}} + res := simulatePartyWithRNG([]Combatant{pete}, enemy, dungeonCombatPhases, seededRNG(7)) + + var swings int + for _, e := range res.Events { + if e.Actor == "player" && e.Seat == 0 && e.Roll > 0 { + swings++ + } + } + if swings == 0 { + t.Fatal("the companion never swung — he was hired, seated, and did nothing") + } +} + +func TestCompanion_SheetIsBelowMedianAndNeverPersisted(t *testing.T) { + setupEmptyTestDB(t) + + // He is statted like a player of his level, so he never drifts from the tuned + // math — but he arrives a level down, which is the whole "help, never a carry" + // rule expressed in one number. + dc := companionSheet(ClassFighter, 7) + if dc.Level != 7 || dc.HPMax <= 0 { + t.Fatalf("companionSheet = level %d / %d HP, want a real level-7 chassis", dc.Level, dc.HPMax) + } + + // And he leaves no trace: a player_meta row is the thing that would turn him + // into a real character everywhere (graveyard, news, XP, leaderboards), so the + // synthesis must not have written one. + if c, _ := LoadDnDCharacter(companionUserID()); c != nil { + t.Fatal("companion has a persisted dnd_character row — he is a player now, which is the one thing he must never be") + } + if _, err := loadAdvCharacter(companionUserID()); err == nil { + t.Fatal("companion has a persisted player_meta row — ensureDnDCharacterForCombat will auto-build him a character on his first swing") + } +} diff --git a/internal/plugin/combat_ally_heal_test.go b/internal/plugin/combat_ally_heal_test.go new file mode 100644 index 0000000..7046f83 --- /dev/null +++ b/internal/plugin/combat_ally_heal_test.go @@ -0,0 +1,174 @@ +package plugin + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +// §1 — the cleric fix. +// +// Until this landed, EVERY heal in the combat engine was self-scoped: +// MistyHealProc healed the actor, HealItem fired the actor's own trigger, and +// turnActionEffect.PlayerHeal wrote to the acting seat. There was no action of any +// kind that could touch another seat's HP. A party cleric — the class whose entire +// identity is keeping other people upright — could not put one hit point on a +// friend, and N3 shipped that way without a single test going red, because party +// combat had no golden. +// +// These tests exist so that can never be true again. + +// startAllyHealFight seats a healer at 0 and a hurt friend at 1. +func startAllyHealFight(t *testing.T, p *AdventurePlugin, hurtHP int) *CombatSession { + t.Helper() + healer := basePlayer() + friend := basePlayer() + enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}} + + // Distinct ids per fight: one active session per player is enforced, so two + // fights sharing a healer would collide. + tag := strings.ReplaceAll(t.Name(), "/", "_") + sess, err := p.startPartyCombatSession("run-"+tag, "enc", "goblin", &enemy, []CombatSeatSetup{ + {UserID: healerID(tag), HP: 100, HPMax: 100, Mods: healer.Mods, C: &healer}, + {UserID: friendID(tag), HP: hurtHP, HPMax: 100, Mods: friend.Mods, C: &friend}, + }) + if err != nil { + t.Fatal(err) + } + return sess +} + +func healerID(tag string) id.UserID { return id.UserID("@healer-" + tag + ":example.org") } +func friendID(tag string) id.UserID { return id.UserID("@friend-" + tag + ":example.org") } + +func TestAllyHeal_LandsOnTheFriendNotTheCaster(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + sess := startAllyHealFight(t, p, 20) + + healer, friend := basePlayer(), basePlayer() + players := []*Combatant{&healer, &friend} + enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}} + ct := &combatTurn{sess: sess, players: players, enemy: &enemy, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))} + + casterBefore, friendBefore := sess.seatHP(0), sess.seatHP(1) + + _, err := p.driveCombatRound(ct, PlayerAction{ + Kind: ActionCast, + Effect: &turnActionEffect{ + Label: "Cure Wounds", Action: "spell_cast", + AllyHeal: 30, AllySeat: 1, + }, + }) + if err != nil { + t.Fatal(err) + } + + if got := sess.seatHP(1); got != friendBefore+30 { + t.Errorf("friend HP = %d, want %d — the heal did not reach them. "+ + "This is the bug: a cleric who cannot heal anyone but themselves.", + got, friendBefore+30) + } + if got := sess.seatHP(0); got > casterBefore { + t.Errorf("caster HP = %d (was %d) — the heal landed on the caster instead of the target", + got, casterBefore) + } +} + +// A heal cannot exceed the target's ceiling, and cannot raise the dead. Death is +// terminal for the fight — the close-out marks them, the hospital takes them — and +// a heal that resurrected a corpse would quietly rewrite the loss rules every +// close-out path depends on. +func TestAllyHeal_CapsAtMaxAndWillNotRaiseTheDead(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + + t.Run("caps at max", func(t *testing.T) { + sess := startAllyHealFight(t, p, 90) + healer, friend := basePlayer(), basePlayer() + ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend}, + enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}, + seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))} + + if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast, + Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 500, AllySeat: 1}, + }); err != nil { + t.Fatal(err) + } + if got, want := sess.seatHP(1), sess.seatHPMax(1); got > want { + t.Errorf("friend healed to %d over a max of %d", got, want) + } + }) + + t.Run("will not raise the dead", func(t *testing.T) { + sess := startAllyHealFight(t, p, 0) // seat 1 is already down + healer, friend := basePlayer(), basePlayer() + ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend}, + enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}, + seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))} + + if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast, + Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 50, AllySeat: 1}, + }); err != nil { + t.Fatal(err) + } + if got := sess.seatHP(1); got > 0 { + t.Errorf("a downed seat was healed to %d — healing keeps people up, it does not bring them back", got) + } + }) +} + +// The target parser resolves against the people in the fight, and leaves anything +// it does not recognise on the string for the spell parser — so every existing +// `!cast` form still means what it always meant. +func TestSplitCastTarget(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + sess := startAllyHealFight(t, p, 50) + healer, friend := basePlayer(), basePlayer() + healer.Name, friend.Name = "Ayla", "Bram" + ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend}, + enemy: &Combatant{Name: "d"}, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))} + + tName := t.Name() + tests := []struct { + name string + in string + wantArgs string + wantSeat int + wantError bool + }{ + {"no target", "cure wounds", "cure wounds", -1, false}, + {"slot level is not a target", "fireball 3", "fireball 3", -1, false}, + {"@mention by display name", "cure wounds @Bram", "cure wounds", 1, false}, + // The flag !help has advertised all along, and that parseCombatCast used to + // accept and silently throw away ("reserved for SP3"). + {"--target flag", "cure wounds --target @Bram", "cure wounds", 1, false}, + {"--target mid-string", "cure wounds --target Bram", "cure wounds", 1, false}, + {"--target with no name", "cure wounds --target", "", -1, true}, + {"bare display name", "cure wounds Bram", "cure wounds", 1, false}, + {"by localpart", "cure wounds @" + friendID(strings.ReplaceAll(tName, "/", "_")).Localpart(), "cure wounds", 1, false}, + {"targeting yourself is just casting it", "cure wounds @Ayla", "cure wounds", -1, false}, + {"@mention of a stranger is an error", "cure wounds @nobody", "", -1, true}, + {"an unknown bare word is left for the spell parser", "mass cure wounds", "mass cure wounds", -1, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args, seat, errMsg := splitCastTarget(ct, 0, tc.in) + if tc.wantError { + if errMsg == "" { + t.Fatalf("splitCastTarget(%q) gave no error; a mistyped @mention would silently waste the slot", tc.in) + } + return + } + if errMsg != "" { + t.Fatalf("splitCastTarget(%q) errored: %s", tc.in, errMsg) + } + if args != tc.wantArgs || seat != tc.wantSeat { + t.Errorf("splitCastTarget(%q) = (%q, %d), want (%q, %d)", + tc.in, args, seat, tc.wantArgs, tc.wantSeat) + } + }) + } +} diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index d110d4e..bfa1697 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -105,7 +105,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { // this is a no-op there); mirror it here so the entry banner and the opening // round resolve against the same ceiling startPartyCombatSession persisted and // the rebuilt rounds use. - enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats)) + enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats)) // Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded // per seat onto the session and its participant rows, so they survive the @@ -425,7 +425,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess // returns the spell plus the resolved slot level. errMsg is non-empty and // player-facing on any validation failure. It performs NO resource spend — // the caller debits the slot only once the round is about to resolve. -func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) { +// +// It takes the seat, not just the user, because "do you know this spell" is a +// question about a combatant and only *usually* a question about a database row: +// the hired companion has no rows and answers it from his synthetic sheet. +func parseCombatCast(sess *CombatSession, seat int, userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) { tokens := strings.Fields(args) upcast := 0 var spellTokens []string @@ -473,7 +477,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx) } } - known, prepared, err := playerKnowsSpell(userID, spell.ID) + known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID) if err != nil { return SpellDefinition{}, 0, "Couldn't check your spell list." } @@ -526,12 +530,92 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e // the roster so the buff is live for the round's enemy turn. Before P5 that // delta went to the session's embedded copy — seat 0 — so a party member // buffing themselves would have buffed the leader. +// splitCastTarget peels an optional ally target off the end of a `!cast` argument +// — `cure wounds @alex`, or just `cure wounds alex` — and resolves it to a seat. +// +// It resolves against the people *in this fight* rather than the room, which is +// both cheaper (no ResolveUser round-trip, no RoomID to thread down here) and +// more correct: the only legal target of a combat heal is somebody sitting in the +// combat. Anything it does not recognise is left on the string for the spell +// parser, so `!cast cure wounds` and `!cast fireball 3` behave exactly as before. +// +// Both spellings work: the `--target @alex` flag that `!help` has advertised all +// along (and that parseCombatCast has been silently swallowing since SP2 — +// "reserved for SP3, accept and ignore"), and a plain trailing `@alex`. +// +// Returns (remainingArgs, seat, errMsg). seat is -1 when no target was named. +func splitCastTarget(ct *combatTurn, caster int, args string) (string, int, string) { + args = strings.TrimSpace(args) + if args == "" || !ct.isParty() { + return args, -1, "" + } + fields := strings.Fields(args) + + // `--target ` anywhere in the string. + explicit, name := false, "" + for i := 0; i < len(fields); i++ { + if !strings.EqualFold(fields[i], "--target") { + continue + } + if i+1 >= len(fields) { + return args, -1, "`--target` needs a name: `!cast cure wounds --target @alex`." + } + explicit, name = true, strings.TrimPrefix(fields[i+1], "@") + fields = append(fields[:i], fields[i+2:]...) + break + } + + if name == "" { + last := fields[len(fields)-1] + // A bare number is a slot level (`!cast fireball 3`), never a target. + if _, err := strconv.Atoi(last); err == nil { + return args, -1, "" + } + explicit = strings.HasPrefix(last, "@") + name = strings.TrimPrefix(last, "@") + if name == "" { + return args, -1, "" + } + fields = fields[:len(fields)-1] + } + // From here `fields` is the spell tokens with the target removed. + rest := strings.Join(fields, " ") + + for i, c := range ct.players { + uid := ct.sess.seatUserID(i) + if !strings.EqualFold(c.Name, name) && + !strings.EqualFold(uid, name) && + !strings.EqualFold(id.UserID(uid).Localpart(), name) { + continue + } + if i == caster { + // Targeting yourself is just casting it on yourself, which is what the + // engine does by default. Drop the target and carry on. + return rest, -1, "" + } + return rest, i, "" + } + // An explicit @mention that matches nobody in the fight is a mistake worth + // naming — silently casting it on yourself would waste the slot. + if explicit { + return args, -1, fmt.Sprintf("**%s** isn't in this fight. Cast it on someone who is.", name) + } + return args, -1, "" +} + func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) { noop := func(bool) {} uid := id.UserID(ct.sess.seatUserID(seat)) - advChar, _ := loadAdvCharacter(uid) - c, err := p.ensureCharForDnDCmd(uid, advChar) + // §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`. + // Split the target off before the spell is parsed, so the spell parser sees + // the same string it always has. + args, targetSeat, targetErr := splitCastTarget(ct, seat, args) + if targetErr != "" { + return PlayerAction{}, noop, targetErr + } + + c, err := p.seatCastSheet(ct.sess, uid) if err != nil || c == nil { return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet." } @@ -540,7 +624,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin "%s isn't a caster class. `!attack` or `!consume ` instead.", titleClass(c.Class)) } - spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args)) + spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args)) if errMsg != "" { return PlayerAction{}, noop, errMsg } @@ -551,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin refund := func(ok bool) { if !ok && spell.Level > 0 { - _ = refundSpellSlot(uid, slotLevel) + _ = refundSeatSlot(ct.sess, seat, uid, slotLevel) } } @@ -568,7 +652,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin return PlayerAction{}, noop, fmt.Sprintf( "%s has no effect the turn-based engine can apply yet.", spell.Name) } - if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" { + if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" { return PlayerAction{}, noop, msg } ct.sess.actorStatusesPtr(seat).applyBuffDelta(d) @@ -590,7 +674,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin return PlayerAction{}, noop, fmt.Sprintf( "%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name) } - if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" { + if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" { return PlayerAction{}, noop, msg } // Park the Necromancy kill-heal stash on the casting seat. The @@ -612,6 +696,20 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin PlayerHeal: out.PlayerHeal, EnemySkip: out.EnemySkip, } + // §1 — redirect the heal onto the named ally. The roll is the same; only + // the body it lands on changes. This is the line that makes a cleric a + // cleric: until it existed, every heal in the engine was a self-heal, and + // the class whose whole job is keeping other people upright could not put + // a single hit point on a friend. + if targetSeat >= 0 && targetSeat != seat { + if out.PlayerHeal <= 0 { + return PlayerAction{}, refund, fmt.Sprintf( + "%s isn't something you can cast on someone else. Drop the target to cast it yourself.", spell.Name) + } + eff.AllyHeal, eff.AllySeat = out.PlayerHeal, targetSeat + eff.PlayerHeal = 0 + eff.Label = fmt.Sprintf("%s → %s (+%d HP)", spell.Name, ct.players[targetSeat].Name, out.PlayerHeal) + } // Concentration AOE damage spells linger: the burst lands this round // (EnemyDamage) and the same value re-ticks every round_end after, via // the engine's concentration aura. spiritual_weapon already covers the @@ -640,18 +738,30 @@ func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error { // success the caller owns the slot and must refundSpellSlot if the round itself // errors. Material components (rare in a fight) are not refunded if the slot // debit then fails — matching the auto-resolve cast path. -func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string { +func (p *AdventurePlugin) chargeSpellCost(sess *CombatSession, seat int, userID id.UserID, spell SpellDefinition, slotLevel int) string { + _, _, companion := seatCompanionLoadout(sess, userID) + + // The companion carries no purse — he has no wallet to debit and no inventory + // to stock one from, so a component cost is not a price he can pay but a spell + // he cannot cast. Refusing here (rather than letting the debit fail on an empty + // account) keeps that an explicit rule instead of an accident of his balance. if spell.MaterialCost > 0 { + if companion { + return fmt.Sprintf("%s needs a component %s doesn't carry.", spell.Name, companionDisplayName) + } if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") { return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost) } } if spell.Level > 0 { - ok, serr := consumeSpellSlot(userID, slotLevel) + ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel) if serr != nil { return "Couldn't consume slot: " + serr.Error() } if !ok { + if companion { + return fmt.Sprintf("%s is out of level-%d energy.", companionDisplayName, slotLevel) + } return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID)) } } diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 340ee07..d92f30b 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -189,6 +189,22 @@ type Combatant struct { Mods CombatModifiers IsPlayer bool Ability *MonsterAbility // non-nil for monsters with a special ability + + // SeatWeight is what this seat costs the enemy: 1.0 is a full peer of the + // leader, and less than that is a seat that brings less to the fight. Zero + // means "unset" and reads as 1.0, so every existing call site — and every solo + // fight — is unchanged. + // + // The enemy's HP bump and its action economy scale on the SUM of these rather + // than on a seat count. A seat count charges the boss the same for an + // under-levelled friend, a hired NPC, and a peer, which is why hiring a + // below-median body was measurably worse than going alone: he cost a full + // seat's worth of boss and did not give a full seat's worth back. + // + // It is derived from the seat's identity (level, and whether it is a hireling), + // NOT from fight state — so every per-round rebuild recomputes the same number + // and there is nothing to persist. See seatWeight. + SeatWeight float64 } type CombatPhase struct { @@ -220,7 +236,14 @@ type CombatEvent struct { // // It exists so a party's play-by-play can name the right person. Solo events // are all seat 0, and the omitempty tag keeps the field out of every solo - // turn_log_json — rows written before N3/P5 decode unchanged. + // turn_log_json — rows written before N3/P5 decode unchanged, and a fight in + // flight across a deploy resumes byte-identically (TestP5Fields_StayOffSoloRows). + // + // The omitempty makes seat 0 and "no seat" identical on the wire, which is + // fine for persistence and actively misleading in a diagnostic trace — it hid + // a companion who never swung, making the fight look like it had one seat. + // Do NOT fix that here; the wire format is load-bearing. The sim's trace + // serializes through simTraceEvent, which always emits the seat. Seat int `json:"Seat,omitempty"` } diff --git a/internal/plugin/combat_engine_party.go b/internal/plugin/combat_engine_party.go index 45a4930..f1aff6f 100644 --- a/internal/plugin/combat_engine_party.go +++ b/internal/plugin/combat_engine_party.go @@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP // scaled action economy to actually threaten each member. Solo is unchanged // (scale 1.0), so SimulateCombat and the golden do not move. if len(players) > 1 { - enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players)) + enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players)) } enemyStart := enemy.Stats.MaxHP if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { @@ -307,11 +307,26 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in // between — a per-round coin-flip for the extra action is the only way to land // the band in the gap. The single draw is taken only for a party, so the solo RNG // stream is untouched. +// §2(b): the budget counts the seats still STANDING, re-derived every round — +// not the seats that walked in. +// +// It used to read len(st.actors), which includes the dead. So a party that lost a +// member kept paying for them: the survivor faced a boss still swinging at +// two-player cadence, alone. That is a death spiral with the arrow pointing the +// wrong way — the moment a party is losing, the engine hits it harder — and it is +// the single nastiest thing the companion sweep turned up, because it is not a +// companion bug at all. It has been live for every human party since N3. +// +// A corpse does not threaten anybody, and the enemy has no reason to keep spending +// actions on one. func enemyActionsThisRound(st *combatState) int { - n := len(st.actors) - if n < 2 { + if livingActors(st) < 2 { return 1 } + // The summed weight of the seats still standing — not a head count of them. + // A seat that brings half a peer buys the boss half a peer's worth of extra + // swings, and a seat that is down buys none at all. + n := livingWeight(st) exp := partyActionExpectation(n) base := int(exp) if frac := exp - float64(base); frac > 0 && st.randFloat() < frac { @@ -320,6 +335,29 @@ func enemyActionsThisRound(st *combatState) int { return base } +// livingActors counts the seats still standing. +func livingActors(st *combatState) int { + n := 0 + for _, a := range st.actors { + if a.playerHP > 0 { + n++ + } + } + return n +} + +// livingWeight is livingActors in the currency the enemy actually charges in: the +// summed SeatWeight of the seats still standing. +func livingWeight(st *combatState) float64 { + total := 0.0 + for _, a := range st.actors { + if a.playerHP > 0 { + total += combatantWeight(a.c) + } + } + return total +} + // partyActionExpectation is the expected number of enemy attack-actions per round // against a party of n. A single enemy swing (the pre-P8 behaviour) let each // member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band @@ -333,17 +371,108 @@ func enemyActionsThisRound(st *combatState) int { // N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at // HP ×1.15. The whole curve is monotonic by party size and never drops a // composition below its solo clear rate — bringing a friend is never a penalty. -func partyActionExpectation(n int) float64 { +// It takes a fractional party size — the summed SeatWeight of the living seats, +// not a head count — and interpolates linearly between the integer knots the P8 +// sweep actually tuned: (1, 1.0), (2, 2.4), and 2n−1 from 3 up. Every integer +// input therefore returns exactly what it always returned, so a solo fight and a +// party of peers are byte-identical and the balance corpus is untouched. Only a +// roster of *unequal* seats lands between the knots, which is the entire point: +// a half-strength body should buy the boss half a body's worth of extra swings. +func partyActionExpectation(n float64) float64 { switch { - case n < 2: + case n <= 1: return 1 - case n == 2: - return 2.4 + case n <= 2: + // (1, 1.0) → (2, 2.4) + return 1 + 1.4*(n-1) + case n <= 3: + // (2, 2.4) → (3, 5.0); the original curve stepped here, so the segment is + // steeper than the 2n−1 line it joins. + return 2.4 + 2.6*(n-2) default: - return float64(2*n - 1) + return 2*n - 1 } } +// seatWeight is what one seat costs the enemy, relative to the leader. +// +// **Level, not stats.** A power score built from HP × damage would rank a cleric +// below a fighter and quietly make every mixed *human* party easier — the support +// classes would stop paying their way. Level is the class-neutral measure of what +// a body is worth, and it is exactly the axis on which the two seats that violated +// the peer assumption differ: an under-levelled friend, and a hireling who arrives +// a level down by design. +// +// A peer of the leader weighs 1.0, so a party of equals is unchanged. Nobody weighs +// more than a peer: out-levelling the leader does not make the boss harder for +// everyone else. +func seatWeight(seatLevel, leaderLevel int, companion bool) float64 { + if leaderLevel <= 0 || seatLevel <= 0 { + return 1 + } + w := float64(seatLevel) / float64(leaderLevel) + if w > 1 { + w = 1 + } + if companion { + // The layers a player accumulates and a hireling never will: no subclass, no + // magic items, no armed ability, and gear that is never Masterwork. Levels + // cannot see any of that, so it is priced here. + w *= companionSeatWeight + } + if w < seatWeightFloor { + w = seatWeightFloor + } + return w +} + +const ( + // companionSeatWeight is the hireling discount — the one tunable in this model. + companionSeatWeight = 0.65 + // seatWeightFloor stops a badly under-levelled seat from becoming free. A body + // in the fight is always worth something to the enemy: it is one more thing that + // has to be killed. + seatWeightFloor = 0.35 +) + +// partyWeight sums the seats' weights. An unset weight (0) reads as a full peer, +// which is what every combatant built before this existed is. +func partyWeight(players []*Combatant) float64 { + total := 0.0 + for _, c := range players { + total += combatantWeight(c) + } + return total +} + +func combatantWeight(c *Combatant) float64 { + if c == nil { + return 0 + } + if c.SeatWeight <= 0 { + return 1 + } + return c.SeatWeight +} + +// partyWeightOf is partyWeight for a value slice. +func partyWeightOf(players []Combatant) float64 { + total := 0.0 + for i := range players { + total += combatantWeight(&players[i]) + } + return total +} + +// seatSetupWeight is partyWeight for a roster that is still being seated. +func seatSetupWeight(seats []CombatSeatSetup) float64 { + total := 0.0 + for _, s := range seats { + total += combatantWeight(s.C) + } + return total +} + // partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo // (roster < 2) returns 1.0, so the solo path — and the characterization golden // and the d8prereq corpus — is byte-for-byte untouched. With the action economy @@ -355,18 +484,27 @@ func partyActionExpectation(n int) float64 { // three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led), // clearly safer than soloing (70% / 27%) but with real TPKs and member deaths — // not the 100% faceroll a single enemy swing produced. -func partyEnemyHPScale(rosterSize int) float64 { - if rosterSize < 2 { +// Like partyActionExpectation it takes the summed SeatWeight rather than a head +// count, and ramps between the same knots: weight 1 (solo) pays nothing, weight 2 +// (a peer at the leader's side) pays the full 1.15 the P8 sweep tuned. Integer +// inputs are byte-exact, so solo and a party of peers are unchanged; a roster +// carrying a below-median body pays proportionally less, because it brought +// proportionally less. +func partyEnemyHPScale(weight float64) float64 { + if weight <= 1 { return 1.0 } - return 1.15 + if weight >= 2 { + return 1.15 + } + return 1.0 + 0.15*(weight-1) } // scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding // rule, so every call site (the auto-resolve engine, the party session's initial // persist, and the per-turn rebuild) agrees on the same number. -func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int { - return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize)) +func scaledEnemyMaxHP(baseMaxHP int, weight float64) int { + return int(float64(baseMaxHP) * partyEnemyHPScale(weight)) } // enemyActionPlan is the shared action budget both combat engines resolve an diff --git a/internal/plugin/combat_engine_party_test.go b/internal/plugin/combat_engine_party_test.go index 42769a5..9ae79ea 100644 --- a/internal/plugin/combat_engine_party_test.go +++ b/internal/plugin/combat_engine_party_test.go @@ -142,11 +142,22 @@ func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) { if got := partyActionExpectation(2); got != 2.4 { t.Fatalf("duo action expectation = %v, want 2.4", got) } + // The curve now takes a fractional weight rather than a head count, so that a + // below-median seat costs the enemy less than a peer does. Every INTEGER input + // must still return exactly what it always returned — that is what keeps solo + // and a party of peers byte-identical, and the balance corpus with them. for n := 3; n <= 5; n++ { - if got, want := partyActionExpectation(n), float64(2*n-1); got != want { + if got, want := partyActionExpectation(float64(n)), float64(2*n-1); got != want { t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want) } } + // A duo carrying a half-strength body sits between soloing and a true duo. + if got := partyActionExpectation(1.5); got <= 1 || got >= 2.4 { + t.Fatalf("weight 1.5 action expectation = %v, want strictly between 1 and 2.4", got) + } + if got := partyEnemyHPScale(1.5); got <= 1.0 || got >= 1.15 { + t.Fatalf("weight 1.5 HP scale = %v, want strictly between 1.0 and 1.15", got) + } if got := partyEnemyHPScale(3); got != 1.15 { t.Fatalf("party HP scale = %v, want 1.15", got) } diff --git a/internal/plugin/combat_party_characterization_test.go b/internal/plugin/combat_party_characterization_test.go new file mode 100644 index 0000000..43614c9 --- /dev/null +++ b/internal/plugin/combat_party_characterization_test.go @@ -0,0 +1,159 @@ +package plugin + +import ( + "flag" + "fmt" + "math/rand/v2" + "os" + "path/filepath" + "strings" + "testing" +) + +// Party characterization — the net that did not exist. +// +// TestCombatCharacterization pins SOLO combat exhaustively, and it has been the +// tripwire for every balance change since. Party combat had nothing. The whole +// N-body layer — initiative order, per-seat resolution, enemy targeting, the P8 +// action-economy and HP scaling, downed-seat handling — shipped unpinned. +// +// What that cost: N3 shipped a Cleric class that cannot heal a single ally (no +// action in the engine can target another seat) and not one test went red. The +// hired companion then stood in fights doing nothing, and the unit tests stayed +// green through that too. It took a 1500-run expedition sweep to see either. +// +// So: pin it. Any change to the party engine now has to state, in the diff, what +// it moved. Regenerate ONLY on purpose: +// +// go test ./internal/plugin -run TestPartyCharacterization -update +// +// This golden covers simulateParty — the auto-resolve engine, which decides most +// expedition rooms and which the balance harness is built on. The turn engine's +// party half (manual play + boss fights) needs DB fixtures and is pinned by +// combat_turn_party_test.go; widening THAT into a golden is the follow-up. + +var updatePartyGolden = flag.Bool("update-party", false, "rewrite the party characterization golden") + +// partyScenario is one pinned N-body fight. +type partyScenario struct { + name string + seats []Combatant + enemy Combatant + phases []CombatPhase +} + +// seatOfClass shapes a seat that stands in for a class archetype. These are +// deliberately engine-level (stat blocks, not sheets): the golden pins what the +// ENGINE does with a roster, not what the character layer feeds it. +func seatOfClass(name string, hp, atk, def, speed int) Combatant { + c := basePlayer() + c.Name = name + c.Stats.MaxHP = hp + c.Stats.Attack = atk + c.Stats.Defense = def + c.Stats.Speed = speed + return c +} + +func partyCharacterizationScenarios() []partyScenario { + tank := seatOfClass("Tank", 160, 14, 12, 8) + striker := seatOfClass("Striker", 90, 22, 6, 14) + support := seatOfClass("Support", 110, 11, 9, 10) + // The seat this whole plan is about: a body that is real but below median. + // If scaling ever stops overcharging for it, THIS line is what moves. + weak := seatOfClass("Weak", 70, 8, 5, 9) + // A seat that will fall early. Its corpse must not keep buffing the enemy — + // when §2 lands, this scenario is the one that proves it. + glass := seatOfClass("Glass", 12, 18, 0, 16) + + return []partyScenario{ + {"duo/even", []Combatant{tank, striker}, tankyEnemy(), dungeonCombatPhases}, + {"duo/tank+support", []Combatant{tank, support}, tankyEnemy(), dungeonCombatPhases}, + {"duo/median+weak", []Combatant{tank, weak}, tankyEnemy(), dungeonCombatPhases}, + {"duo/glass falls early", []Combatant{tank, glass}, hardHitEnemy(), dungeonCombatPhases}, + {"trio/even", []Combatant{tank, striker, support}, tankyEnemy(), dungeonCombatPhases}, + {"trio/one weak seat", []Combatant{tank, striker, weak}, tankyEnemy(), dungeonCombatPhases}, + {"trio/two glass seats", []Combatant{tank, glass, glass}, hardHitEnemy(), dungeonCombatPhases}, + {"duo/vs ability enemy", []Combatant{tank, striker}, + abilityEnemy("Wither", "poison", "Duel"), dungeonCombatPhases}, + // The degenerate case. A one-seat roster MUST stay bit-identical to solo — + // it is the invariant the entire balance corpus rests on, and the reason + // the solo golden is allowed to stay untouched while this file grows. + {"solo/one-seat roster", []Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases}, + } +} + +// formatPartyResult prints the seat on EVERY line. The solo formatter does not +// (it cannot; there is only one seat), and CombatEvent.Seat is `omitempty`, so a +// JSON trace renders seat 0 and "no seat" identically — which sent me chasing a +// phantom "the companion never swings" bug for an hour. A party golden that +// could not tell you WHO acted would be worth very little. +func formatPartyResult(r PartyCombatResult) string { + var b strings.Builder + fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d survivors=%v\n", + r.PlayerWon, r.TimedOut, r.TotalRounds, r.AnySurvivor()) + fmt.Fprintf(&b, " enemy: start=%d entry=%d end=%d\n", r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP) + for i, s := range r.Seats { + fmt.Fprintf(&b, " seat[%d]: start=%d entry=%d end=%d\n", + i, s.PlayerStartHP, s.PlayerEntryHP, s.PlayerEndHP) + } + for i, e := range r.Events { + fmt.Fprintf(&b, " [%02d] r%d seat=%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n", + i, e.Round, e.Seat, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP, + e.Roll, e.RollAgainst, e.Desc) + } + return b.String() +} + +func TestPartyCharacterization(t *testing.T) { + var b strings.Builder + for _, sc := range partyCharacterizationScenarios() { + for _, seed := range charSeeds { + rng := rand.New(rand.NewPCG(seed, 0xC0FFEE)) + res := simulatePartyWithRNG(sc.seats, sc.enemy, sc.phases, rng) + fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed) + b.WriteString(formatPartyResult(res)) + b.WriteString("\n") + } + } + got := b.String() + + path := filepath.Join("testdata", "party_characterization.golden") + if *updatePartyGolden { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + t.Log("party golden rewritten:", path) + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read party golden (run with -update-party to create it): %v", err) + } + if string(want) != got { + t.Fatalf("PARTY ENGINE BEHAVIOUR MOVED.\n\n%s\n\n"+ + "If this was deliberate, say so in the commit and regenerate:\n"+ + " go test ./internal/plugin -run TestPartyCharacterization -update-party", + firstDiff(string(want), got)) + } +} + +// The one-seat roster is the solo engine. If this ever fails, the N-body path has +// stopped being a superset of the path the entire balance corpus was measured on, +// and every baseline in the repo is suspect. +func TestPartyCharacterization_OneSeatIsStillSolo(t *testing.T) { + for _, seed := range charSeeds { + solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, + rand.New(rand.NewPCG(seed, 0xC0FFEE))) + party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases, + rand.New(rand.NewPCG(seed, 0xC0FFEE))) + + if solo.PlayerWon != party.PlayerWon || solo.TotalRounds != party.TotalRounds || + solo.EnemyEndHP != party.EnemyEndHP || solo.PlayerEndHP != party.Seats[0].PlayerEndHP { + t.Fatalf("seed %d: a one-seat roster diverged from solo\n solo: won=%v rounds=%d ehp=%d php=%d\n party: won=%v rounds=%d ehp=%d php=%d", + seed, + solo.PlayerWon, solo.TotalRounds, solo.EnemyEndHP, solo.PlayerEndHP, + party.PlayerWon, party.TotalRounds, party.EnemyEndHP, party.Seats[0].PlayerEndHP) + } + } +} diff --git a/internal/plugin/combat_party_finish.go b/internal/plugin/combat_party_finish.go index b491bae..b242c89 100644 --- a/internal/plugin/combat_party_finish.go +++ b/internal/plugin/combat_party_finish.go @@ -55,6 +55,18 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string { // so does the bookkeeping that outlives the fight — a Berserker who raged and // lost is still exhausted. Both fan out; neither is the owner's alone. for seat := range sess.RosterSize() { + // The companion has no sheet, so none of the sheet-keyed bookkeeping below + // applies to him — and postCombatBookkeepingForSeat logs at ERROR for a + // seat with no sheet, which would file one for every party fight he is ever + // hired for. But his HP is not bookkeeping: it is the fight's result. It + // lands on his roster row, because that is the only row he has. + // + // He used to be skipped outright, and "he arrives fresh next time" was the + // stated intent. It is a free lunch — see companionSeatHP. + if isCompanionUser(sess.seatUserID(seat)) { + _ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat)) + continue + } persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat)) p.postCombatBookkeepingForSeat(sess, seat) } @@ -100,6 +112,16 @@ func (p *AdventurePlugin) finishPartyWin( uid := id.UserID(sess.seatUserID(seat)) hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) + // The hired companion takes no cut. He earns no XP (there is no sheet to + // put it on), rolls no loot (dropZoneLoot writes real inventory rows for + // whatever id it is handed, and a bot with a magic sword is nobody's + // intent), and takes no death row. His seat renders nothing: he is not + // reading this. He was paid up front. + if isCompanionSeat(uid) { + out[seat] = "" + continue + } + // A member who went down before the killing blow still earns the kill. p.grantSeatWinXP(uid, hp, hpMax, monster, tier) @@ -148,7 +170,17 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence) out := make([]string, sess.RosterSize()) for seat := range sess.RosterSize() { - markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display) + uid := id.UserID(sess.seatUserID(seat)) + // The companion does not die. markAdventureDead is a silent no-op for him + // today (no player_meta row to mark), but relying on that accident is how + // he ends up in the graveyard the first time someone gives him a row — + // and emitDeathNews would have the news bot file a death notice about + // itself. Say it out loud instead. + if isCompanionSeat(uid) { + out[seat] = "" + continue + } + markAdventureDead(uid, "zone", zone.Display) var b strings.Builder if line != "" && seat == 0 { b.WriteString(line + "\n") @@ -220,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) { } } _ = abandonZoneRun(owner) - reason := "combat flee" + reason := lossCombatFlee if death { - reason = "combat death" + reason = lossCombatDeath } forceExtractExpeditionForRunLoss(owner, reason) } diff --git a/internal/plugin/combat_party_start.go b/internal/plugin/combat_party_start.go index c2f715a..7707489 100644 --- a/internal/plugin/combat_party_start.go +++ b/internal/plugin/combat_party_start.go @@ -33,7 +33,9 @@ func fightRoster(sender id.UserID) []id.UserID { if err != nil || e == nil { return []id.UserID{sender} } - return expeditionAudience(e) + // Seats, not audience: the hired companion fights even though he never + // receives a DM about it. + return expeditionSeats(e) } // buildFightSeats turns a roster into the seats that will actually sit down, and @@ -56,9 +58,40 @@ func (p *AdventurePlugin) buildFightSeats( senderSkip = why } } + // Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the + // enemy is priced from these once the roster is final. + var levels []int + var companions []bool for i, uid := range roster { leader := i == 0 + // The hired companion. He must be handled before everything below: + // dndHPSnapshot returns (0,0) for a user with no sheet, so the very next + // check would quietly sit him out of every fight he was paid for, and + // buildZoneCombatants would then fail on him anyway. + // + // He is latched onto autopilot at seat time rather than after the away-player + // deadline — nobody is going to type for him, and waiting three minutes to + // discover that would stall the fight and then announce him to the party as + // an absent player. + if isCompanionSeat(uid) { + expID := companionExpeditionFor(roster[0]) + class, level := companionLoadout(expID) + player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood) + // He carries his wounds between fights, like everyone else. Seating him at + // full max HP — which is what this did — hands the party an infinite body: + // he soaks a share of every fight's incoming and then resets, while the + // humans beside him bleed all the way to camp. + seats = append(seats, CombatSeatSetup{ + UserID: uid, + HP: companionSeatHP(expID, player.Stats.MaxHP), + HPMax: player.Stats.MaxHP, + Mods: player.Mods, C: &player, EngineDriven: true, + }) + levels, companions = append(levels, level), append(companions, true) + continue + } + // Both refusals below are cheap and neither needs the build, so they run // before it: consuming a seat's armed ability and *then* sitting them out // would spend their rage on a fight they never joined. @@ -94,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats( trySimAutoArm(c) armed = consumeArmedAbility(c) } - player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed) + player, e, dc, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed) if err != nil { if leader { return nil, nil, "", "Couldn't set up the fight: " + err.Error() @@ -113,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats( seats = append(seats, CombatSeatSetup{ UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed, }) + lvl := 0 + if dc != nil { + lvl = dc.Level + } + levels, companions = append(levels, lvl), append(companions, false) } + + // Price each seat against the leader. It runs here, over the seats that were + // actually seated — a member who was skipped (downed, busy elsewhere) never + // joined the fight and must not be charged to the enemy. + applySeatWeights(seatCombatants(seats), levels, companions) return seats, enemy, senderSkip, "" } @@ -159,6 +202,12 @@ func (p *AdventurePlugin) announcePartyFightStart( names[i] = c.Name } for seat, uid := range sess.SeatUserIDs() { + // Seat-keyed fan-out, so it bypasses expeditionAudience's filter — the + // companion sits down but is never written to. (He also has no magic items + // to line up, and activeMagicItemsLine would go looking for them.) + if isCompanionUser(uid) { + continue + } var b strings.Builder b.WriteString(header) b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat))) diff --git a/internal/plugin/combat_party_turn.go b/internal/plugin/combat_party_turn.go index da5c7d6..cc0ec17 100644 --- a/internal/plugin/combat_party_turn.go +++ b/internal/plugin/combat_party_turn.go @@ -166,6 +166,15 @@ func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) ( return fail(notYourTurnMsg(players, acting, waiting)) } + // An engine-driven seat has nobody to hand the wheel back to. A command + // arriving from one is not a player returning to the keyboard — it is a driver + // impersonating a seat, which is precisely the confusion that used to strand + // the companion: his own auto-played turn came back through here looking like + // a keystroke and cleared the latch that was moving him. Refuse it outright. + if sess.seatIsEngineDriven(seat) { + return fail("That seat isn't yours to play.") + } + // They typed, so they are here. Hand back the wheel. sess.actorStatusesPtr(seat).Autopilot = false @@ -315,6 +324,33 @@ func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) { p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct)) } +// driveEngineSeat plays one engine-driven seat's turn and persists the result. +// It is the seat-driver equivalent of a player typing `!attack`, for a seat that +// will never type anything. +// +// It exists because the alternative — dispatching a combat command as that seat — +// sends the turn back through beginCombatTurn, which reads any command from a +// seat as "that player is back". For a human that is correct. For a seat with no +// human it is fatal: it drops the latch that is the only thing moving them. +func (p *AdventurePlugin) driveEngineSeat(sess *CombatSession, seat int) error { + players, enemy, err := p.partyCombatantsForSession(sess) + if err != nil { + return fmt.Errorf("rebuild for engine seat: %w", err) + } + ct := &combatTurn{ + sess: sess, players: players, enemy: enemy, + seat: seat, uid: id.UserID(sess.seatUserID(seat)), + } + if _, err := p.runAutoSeatTurn(ct, seat); err != nil { + return err + } + if err := saveCombatSession(sess); err != nil { + return fmt.Errorf("save after engine seat turn: %w", err) + } + p.closePartyRound(ct) + return nil +} + // turnDeadlineLapsed reports whether the fight has sat on one member's turn past // partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the // save that parked the fight on this seat's player_turn was the last one — so it @@ -387,6 +423,12 @@ func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEven names := ct.seatNames() acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy) for seat, uid := range ct.sess.SeatUserIDs() { + // The combat fan-out is seat-keyed, so it does not pass through + // expeditionAudience and does not inherit its companion filter. He fights; + // he is not written to about it. + if isCompanionUser(uid) { + continue + } // Rendered once per reader: the flavor pool speaks in the second person, // so each member's own events must be theirs and nobody else's. body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat) diff --git a/internal/plugin/combat_seat_spells.go b/internal/plugin/combat_seat_spells.go new file mode 100644 index 0000000..1ba5bf3 --- /dev/null +++ b/internal/plugin/combat_seat_spells.go @@ -0,0 +1,174 @@ +package plugin + +import ( + "maunium.net/go/mautrix/id" +) + +// Seat-scoped spellbook. +// +// Every spell lookup in the engine — known list, prepared flag, slot pool, slot +// spend — is keyed on a Matrix user id and answered by a `dnd_*` table. That was +// fine while every combatant was a player. The hired companion is not: he has no +// row in dnd_character, dnd_known_spells or dnd_spell_slots, deliberately, because +// a sheet on disk for him is the thing that would turn him into a real character +// everywhere (player_meta, the leaderboard, !stats). His sheet is synthesized per +// fight and thrown away. +// +// So every one of those lookups returned "nothing" for him, and the picker's very +// first line — `c, _ := LoadDnDCharacter(uid); if c == nil { return "attack" }` — +// sent him to swing a mace, every turn, forever. A hired Cleric could not heal. +// Role-fill hands a lone fighter a Cleric, so that was the *common* case. +// +// These are the seat-scoped forms of those lookups. A human seat delegates to the +// DB functions verbatim — same queries, same order, so solo combat and the balance +// corpus are untouched. A companion seat is answered from his in-memory sheet and +// a slot ledger on his seat's persisted statuses. +// +// Nothing here may write a row for the companion. That invariant is what +// TestCompanion_SheetIsBelowMedianAndNeverPersisted pins, and the auto-migration +// inside ensureCharForDnDCmd would violate it silently: handed a user with no +// sheet, it *builds one at level 1 and saves it*. Hence seatCastSheet. + +// seatCompanionLoadout returns the class and level a companion seat fights as, +// and whether the seat is the companion at all. +func seatCompanionLoadout(sess *CombatSession, uid id.UserID) (DnDClass, int, bool) { + if sess == nil || !isCompanionSeat(uid) { + return "", 0, false + } + class, level := companionLoadoutForRun(sess.RunID) + return class, level, true +} + +// seatCastSheet resolves the character behind a seat for the !cast path. +// +// A human goes through ensureCharForDnDCmd, exactly as before — including its +// auto-migration for a player who predates Adv 2.0. The companion must NOT: that +// migration would mint and persist a level-1 dnd_character row for him, quietly +// making him a player and throwing away the class and level he was hired at. He +// gets his synthetic sheet instead, built from the same class-priority pipeline a +// real character of his level uses. +func (p *AdventurePlugin) seatCastSheet(sess *CombatSession, uid id.UserID) (*DnDCharacter, error) { + if class, level, ok := seatCompanionLoadout(sess, uid); ok { + return companionSheet(class, level), nil + } + advChar, _ := loadAdvCharacter(uid) + return p.ensureCharForDnDCmd(uid, advChar) +} + +// seatPickSheet is seatCastSheet for the auto-picker, which reads a sheet to +// decide a turn and must never create one. Humans get LoadDnDCharacter, which is +// what the picker has always called; a miss returns nil and the caller swings. +func seatPickSheet(sess *CombatSession, uid id.UserID) *DnDCharacter { + if class, level, ok := seatCompanionLoadout(sess, uid); ok { + return companionSheet(class, level) + } + c, _ := LoadDnDCharacter(uid) + return c +} + +// companionKnownSpells is the companion's spell list: the same default kit +// ensureSpellsForCharacter grants a real character of that class and level, every +// entry prepared (which is also what that function does — preparation is SP4 and +// until it lands every granted spell is auto-prepared so !cast works). +// +// He gets the player kit on purpose. His below-median comes from the level penalty, +// the never-Masterwork gear and the absent subclass/magic items — the layers a +// player accumulates. Handing him a bespoke, weaker spell list would be a second +// nerf hidden in a different file, and it would drift away from the tuned list the +// moment anyone touched one and not the other. +func companionKnownSpells(class DnDClass, level int) []knownSpellRow { + ids := defaultKnownSpells(class, level) + out := make([]knownSpellRow, 0, len(ids)) + for _, sid := range ids { + out = append(out, knownSpellRow{SpellID: sid, Source: "companion", Prepared: true}) + } + return out +} + +// companionSlotPool is his slot table (total, used) — the class/level progression +// every caster shares, less what he has already spent. `used` is the expedition's +// ledger, NOT a per-fight one: he rations one pool across the run and gets it back +// at camp, exactly as a human caster does. +func companionSlotPool(class DnDClass, level int, used [6]int) map[int][2]int { + out := map[int][2]int{} + for lvl, total := range slotsForClassLevel(class, level) { + spent := 0 + if lvl >= 0 && lvl < len(used) { + spent = min(used[lvl], total) + } + out[lvl] = [2]int{total, spent} + } + return out +} + +// seatKnownSpells is listKnownSpells for a seat. +func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpellRow, error) { + if class, level, ok := seatCompanionLoadout(sess, uid); ok { + return companionKnownSpells(class, level), nil + } + return listKnownSpells(uid) +} + +// seatSpellSlots is getSpellSlots for a seat. +func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) { + if class, level, ok := seatCompanionLoadout(sess, uid); ok { + return companionSlotPool(class, level, companionSlotsForRun(sess.RunID)), nil + } + return getSpellSlots(uid) +} + +// seatKnowsSpell is playerKnowsSpell for a seat: (known, prepared, err). +func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string) (bool, bool, error) { + if _, _, ok := seatCompanionLoadout(sess, uid); ok { + known, err := seatKnownSpells(sess, seat, uid) + if err != nil { + return false, false, err + } + for _, k := range known { + if k.SpellID == spellID { + return true, k.Prepared, nil + } + } + return false, false, nil + } + return playerKnowsSpell(uid, spellID) +} + +// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on +// the expedition's ledger — one pool for the whole run, refilled at camp — so he +// rations his slots the way a human caster has to. Keying it by expedition also +// keeps two parties who have each hired him from sharing a pool, which anything +// keyed on his (single, shared) user id would have done. +func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) { + class, level, ok := seatCompanionLoadout(sess, uid) + if !ok { + return consumeSpellSlot(uid, slotLevel) + } + used := companionSlotsForRun(sess.RunID) + if slotLevel < 1 || slotLevel >= len(used) { + return false, nil + } + pair, exists := companionSlotPool(class, level, used)[slotLevel] + if !exists || pair[0]-pair[1] <= 0 { + return false, nil + } + used[slotLevel]++ + if err := setCompanionSlotsForRun(sess.RunID, used); err != nil { + return false, err + } + return true, nil +} + +// refundSeatSlot is refundSpellSlot for a seat: the rollback half of the above, +// called when the round the slot was spent on failed to resolve. +func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) error { + if _, _, ok := seatCompanionLoadout(sess, uid); !ok { + return refundSpellSlot(uid, slotLevel) + } + used := companionSlotsForRun(sess.RunID) + if slotLevel < 1 || slotLevel >= len(used) || used[slotLevel] <= 0 { + return nil + } + used[slotLevel]-- + return setCompanionSlotsForRun(sess.RunID, used) +} diff --git a/internal/plugin/combat_seat_spells_test.go b/internal/plugin/combat_seat_spells_test.go new file mode 100644 index 0000000..cb1928e --- /dev/null +++ b/internal/plugin/combat_seat_spells_test.go @@ -0,0 +1,277 @@ +package plugin + +import ( + "strings" + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// The hired companion casts. +// +// He could not, and the reason was one line: every spell lookup in the engine is +// keyed on a user id and answered by a dnd_* table, and he has no rows in any of +// them — deliberately, because a sheet on disk is what would turn him into a real +// character everywhere. So the picker's first statement (`LoadDnDCharacter(uid)`) +// came back nil and returned "attack", every turn, for the whole fight. +// +// Role-fill hands a lone martial a Cleric. So the common case of the feature was a +// healer who could not heal, in a party engine that had only just learned to let +// anyone heal anyone (§1). These pin both halves: that he picks the heal, and that +// picking it leaves no trace of him in the database. + +// hireForFight seeds an expedition with the companion hired into it, and returns +// the run id his loadout is resolved against. +func hireForFight(t *testing.T, expID string, owner id.UserID, class DnDClass, level int) string { + t.Helper() + seedExpedition(t, expID, owner, "active") + seatLeaderFixture(t, expID) + if err := hireCompanion(expID, class, level); err != nil { + t.Fatalf("hireCompanion: %v", err) + } + return "run-" + expID +} + +// petePartyFight seats a hurt leader and the companion, and returns the turn. +func petePartyFight(t *testing.T, p *AdventurePlugin, runID string, leader id.UserID, leaderHP int) *combatTurn { + t.Helper() + monster := dndBestiary["goblin"] + lead, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0) + lead.Name = "lead" + pete, _, _ := p.companionCombatant(ClassCleric, 6, monster, 2, 0) + pete.Name = companionDisplayName + enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}} + + sess, err := p.startPartyCombatSession(runID, "enc", "goblin", &enemy, []CombatSeatSetup{ + {UserID: leader, HP: leaderHP, HPMax: 100, Mods: lead.Mods, C: &lead}, + {UserID: companionUserID(), HP: 60, HPMax: 60, Mods: pete.Mods, C: &pete, EngineDriven: true}, + }) + if err != nil { + t.Fatal(err) + } + return &combatTurn{ + sess: sess, players: []*Combatant{&lead, &pete}, enemy: &enemy, + seat: 1, uid: companionUserID(), + } +} + +// The headline: a hired Cleric, watching the leader bleed out, casts a heal on him. +// Before the seat-scoped spellbook this returned ("attack", "") — he swung a mace +// at the boss while the man who paid for him died. +func TestCompanionSpells_HiredClericHealsTheLeader(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + leader := id.UserID("@lead:example.org") + runID := hireForFight(t, "exp-heal", leader, ClassCleric, 6) + ct := petePartyFight(t, p, runID, leader, 20) // leader at 20/100 — well under the 45% bar + + kind, arg := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1) + if kind != "cast" { + t.Fatalf("the hired cleric picked %q %q with the leader at 20%% HP — he must heal", kind, arg) + } + if !strings.Contains(arg, "@lead") { + t.Fatalf("cast arg = %q, want the heal aimed at the leader's seat", arg) + } + + // And it lands on the leader, not on himself. + action, settle, msg := p.castActionForSeat(ct, 1, arg) + if msg != "" { + t.Fatalf("castActionForSeat refused the companion's own pick: %s", msg) + } + settle(true) + eff := action.Effect + if eff == nil || eff.AllyHeal <= 0 { + t.Fatalf("effect = %+v, want an ally heal", eff) + } + if eff.AllySeat != 0 { + t.Errorf("heal landed on seat %d, want seat 0 (the leader)", eff.AllySeat) + } + if eff.PlayerHeal != 0 { + t.Errorf("the heal also healed the caster (%d HP) — it was redirected, not copied", eff.PlayerHeal) + } +} + +// His slots are spent on his seat, and he leaves no rows behind. +// +// This is the invariant with teeth. castActionForSeat used to load the caster via +// ensureCharForDnDCmd, whose auto-migration branch — handed a user with no sheet — +// *builds one at level 1 and saves it*. Pointed at the companion that silently +// makes him a player: a dnd_character row, a player_meta seed, a spellbook, and a +// level-1 chassis in place of the level he was hired at. +func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + leader := id.UserID("@lead:example.org") + runID := hireForFight(t, "exp-rows", leader, ClassCleric, 6) + ct := petePartyFight(t, p, runID, leader, 20) + + action, settle, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead") + if msg != "" { + t.Fatalf("the hired cleric could not cast cure wounds: %s", msg) + } + settle(true) + if action.Effect == nil || action.Effect.AllyHeal <= 0 { + t.Fatalf("effect = %+v, want an ally heal", action.Effect) + } + + // The slot came off the expedition's ledger — which is where it has to live, so + // that it is still spent in the NEXT fight of the same run. + if used := companionSlotsForRun(ct.sess.RunID); used[1] != 1 { + t.Errorf("companion spent %v level-1 slots on the run's ledger, want 1", used[1]) + } + + for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} { + var n int + if err := db.Get().QueryRow( + `SELECT COUNT(*) FROM `+table+` WHERE user_id = ?`, string(companionUserID()), + ).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + if n != 0 { + t.Errorf("casting wrote %d %s row(s) for the companion — he is not a player", n, table) + } + } +} + +// He runs dry like anybody else, and then he swings. A companion with an infinite +// spell pool is the "carry" the whole design says he must never be. +func TestCompanionSpells_RunsOutOfSlots(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + leader := id.UserID("@lead:example.org") + runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6) + ct := petePartyFight(t, p, runID, leader, 20) + + total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0] + if total <= 0 { + t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve") + } + for i := range total { + if ok, err := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil || !ok { + t.Fatalf("slot %d/%d: consume = %v (%v), want it to succeed", i+1, total, ok, err) + } + } + if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); ok { + t.Fatalf("the companion cast a %d-th level-1 spell out of a %d-slot pool", total+1, total) + } + + // The refund half: a round that fails to resolve gives the slot back. + if err := refundSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil { + t.Fatal(err) + } + if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); !ok { + t.Error("a refunded slot was not castable again") + } +} + +// His pool does NOT refill between fights, and it DOES come back at camp. +// +// This is the one the sweep caught and the unit tests did not. The first cut of +// the spellbook parked his ledger on his combat seat — and a seat is per-session, +// so every fight opened a fresh one and he walked in with full slots. A human +// cleric rations a single pool across the whole run; rationing it IS the caster's +// game. Handed an infinite pool, a gearless level-penalized hireling out-cleared a +// same-level human cleric by 15pp in the sim. +func TestCompanionSpells_PoolIsRationedAcrossTheRun(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + leader := id.UserID("@lead:example.org") + runID := hireForFight(t, "exp-ration", leader, ClassCleric, 6) + + // Fight one: spend every level-1 slot he owns. + first := petePartyFight(t, p, runID, leader, 20) + total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0] + for range total { + if ok, err := consumeSeatSlot(first.sess, 1, companionUserID(), 1); err != nil || !ok { + t.Fatalf("consume: %v (%v)", ok, err) + } + } + + // Fight two, same run — a NEW combat session, which is exactly what used to + // hand him a fresh pool. + if err := markCombatSessionExpired(first.sess.SessionID); err != nil { + t.Fatal(err) + } + second := petePartyFight(t, p, runID, leader, 20) + if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); ok { + t.Error("the companion's spell slots refilled between fights — he is an infinite caster") + } + + // ...and camp gives them back, the same way it does for every human. + if err := refreshCompanionSlots("exp-ration"); err != nil { + t.Fatal(err) + } + if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); !ok { + t.Error("camp did not restore the companion's slots — his pool only ever goes down") + } +} + +// He carries his wounds between fights, and camp patches him up. +// +// He used to re-seat at full max HP for every single fight — "he arrives fresh +// next time" was the close-out's stated intent — which is an infinite body. A +// player bleeds across a 30-room run and only heals at camp; the hireling soaked +// his share of every fight and then reset. In the sim his party fled 5 runs out of +// 640 where the same party with a *human* cleric fled 56. +func TestCompanion_CarriesWoundsBetweenFights(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + seedExpedition(t, "exp-hp", leader, "active") + seatLeaderFixture(t, "exp-hp") + if err := hireCompanion("exp-hp", ClassCleric, 6); err != nil { + t.Fatal(err) + } + + // A fresh hire is at full. + if got := companionSeatHP("exp-hp", 100); got != 100 { + t.Errorf("a fresh hire seats at %d/100 HP, want full", got) + } + + // He walks out of a fight on 30 HP; he walks into the next one on 30 HP. + if err := setCompanionHP("exp-hp", 30); err != nil { + t.Fatal(err) + } + if got := companionSeatHP("exp-hp", 100); got != 30 { + t.Errorf("he re-seated at %d/100 HP after ending a fight on 30 — the wound did not carry", got) + } + + // Dropped in a fight, he comes back on his feet but barely — not as a corpse + // (there is no companion-death rule) and not at full. + if err := setCompanionHP("exp-hp", 0); err != nil { + t.Fatal(err) + } + if got := companionSeatHP("exp-hp", 100); got != 1 { + t.Errorf("after being dropped he seats at %d/100 HP, want 1", got) + } + + // Camp puts him right, exactly as it does every human. + if err := refreshCompanionHP("exp-hp"); err != nil { + t.Fatal(err) + } + if got := companionSeatHP("exp-hp", 100); got != 100 { + t.Errorf("camp left him on %d/100 HP — his body only ever goes down", got) + } +} + +// A hired martial is still a martial: the spellbook is the class's, not a blanket +// grant, so hiring a Fighter does not quietly buy a caster. +func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + leader := id.UserID("@lead:example.org") + runID := hireForFight(t, "exp-mart", leader, ClassFighter, 6) + ct := petePartyFight(t, p, runID, leader, 20) + + if known, _ := seatKnownSpells(ct.sess, 1, companionUserID()); len(known) != 0 { + t.Errorf("a hired Fighter knows %d spells, want none", len(known)) + } + if _, _, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead"); msg == "" { + t.Error("a hired Fighter cast cure wounds") + } + kind, _ := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1) + if kind != "attack" { + t.Errorf("a hired Fighter picked %q, want attack", kind) + } +} diff --git a/internal/plugin/combat_seat_weight_test.go b/internal/plugin/combat_seat_weight_test.go new file mode 100644 index 0000000..9200bcf --- /dev/null +++ b/internal/plugin/combat_seat_weight_test.go @@ -0,0 +1,108 @@ +package plugin + +import "testing" + +// §2(a) — the enemy charges for what a seat BRINGS, not for the fact that it sat +// down. +// +// Both scaling levers (the +15% boss HP and the 1 → 2.4 attacks-a-round action +// economy) counted seats. A seat count charges the same for an under-levelled +// friend, a hired NPC, and a true peer — so a below-median body cost a full seat's +// worth of boss and did not give a full seat's worth back. Measured: hiring the +// companion was *worse than going alone* (66.1% against solo's 69.0%) once his +// free full-heal was taken away. +// +// The invariant that makes this safe to ship: every seat that IS a peer still +// weighs exactly 1.0, so solo and a party of equals are byte-identical and the +// balance corpus does not move. Only an unequal roster lands between the knots. + +func TestSeatWeight_APeerWeighsExactlyOne(t *testing.T) { + // The leader, and a friend of the leader's level: both full price. + if got := seatWeight(10, 10, false); got != 1.0 { + t.Errorf("a peer weighs %v, want exactly 1.0 — a party of equals must not move", got) + } + // Out-levelling the leader does not make the boss harder for everybody else. + if got := seatWeight(14, 10, false); got != 1.0 { + t.Errorf("a higher-level friend weighs %v, want 1.0 (capped)", got) + } +} + +func TestSeatWeight_TheUnderLevelledAndTheHiredCostLess(t *testing.T) { + // The under-levelled friend — the case that has nothing to do with the + // companion and has been live since parties shipped. + half := seatWeight(5, 10, false) + if half >= 1.0 || half <= seatWeightFloor { + t.Errorf("a level-5 friend of a level-10 leader weighs %v, want between the floor and 1.0", half) + } + // The hireling pays the same level penalty AND the discount for everything a + // player accrues that he never will (subclass, magic items, Masterwork gear). + hired := seatWeight(9, 10, true) + peerAtSameLevel := seatWeight(9, 10, false) + if hired >= peerAtSameLevel { + t.Errorf("the hireling weighs %v and a human of his level weighs %v — he must cost the enemy less", + hired, peerAtSameLevel) + } + // But a body is never free: it is still one more thing the boss has to kill. + if got := seatWeight(1, 20, true); got < seatWeightFloor { + t.Errorf("a hopelessly under-levelled hireling weighs %v, below the floor %v", got, seatWeightFloor) + } +} + +// The levers themselves: integer knots byte-exact, fractions in between. +func TestSeatWeight_ScalingIsExactAtThePeerKnots(t *testing.T) { + // Solo and a party of peers reproduce the pre-§2(a) numbers exactly. This is + // the whole safety argument — if either of these drifts, the corpus is invalid. + for _, tc := range []struct { + weight float64 + acts float64 + hp float64 + }{ + {1, 1, 1.0}, // solo + {2, 2.4, 1.15}, // a duo of peers + {3, 5, 1.15}, // a trio of peers + } { + if got := partyActionExpectation(tc.weight); got != tc.acts { + t.Errorf("weight %v: enemy actions = %v, want exactly %v", tc.weight, got, tc.acts) + } + if got := partyEnemyHPScale(tc.weight); got != tc.hp { + t.Errorf("weight %v: enemy HP scale = %v, want exactly %v", tc.weight, got, tc.hp) + } + } + + // A leader plus a hireling is strictly cheaper than a leader plus a peer, and + // strictly dearer than soloing. Bringing him must cost the boss *something* — + // that is what stops a free body from becoming a carry. + duoWithHire := 1 + seatWeight(9, 10, true) + if a := partyActionExpectation(duoWithHire); a <= 1 || a >= 2.4 { + t.Errorf("leader + hireling buys the enemy %v actions, want strictly between 1 and 2.4", a) + } + if h := partyEnemyHPScale(duoWithHire); h <= 1.0 || h >= 1.15 { + t.Errorf("leader + hireling scales boss HP by %v, want strictly between 1.0 and 1.15", h) + } +} + +// A seat that is DOWN buys the enemy nothing. §2(b) fixed the head-count half of +// this; the weight half has to hold too, or a corpse keeps paying for swings. +func TestSeatWeight_TheDeadBuyTheEnemyNothing(t *testing.T) { + alive := &Combatant{SeatWeight: 1} + hire := &Combatant{SeatWeight: 0.6} + st := &combatState{actors: []*actor{ + {c: alive, playerHP: 40}, + {c: hire, playerHP: 0}, // dropped + }} + if got := livingWeight(st); got != 1 { + t.Errorf("living weight with a downed hireling = %v, want 1 (only the survivor pays)", got) + } + st.actors[1].playerHP = 20 + if got := livingWeight(st); got != 1.6 { + t.Errorf("living weight with the hireling up = %v, want 1.6", got) + } +} + +// An unset weight reads as a full peer, so every combatant built before this +// existed — and every test that builds one by hand — is priced exactly as before. +func TestSeatWeight_UnsetIsAPeer(t *testing.T) { + if got := combatantWeight(&Combatant{}); got != 1 { + t.Errorf("a combatant with no weight set counts %v, want 1", got) + } +} diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 827ab29..f962b3d 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -121,7 +121,7 @@ type ActorStatuses struct { // fight, set when its turn deadline lapses once (see partyTurnDeadline). // Without the latch an away member taxes the party the full deadline every // single round; with it, they cost one wait and then resolve instantly. Any - // combat command from that member clears it. + // combat command from that member clears it — they typed, so they are back. // // Like the Buff* deltas it is session-layer state with no combatState // counterpart, so snapshotActor carries it over from the prior snapshot @@ -129,6 +129,24 @@ type ActorStatuses struct { // fight has no turn deadline, only the 1h session reaper. Autopilot bool `json:"autopilot,omitempty"` + // EngineDriven marks a seat that has NO human behind it and never will: a + // hired companion today, an NPC ally or a Pete-led expedition tomorrow. It is + // the answer to "who owns this turn", and it is deliberately NOT the same + // question as Autopilot. + // + // Autopilot means "a human is away"; it is provisional, and a keystroke ends + // it. EngineDriven means "there is nobody to come back", and nothing clears it. + // Collapsing the two is a bug with teeth: the expedition autopilot drives a + // party by dispatching each seat's turn as a command from that seat, so an + // engine seat's own auto-played move arrived back at beginCombatTurn looking + // exactly like a player returning to the keyboard, and cleared the very latch + // that was moving it. The seat then stood in the fight doing nothing for the + // rest of the fight — while the enemy it had inflated by 15% killed the party. + // + // So the property lives on the seat, not on an identity check, and no command + // path can unset it. + EngineDriven bool `json:"engine_driven,omitempty"` + // Debuffs the enemy has stacked onto this character specifically. PlayerAtkDrain int `json:"player_atk_drain,omitempty"` PlayerACDebuff int `json:"player_ac_debuff,omitempty"` @@ -256,7 +274,8 @@ func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool { st.ArcaneWardHP = playerMods.ArcaneWardHP st.HealChargesLeft = playerMods.HealItemCharges st.ArmedAbility = seat.ArmedAbility - return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 || + st.EngineDriven = seat.EngineDriven + return st.EngineDriven || st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 || st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 || st.ArmedAbility != "" } @@ -387,16 +406,30 @@ func (s *CombatSession) seatOf(userID id.UserID) (int, bool) { // turn silently rather than blocking the round on a corpse. func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 } -// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker -// by a lapsed turn deadline. +// seatIsEngineDriven reports whether a seat has no human behind it at all — a +// hired companion, an NPC ally. Permanent for the life of the fight; no command +// clears it. Contrast seatIsAutopiloted, which is a human who stepped away. +func (s *CombatSession) seatIsEngineDriven(seat int) bool { + return s.actorStatusesForSeat(seat).EngineDriven +} + +// seatIsAutopiloted reports whether the engine, rather than a person, decides +// this seat's action — because its human is away (a lapsed turn deadline) or +// because it never had one. +// +// Every driver in the codebase reads this, which is exactly why the two reasons +// share one accessor: the picker does not care WHY nobody is typing. What differs +// is who is allowed to take the wheel back, and that question is asked in +// beginCombatTurn against seatIsEngineDriven — never here. func (s *CombatSession) seatIsAutopiloted(seat int) bool { - return s.actorStatusesForSeat(seat).Autopilot + st := s.actorStatusesForSeat(seat) + return st.Autopilot || st.EngineDriven } // seatNeedsNoHuman reports whether the engine can resolve a seat's turn without -// waiting on its player: it is down (forfeits silently) or latched onto -// autopilot. driveCombatRound keeps stepping while this holds, so a round only -// comes to rest on a live human's turn. +// waiting on its player: it is down (forfeits silently), latched onto autopilot, +// or has no human to wait for. driveCombatRound keeps stepping while this holds, +// so a round only comes to rest on a live human's turn. func (s *CombatSession) seatNeedsNoHuman(seat int) bool { return !s.seatAlive(seat) || s.seatIsAutopiloted(seat) } @@ -491,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession( // Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild // in partyCombatantsForSession applies the identical scalar to the enemy's // Stats.MaxHP, so the persisted current HP and the rebuilt max never drift. - enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats)) + enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats)) owner := seats[0] sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID, owner.HP, owner.HPMax, enemyHP, enemyHP) @@ -553,6 +586,12 @@ type CombatSeatSetup struct { // if they armed nothing. It is persisted onto the seat's statuses so every // later rebuild can re-apply the ability without re-spending it. ArmedAbility string + // EngineDriven seats this combatant with no human behind it — the hired + // companion today. It resolves from the opening round rather than after the + // 3-minute away-player deadline (nobody is coming, so waiting out a deadline + // would idle the fight and then announce him to the party as absent), and no + // command can hand the wheel back to a player who does not exist. + EngineDriven bool } // getActiveCombatSession returns the player's in-flight fight, or (nil, nil). diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index d23d3e8..70003a3 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -154,13 +154,36 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com seats := sess.SeatUserIDs() players := make([]*Combatant, len(seats)) + levels := make([]int, len(seats)) + companions := make([]bool, len(seats)) var enemy Combatant for seat, uid := range seats { // The ability this seat armed was consumed once, at fight start, and its // id parked on their statuses. Re-applying it here — not re-consuming — // is what makes a rage last the whole fight instead of one round. st := sess.actorStatusesForSeat(seat) - player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility) + + // The hired companion has no sheet to load — by design, because a + // player_meta row for him is the thing that would turn him into a real + // character everywhere. Synthesize his seat instead. This branch is + // load-bearing: buildZoneCombatants would fail on him, and one + // unbuildable seat fails the whole rebuild, which is what every human's + // !attack in the fight depends on. + if isCompanionUser(uid) { + class, level := companionLoadoutForRun(sess.RunID) + player, en, _ := p.companionCombatant(class, level, monster, int(zone.Tier), run.DMMood) + applySessionBuffs(&player, st) + players[seat] = &player + levels[seat], companions[seat] = level, true + if seat == 0 { + // Unreachable today (seat 0 is always the leader), but if it ever + // isn't, the enemy still has to come from somewhere. + enemy = en + } + continue + } + + player, e, dc, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility) if err != nil { return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err) } @@ -171,22 +194,47 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com // stat deltas are applied here — and only that seat's own. applySessionBuffs(&player, st) players[seat] = &player + if dc != nil { + levels[seat] = dc.Level + } if seat == 0 { // The enemy build reads only (monster, tier, dmMood): every seat // rebuilds the identical stat block, so seat 0's copy is the fight's. // Only the *player* half of the build varies by seat. enemy = e - // Party-only enemy HP bump, re-derived each turn from the template so - // it never compounds. Matches the scalar startPartyCombatSession used - // for the initial persist; solo (roster 1) scales by 1.0. - if sess.IsParty() { - enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize()) - } } } + + // What each seat costs the enemy, priced against the leader. This runs after + // the loop and not inside it, because a seat's weight is relative to seat 0's + // level and the enemy's HP is scaled off the *summed* weight — neither is known + // until every seat is built. + applySeatWeights(players, levels, companions) + + // Party-only enemy HP bump, re-derived each turn from the template so it never + // compounds. Matches the scalar startPartyCombatSession used for the initial + // persist; solo (one seat, weight 1) scales by 1.0. + if sess.IsParty() { + enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeight(players)) + } return players, &enemy, nil } +// applySeatWeights prices every seat against the leader's level. Seat 0 is the +// leader and always weighs a full 1.0. +func applySeatWeights(players []*Combatant, levels []int, companions []bool) { + if len(players) == 0 { + return + } + leaderLevel := levels[0] + for i, c := range players { + if c == nil { + continue + } + c.SeatWeight = seatWeight(levels[i], leaderLevel, companions[i]) + } +} + // seatFightStartMods re-derives the modifiers a finished fight's close-out still // needs: the Berserker's rage flag, which decides whether the character owes a // point of exhaustion, and the Necromancy Mage's Grim Harvest stash. diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index e5ade57..6071e1f 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -65,6 +65,19 @@ type turnActionEffect struct { EnemyDamage int PlayerHeal int EnemySkip bool // control spell: enemy forfeits its attack this round + + // AllyHeal heals ANOTHER seat instead of the caster — the thing the engine + // could not do until §1, and the reason a party cleric was a cleric in name + // only. Every heal in the engine wrote to the acting seat: MistyHealProc, + // HealItem, PlayerHeal above. Nothing could put a hit point on a friend, so + // the class whose entire identity is keeping other people alive could not. + // N3 shipped that way and no test noticed, because there was no party golden. + // + // Zero means no ally heal, which keeps every existing construction of this + // struct meaning exactly what it meant before. AllySeat is only read when + // AllyHeal > 0, so its zero value is never mistaken for "seat 0". + AllyHeal int + AllySeat int // ConcentrationDmg arms a per-round aura tick when a concentration damage // spell is cast: EnemyDamage is the burst that lands this round, this is // what re-ticks at every round_end after. Zero for one-shot spells; a @@ -560,6 +573,25 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { hpCap := max(1, st.hpMax-st.maxHPDrain) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) } + // §1 — heal somebody else. The caster's cursor stays where it is; only the + // target's HP moves. + // + // A downed seat is NOT raised. Death in this engine is terminal for the fight + // (the close-out marks them, the hospital takes them), and a heal that + // resurrected a corpse would quietly rewrite the loss rules every close-out + // path depends on. Healing keeps people up; it does not bring them back. + if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) { + if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 { + cap := max(1, tgt.hpMax-tgt.maxHPDrain) + before := tgt.playerHP + tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal", + Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP, + Seat: eff.AllySeat, Desc: eff.Label, + }) + } + } // Arm / replace the concentration aura. A new concentration cast overwrites // the old one (5e: one concentration at a time); non-concentration casts // leave any running aura alone. diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index 7dd4547..046507c 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -1,7 +1,9 @@ package plugin import ( + "database/sql" "encoding/json" + "errors" "fmt" "log/slog" "math/rand/v2" @@ -27,9 +29,11 @@ import ( // - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with // a "Phase 11" note — they require the boss turn-based engine. // -// Cross-player targeting (--target @user) is deliberately deferred. SP2 -// supports self-target only. Heals on self work; ally buffs queue with the -// caster as the target until SP3 wires up the multi-player resolution. +// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human +// on your expedition — see dnd_cast_target.go. Only heals may name somebody else +// out of combat: UTILITY resolves on the caster, and everything else queues as a +// PendingCast for the *caster's* next fight, where an ally target has nothing to +// mean. In-combat targeting is splitCastTarget (combat_cmd.go). // PendingCast is the shape stored in dnd_character.pending_cast (JSON blob). // Keep this minimal — the combat layer resolves the actual numbers on fire. @@ -97,6 +101,13 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro return p.dndCastDrop(ctx, c) } + // An ally target — `--target @alex`, or a trailing `@alex` — comes off the + // string before the spell parser sees it, exactly as in combat. It is + // resolved (and refused) further down, once we know the spell is a heal: a + // target on anything else is a mistake, and refusing it early would cost us + // the "which spell?" half of the error message. + args, targetName := splitOutOfCombatTarget(args) + // Parse spell name + optional --upcast N. tokens := strings.Fields(args) upcast := 0 @@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro } i++ } - case "--target": - // Reserved for SP3 — accept and ignore for now. - if i+1 < len(tokens) { - i++ - } default: spellTokens = append(spellTokens, tokens[i]) } @@ -195,6 +201,23 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro } } + // Resolve an ally target now — BEFORE anything is spent. Out of combat only a + // heal can land on somebody else: UTILITY resolves on the caster and + // everything else queues as a PendingCast for *this* caster's next fight, so + // a target on those would be accepted and then silently dropped. + var targetUser id.UserID + if targetName != "" { + if spell.Effect != EffectSpellHeal { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name)) + } + uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName) + if errMsg != "" { + return p.SendDM(ctx.Sender, errMsg) + } + targetUser = uid // empty when they named themselves — self-heal as usual + } + // Material cost (Revivify, Raise Dead). if spell.MaterialCost > 0 { if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") { @@ -220,6 +243,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro // debited above; if SaveDnDCharacter fails we refund. switch spell.Effect { case EffectSpellHeal: + if targetUser != "" { + return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel) + } return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel) case EffectUtility: return p.resolveUtility(ctx, c, spell, slotLevel) @@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro // ── Out-of-combat: HEAL ────────────────────────────────────────────────────── -func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error { +// rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a +// property of the caster (their WIS, their domain), never of the body it lands +// on, so an ally heal rolls exactly the same as a self-heal. +func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int { dice, faces, _ := parseDamageDice(spell.DamageDice) if dice == 0 { dice, faces = 1, 8 // safety fallback @@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara } heal += abilityModifier(c.WIS) heal += lifeDomainHealBonus(c, spell, slotLevel) + return heal +} + +func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error { + heal := rollOutOfCombatHeal(c, spell, slotLevel) before := c.HPCurrent c.HPCurrent = min(c.HPMax, c.HPCurrent+heal) @@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara renderSlotsBrief(ctx.Sender))) } +// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is +// already spent, so every failure below refunds it: a heal that lands on nobody +// must not cost the caster anything. +func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error { + refund := func(msg string) error { + if spell.Level > 0 { + _ = refundSpellSlot(ctx.Sender, slotLevel) + } + return p.SendDM(ctx.Sender, msg) + } + + heal := rollOutOfCombatHeal(c, spell, slotLevel) + before, after, maxHP, err := healPartyMember(target, heal) + + targetName := charName(target) + if targetName == "" { + targetName = target.Localpart() + } + casterName := charName(ctx.Sender) + if casterName == "" { + casterName = ctx.Sender.Localpart() + } + + switch { + case errors.Is(err, errHealTargetFull): + return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP)) + case errors.Is(err, errHealTargetDown): + // The same rule the combat path holds: a heal is not a resurrection. + return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName)) + case errors.Is(err, sql.ErrNoRows): + return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName)) + case err != nil: + return refund("Couldn't apply the heal: " + err.Error()) + } + + _ = p.SendDM(target, fmt.Sprintf( + "🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).", + casterName, spell.Name, after-before, before, after, maxHP)) + + return p.SendDM(ctx.Sender, fmt.Sprintf( + "🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s", + spell.Name, targetName, after-before, before, after, maxHP, + renderSlotsBrief(ctx.Sender))) +} + // ── Out-of-combat: UTILITY ────────────────────────────────────────────────── func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error { @@ -676,6 +755,7 @@ func renderCastHelp(c *DnDCharacter) string { var b strings.Builder b.WriteString("**Cast a spell**\n\n") b.WriteString("Usage: `!cast [--upcast N]`\n") + b.WriteString(" `!cast @friend` to heal someone on your expedition.\n") b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n") b.WriteString("Run `!spells` for your full list.\n\n") if pc, ok := decodePendingCast(c.PendingCast); ok { diff --git a/internal/plugin/dnd_cast_ally_e2e_test.go b/internal/plugin/dnd_cast_ally_e2e_test.go new file mode 100644 index 0000000..3681832 --- /dev/null +++ b/internal/plugin/dnd_cast_ally_e2e_test.go @@ -0,0 +1,139 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +// The whole `!cast cure wounds @friend` path, out of combat, through the real +// handler: parse → class/known/prepared gates → slot debit → the ally's sheet. +// +// The seams are unit-tested next door; this exists for the one thing they cannot +// see — that the slot ledger and the HP write agree about whether the heal +// happened. A refund that does not fire is a slot a player paid for nothing, and +// a refund that fires twice is a free heal. + +// castingCleric turns an existing sheet into a cleric who knows and has prepared +// Cure Wounds, with slots to spend. +func castingCleric(t *testing.T, uid id.UserID) { + t.Helper() + c, err := LoadDnDCharacter(uid) + if err != nil || c == nil { + t.Fatalf("load %s: %v", uid, err) + } + c.Class = ClassCleric + c.Level = 5 + c.WIS = 16 + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if err := setSpellSlotsForCharacter(c); err != nil { + t.Fatal(err) + } + if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil { + t.Fatal(err) + } + if err := setSpellPrepared(uid, "cure_wounds", true); err != nil { + t.Fatal(err) + } +} + +func slotsUsed(t *testing.T, uid id.UserID, level int) int { + t.Helper() + slots, err := getSpellSlots(uid) + if err != nil { + t.Fatal(err) + } + return slots[level][1] +} + +func setCharHP(t *testing.T, uid id.UserID, hp int) { + t.Helper() + c, err := LoadDnDCharacter(uid) + if err != nil || c == nil { + t.Fatalf("load %s: %v", uid, err) + } + c.HPCurrent = hp + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } +} + +func TestCastAlly_OutOfCombat_HealsTheFriendAndSpendsOneSlot(t *testing.T) { + setupEmptyTestDB(t) + leader, member := seatedMember(t, "e2eheal") + castingCleric(t, leader) + setCharHP(t, member, 5) // of 20 + + p := &AdventurePlugin{} + if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil { + t.Fatalf("cast: %v", err) + } + + mc, _ := LoadDnDCharacter(member) + if mc.HPCurrent <= 5 { + t.Fatalf("the friend is still on %d HP; the heal landed on nobody", mc.HPCurrent) + } + // The caster must NOT have healed themselves — the bug this whole section exists for. + if lc, _ := LoadDnDCharacter(leader); lc.HPCurrent != lc.HPMax { + t.Fatalf("caster HP moved to %d/%d; the heal landed on the caster", lc.HPCurrent, lc.HPMax) + } + if used := slotsUsed(t, leader, 1); used != 1 { + t.Fatalf("level-1 slots used = %d, want exactly 1", used) + } +} + +// Naming a full-HP ally must cost nothing: the slot is debited before the target +// is touched, so this is the refund path firing for real. +func TestCastAlly_OutOfCombat_FullHPAllyRefundsTheSlot(t *testing.T) { + setupEmptyTestDB(t) + leader, member := seatedMember(t, "e2efull") + castingCleric(t, leader) + + p := &AdventurePlugin{} + if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil { + t.Fatalf("cast: %v", err) + } + if used := slotsUsed(t, leader, 1); used != 0 { + t.Fatalf("level-1 slots used = %d after healing a full-HP ally; the slot was not refunded", used) + } +} + +// A target nobody can reach is refused before anything is spent. +func TestCastAlly_OutOfCombat_StrangerCostsNothing(t *testing.T) { + setupEmptyTestDB(t) + leader, _ := seatedMember(t, "e2estranger") + castingCleric(t, leader) + + p := &AdventurePlugin{} + if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @nobody"); err != nil { + t.Fatalf("cast: %v", err) + } + if used := slotsUsed(t, leader, 1); used != 0 { + t.Fatalf("level-1 slots used = %d after naming a stranger; nothing should have been spent", used) + } +} + +// A target on a spell that cannot use one is refused, rather than quietly +// dropping the target and firing the spell at the caster — which is what the old +// "accept and ignore" parse did. +func TestCastAlly_OutOfCombat_TargetOnANonHealIsRefused(t *testing.T) { + setupEmptyTestDB(t) + leader, member := seatedMember(t, "e2enonheal") + castingCleric(t, leader) + if err := addKnownSpell(leader, "guiding_bolt", "class", true); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "guiding bolt @"+member.Localpart()); err != nil { + t.Fatalf("cast: %v", err) + } + if used := slotsUsed(t, leader, 1); used != 0 { + t.Fatalf("level-1 slots used = %d; a targeted non-heal must be refused before it is paid for", used) + } + if lc, _ := LoadDnDCharacter(leader); lc.PendingCast != "" { + t.Fatalf("guiding bolt queued as %q; the target should have refused the cast outright", lc.PendingCast) + } +} diff --git a/internal/plugin/dnd_cast_target.go b/internal/plugin/dnd_cast_target.go new file mode 100644 index 0000000..a65d346 --- /dev/null +++ b/internal/plugin/dnd_cast_target.go @@ -0,0 +1,156 @@ +package plugin + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// Out-of-combat ally targeting for `!cast` — the other half of §1. +// +// splitCastTarget (combat_cmd.go) resolves a target against the people *in the +// fight*. Out of combat there is no fight, so the equivalent set is the people +// on your expedition: the party is who you are standing next to. Healing a +// stranger across the world is not a thing a cleric at camp can do, and scoping +// it to the party keeps the two `!cast` paths answering the same question. +// +// Only heals may name somebody else. Everything else `!cast` can do out of +// combat either resolves on the caster (UTILITY) or queues as a PendingCast for +// **the caster's** next fight — an ally target on those would be silently +// dropped, which is how `--target` came to be swallowed in the first place. + +// splitOutOfCombatTarget peels an ally target off a `!cast` argument, using the +// same two spellings the combat path accepts: the `--target @alex` flag, and a +// plain trailing `@alex`. The bare form requires the `@` here (unlike in combat, +// where the roster disambiguates) so a trailing spell word is never mistaken for +// a name. +// +// Returns (remainingArgs, name). name is empty when no target was named. +func splitOutOfCombatTarget(args string) (string, string) { + fields := strings.Fields(args) + for i := 0; i < len(fields); i++ { + if !strings.EqualFold(fields[i], "--target") { + continue + } + if i+1 >= len(fields) { + return args, "" + } + name := strings.TrimPrefix(fields[i+1], "@") + fields = append(fields[:i], fields[i+2:]...) + return strings.Join(fields, " "), name + } + if len(fields) == 0 { + return args, "" + } + if last := fields[len(fields)-1]; strings.HasPrefix(last, "@") { + if name := strings.TrimPrefix(last, "@"); name != "" { + return strings.Join(fields[:len(fields)-1], " "), name + } + } + return args, "" +} + +// resolveCastTargetOnExpedition maps a named target to a human on the caster's +// active expedition. errMsg is non-empty and player-facing on any failure; a +// caster who names somebody must never fall through to healing themselves, +// because that quietly burns the slot on the wrong body. +func resolveCastTargetOnExpedition(caster id.UserID, name string) (id.UserID, string) { + exp, _, err := activeExpeditionFor(caster) + if err != nil { + return "", "Couldn't look up your expedition." + } + if exp == nil { + return "", "You're not travelling with anyone. `!cast` on yourself, or join a party first." + } + seats, err := partyHumans(exp.ID, exp.UserID) + if err != nil { + return "", "Couldn't look up your party." + } + for _, s := range seats { + uid := string(s.UserID) + if strings.EqualFold(uid, name) || strings.EqualFold(id.UserID(uid).Localpart(), name) { + if s.UserID == caster { + // Naming yourself is just casting it on yourself. + return "", "" + } + return s.UserID, "" + } + // Character name, which is what players actually call each other. + if n := charName(s.UserID); n != "" && strings.EqualFold(n, name) { + if s.UserID == caster { + return "", "" + } + return s.UserID, "" + } + } + if strings.EqualFold(name, companionDisplayName) { + return "", companionDisplayName + " patches himself up at camp. Save the slot." + } + return "", fmt.Sprintf("**%s** isn't on your expedition. You can only heal someone you're travelling with.", name) +} + +// errHealTargetFull / errHealTargetDown are the two ways an ally heal declines +// to spend the slot. Both are recoverable: the caller refunds. +var ( + errHealTargetFull = errors.New("target already at full HP") + errHealTargetDown = errors.New("target is down") +) + +// healPartyMember applies a heal to somebody else's sheet. +// +// It does NOT take the target's advUserLock. Gifting sets the precedent +// (adventure_gifting.go): mutate the other player's row with one guarded +// statement rather than a read-modify-write under two locks. Two clerics healing +// each other at the same instant would otherwise take those locks in opposite +// orders and deadlock the pair of them. +// +// The clamp lives in SQL for the same reason — read, add, cap and write are one +// statement, so a heal landing between a concurrent heal's read and write cannot +// push anybody past their maximum. +func healPartyMember(target id.UserID, amount int) (before, after, maxHP int, err error) { + tx, err := db.Get().Begin() + if err != nil { + return 0, 0, 0, err + } + defer func() { _ = tx.Rollback() }() + + err = tx.QueryRow( + `SELECT hp_current, hp_max FROM dnd_character WHERE user_id = ?`, + string(target)).Scan(&before, &maxHP) + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, 0, sql.ErrNoRows + } + if err != nil { + return 0, 0, 0, err + } + switch { + case before <= 0: + // A heal is not a resurrection — the same rule the combat path holds. + return before, before, maxHP, errHealTargetDown + case before >= maxHP: + return before, before, maxHP, errHealTargetFull + } + + if _, err = tx.Exec( + `UPDATE dnd_character + SET hp_current = MIN(hp_max, hp_current + ?), + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND hp_current > 0`, + amount, string(target)); err != nil { + return before, before, maxHP, err + } + if err = tx.QueryRow( + `SELECT hp_current FROM dnd_character WHERE user_id = ?`, + string(target)).Scan(&after); err != nil { + return before, before, maxHP, err + } + if err = tx.Commit(); err != nil { + return before, before, maxHP, err + } + return before, after, maxHP, nil +} diff --git a/internal/plugin/dnd_cast_target_test.go b/internal/plugin/dnd_cast_target_test.go new file mode 100644 index 0000000..c58cfdd --- /dev/null +++ b/internal/plugin/dnd_cast_target_test.go @@ -0,0 +1,102 @@ +package plugin + +import ( + "errors" + "testing" + + "maunium.net/go/mautrix/id" +) + +// The out-of-combat half of §1. `--target` was parsed and thrown away here since +// SP2 ("reserved for SP3, accept and ignore"), so a party cleric standing over a +// bleeding friend between fights could do precisely nothing for them. + +func TestSplitOutOfCombatTarget(t *testing.T) { + cases := []struct { + name, args, wantRest, wantTarget string + }{ + {"flag", "cure wounds --target @alex", "cure wounds", "alex"}, + {"flag mid-string", "cure wounds --target @alex --upcast 3", "cure wounds --upcast 3", "alex"}, + {"flag without the @", "cure wounds --target alex", "cure wounds", "alex"}, + {"trailing mention", "cure wounds @alex", "cure wounds", "alex"}, + {"no target", "cure wounds", "cure wounds", ""}, + // A trailing bare word is a spell word, not a name — the `@` is what makes + // it a target out of combat. Getting this wrong eats half of "cure wounds". + {"bare trailing word is not a target", "healing word", "healing word", ""}, + {"upcast digit is not a target", "cure wounds --upcast 3", "cure wounds --upcast 3", ""}, + {"dangling flag", "cure wounds --target", "cure wounds --target", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rest, target := splitOutOfCombatTarget(tc.args) + if rest != tc.wantRest || target != tc.wantTarget { + t.Fatalf("splitOutOfCombatTarget(%q) = (%q, %q), want (%q, %q)", + tc.args, rest, target, tc.wantRest, tc.wantTarget) + } + }) + } +} + +// The target set is the expedition, not the world: you can heal the person you +// are travelling with, and nobody else. +func TestResolveCastTarget_OnlyThePartyIsReachable(t *testing.T) { + setupEmptyTestDB(t) + leader, member := seatedMember(t, "casttarget") + + uid, errMsg := resolveCastTargetOnExpedition(leader, member.Localpart()) + if errMsg != "" || uid != member { + t.Fatalf("leader → member = (%q, %q); want the member, no error", uid, errMsg) + } + + // Somebody with a sheet, but not on this expedition. + stranger := id.UserID("@stranger-casttarget:example.org") + zoneCmdTestCharacter(t, stranger, 1) + if _, errMsg := resolveCastTargetOnExpedition(leader, stranger.Localpart()); errMsg == "" { + t.Fatal("healed a stranger across the world; only the party is reachable") + } + + // Naming yourself is just casting it on yourself: no target, no error. A + // refusal here would make `!cast cure wounds @me` cost a slot for nothing. + if uid, errMsg := resolveCastTargetOnExpedition(leader, leader.Localpart()); uid != "" || errMsg != "" { + t.Fatalf("self-target = (%q, %q); want the self-heal path, silently", uid, errMsg) + } +} + +func TestHealPartyMember_ClampsAtMaxAndWillNotRaiseTheDead(t *testing.T) { + setupEmptyTestDB(t) + target := id.UserID("@heal-target:example.org") + zoneCmdTestCharacter(t, target, 1) // HPMax 20 + + setHP := func(hp int) { + c, err := LoadDnDCharacter(target) + if err != nil || c == nil { + t.Fatalf("load: %v", err) + } + c.HPCurrent = hp + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + } + + // A heal bigger than the wound tops out at max rather than overflowing. + setHP(15) + before, after, maxHP, err := healPartyMember(target, 100) + if err != nil || before != 15 || after != 20 || maxHP != 20 { + t.Fatalf("overheal = (%d → %d / %d, %v); want clamped to 20", before, after, maxHP, err) + } + + // Already full: decline so the caller can refund. Spending a slot to heal + // zero HP is the kind of thing players never forgive. + if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetFull) { + t.Fatalf("healing a full-HP ally = %v; want errHealTargetFull", err) + } + + // Down: a heal is not a resurrection. Same rule the combat path holds. + setHP(0) + if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetDown) { + t.Fatalf("healing a downed ally = %v; want errHealTargetDown", err) + } + if c, _ := LoadDnDCharacter(target); c == nil || c.HPCurrent != 0 { + t.Fatal("the downed ally was raised by a heal") + } +} diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 2429236..e4a300f 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -605,3 +605,51 @@ func emitDeathNews(userID id.UserID, location string) { OccurredAt: ts, }, userID, "") } + +// emitRetreatNews files a BULLETIN when an expedition ends with the player +// walking out alive. +// +// Until this existed the news had no way to say "it went badly but nobody +// died", so it never said it. Pete's whole taxonomy was arrival, companion_hire, +// death, milestone, rival_result and zone_first — every one of them a win, a +// death, or an introduction. A run that simply fell apart emitted nothing, and +// the feed showed a realm where adventurers only ever triumph or die. +// +// That was not a rare gap. Before §6, a cleric retreated on 167 of 500 +// simulated expeditions: a third of that class's runs ended in a way the news +// was structurally incapable of reporting. Casters did not look unlucky in the +// feed — they looked absent. +// +// Bulletin, not priority: a retreat is a bad day, not a funeral. A death already +// files its own priority dispatch from markAdventureDead, and a wipe that killed +// someone must not ALSO be reported as a retreat — hence the reason gate rather +// than an "expedition ended" catch-all. An idle reap is excluded too: a player +// who closed their laptop did not flee anything, and Pete announcing that they +// were driven from the field would be a lie about a person by name. +func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) { + switch reason { + case lossCombatRetreat, lossCombatFlee: + default: + return // a death tells its own story; an idle reap is not a story + } + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + name := charName(userID) + if name == "" { + return + } + zone := zoneOrFallback(zoneID) + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("retreat:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts), + EventType: "retreat", + Tier: "bulletin", + Subject: name, + Zone: zone.Display, + Level: charLevel(userID), + Count: day, // the day they got to before it fell apart + Outcome: "retreated", + OccurredAt: ts, + }, userID, "") +} diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 47b38e3..3d216ce 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -325,6 +325,13 @@ func applyCampRest(e *Expedition, kind string) string { if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase { _ = refreshAllResources(uid) _ = refreshSpellSlots(uid) + // The companion sleeps at the same fire. His slots and his wounds are not + // dnd_spell_slots / dnd_character rows — he has none — so nothing above + // reaches him, and without these two his pool and his body would only ever + // go down. Camp is where a caster gets their slots back and a body gets + // patched up, and he is at the camp. + _ = refreshCompanionSlots(e.ID) + _ = refreshCompanionHP(e.ID) _ = ReplenishHarvestNodes(e) } diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index ee68271..996c601 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -95,6 +95,10 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string return p.expeditionCmdParty(ctx) case "leave": return p.expeditionCmdLeave(ctx) + case "hire": + return p.expeditionCmdHire(ctx, rest) + case "dismiss": + return p.expeditionCmdDismiss(ctx) case "extract": return p.handleExtractCmd(ctx, "") case "resume": @@ -122,7 +126,9 @@ func expeditionHelpText() string { b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n") b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n") b.WriteString("`!expedition party` — who's with you\n") - b.WriteString("`!expedition leave` — turn back for town (members only)\n\n") + b.WriteString("`!expedition leave` — turn back for town (members only)\n") + b.WriteString("`!expedition hire [class]` — pay Pete to fill the role you're missing\n") + b.WriteString("`!expedition dismiss` — send Pete home\n\n") b.WriteString("**Mid-expedition:**\n") b.WriteString("`!extract` — bail safely (resumable for 7 days)\n") b.WriteString("`!resume [loadout]` — resume an extracted expedition\n") diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 051cab8..55a101e 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) { return e, tax, nil } +// The reasons a run can end badly. They were bare strings at four call sites; +// they are constants now because the news seam has to tell them apart — a +// retreat is a story and a death is a different story, and an idle reap is +// neither. +const ( + lossCombatDeath = "combat death" + lossCombatRetreat = "combat retreat" // solo: ran out the phase clock and withdrew + lossCombatFlee = "combat flee" // party: the turn engine broke off + lossIdleTimeout = "run idle-timeout (§4.3 stale-run reap)" +) + // forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based // elite/boss death or flee, exploration combat death, patrol-interrupt // death) into the forced-extract flow. Those sites already abandon the // zone run, but without flipping the wrapping expedition the ambient // ticker keeps DMing about a dungeon the player walked away from. No-op // when there is no active expedition for this user. +// +// It is also the one chokepoint every bad ending passes through, which makes it +// where the retreat dispatch is filed. Read the expedition BEFORE the extract: +// forcedExtractExpedition stamps it 'abandoned' and zeroes the live fields, so +// afterwards there is no day count left to report. func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) { exp, err := getActiveExpedition(userID) if err != nil || exp == nil { return } + day, zoneID := exp.CurrentDay, exp.ZoneID if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil { slog.Warn("expedition: force-extract on run loss", "user", userID, "expedition", exp.ID, "reason", reason, "err", err) + return } + emitRetreatNews(userID, reason, zoneID, day) } // finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down, diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index bc712d9..9f84e8f 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -1207,9 +1207,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z // seat off HP, which for a solo walker is the same `!TimedOut` rule. closeOutZoneLoss(pres, seated, zone, "zone") if !result.TimedOut { - forceExtractExpeditionForRunLoss(userID, "combat death") + forceExtractExpeditionForRunLoss(userID, lossCombatDeath) } else { - forceExtractExpeditionForRunLoss(userID, "combat retreat") + forceExtractExpeditionForRunLoss(userID, lossCombatRetreat) } if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 8dd846a..4b08ba8 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { // but only when this run is the active expedition's current run so // a standalone (non-expedition) stale run still reaps cleanly. if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID { - forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)") + forceExtractExpeditionForRunLoss(userID, lossIdleTimeout) } return nil, nil } diff --git a/internal/plugin/expedition_companion_cmd.go b/internal/plugin/expedition_companion_cmd.go new file mode 100644 index 0000000..bb8f7bd --- /dev/null +++ b/internal/plugin/expedition_companion_cmd.go @@ -0,0 +1,115 @@ +package plugin + +import ( + "errors" + "fmt" + "strings" +) + +// The player-facing surface for hiring Pete. +// +// !expedition hire [class] (leader, Day 1 — auto-fills the missing role) +// !expedition dismiss (leader — sends him home, no refund) +// +// He is hired on Day 1 like any other companion, because a body that materializes +// three rooms in is not a party member, it is a cheat code. See +// adventure_companion.go for why he is an NPC seat and not a player. + +// expeditionCmdHire brings the correspondent along. +func (p *AdventurePlugin) expeditionCmdHire(ctx MessageContext, rest string) error { + exp, isLeader, err := activeExpeditionFor(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + } + if exp == nil { + return p.SendDM(ctx.Sender, "No active expedition. `!expedition start ` first.") + } + if !isLeader { + return p.SendDM(ctx.Sender, "Only the party leader hires. You're along for the ride.") + } + if !inviteWindowOpen(exp) { + return p.SendDM(ctx.Sender, + "This expedition has already set off — Pete joins on Day 1 or not at all.") + } + if companionSeated(exp.ID) { + return p.SendDM(ctx.Sender, "Pete's already with you. `!expedition party` to see the roster.") + } + if p.euro == nil { + return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.") + } + + zone := zoneOrFallback(exp.ZoneID) + level := companionPartyLevel(exp.ID) + + class, explicit := parseCompanionClass(rest) + if !explicit { + if arg := strings.TrimSpace(rest); arg != "" && !strings.EqualFold(arg, "auto") { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Don't know the class %q. Name one, or just `!expedition hire` and he'll fill whatever you're missing.", arg)) + } + class = companionRoleFill(companionPartyClasses(exp.ID)) + } + + cost := float64(companionHireCost(level, zone.Tier)) + if balance := p.euro.GetBalance(ctx.Sender); balance < cost { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Pete doesn't work for free. His fee for **%s** is **%d** — you have **%.0f**.", + zone.Display, int(cost), balance)) + } + if !p.euro.Debit(ctx.Sender, cost, "expedition: hired Pete") { + return p.SendDM(ctx.Sender, "Couldn't debit Pete's fee (try again).") + } + + if err := hireCompanion(exp.ID, class, level); err != nil { + p.euro.Credit(ctx.Sender, cost, "expedition: Pete hire refund") + return p.SendDM(ctx.Sender, companionRefusalText(err)) + } + + emitCompanionHireFact(ctx.Sender, class, level, zone) + + ci, _ := classInfo(class) + filled := "You asked for a " + ci.Display + "." + if !explicit { + filled = "He's filling the hole in your roster — **" + ci.Display + "**." + } + return p.SendDM(ctx.Sender, fmt.Sprintf( + "📝 **Pete's coming with you into %s.** %s He's **level %d**, and his fee was **%d**.\n\n"+ + "He fights his own turns — you don't command him. He takes no loot and no XP; he's here for the story.\n"+ + "`!expedition dismiss` sends him home (no refund — he's already packed).", + zone.Display, filled, level, int(cost))) +} + +// expeditionCmdDismiss sends him home mid-run. +func (p *AdventurePlugin) expeditionCmdDismiss(ctx MessageContext) error { + exp, isLeader, err := activeExpeditionFor(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + } + if exp == nil { + return p.SendDM(ctx.Sender, "No active expedition.") + } + if !isLeader { + return p.SendDM(ctx.Sender, "Only the party leader can dismiss him.") + } + if err := dismissCompanion(exp.ID); err != nil { + return p.SendDM(ctx.Sender, companionRefusalText(err)) + } + return p.SendDM(ctx.Sender, + "📝 **Pete heads back.** He got what he came for. The fee stays spent.") +} + +// companionRefusalText turns the companion layer's sentinel errors into copy. +func companionRefusalText(err error) string { + switch { + case errors.Is(err, ErrCompanionAlreadyHired): + return "Pete's already with you." + case errors.Is(err, ErrCompanionOnAssignment): + return "Pete's out on assignment with another party. He'll be back." + case errors.Is(err, ErrCompanionNotHired): + return "Pete isn't with you." + case errors.Is(err, ErrPartyFull): + return "No room — your party's full. Someone has to `!expedition leave` first." + default: + return "Couldn't hire Pete: " + err.Error() + } +} diff --git a/internal/plugin/expedition_party.go b/internal/plugin/expedition_party.go index be5ef2f..af576cb 100644 --- a/internal/plugin/expedition_party.go +++ b/internal/plugin/expedition_party.go @@ -82,30 +82,126 @@ func partyMembers(expeditionID string) ([]PartyMember, error) { return out, rows.Err() } -// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what -// the fan-out seams (digest, briefing, recap) want: a solo expedition yields -// just the owner, so a caller can loop unconditionally. -func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) { - members, err := partyMembers(expeditionID) +// PartySeatKind is what a seat *is*. The three answers differ in ways that matter +// at almost every seam, and conflating any two of them has already cost us a bug: +// a companion is a seat but not a mouth and not a mailbox; a leader owns the +// expedition row that everyone else references. +type PartySeatKind int + +const ( + SeatLeader PartySeatKind = iota + SeatMember + SeatCompanion +) + +// PartySeat is one body on an expedition. +type PartySeat struct { + UserID id.UserID + Kind PartySeatKind +} + +// IsHuman reports whether there is a person behind this seat — someone who can be +// DM'd, can earn loot, eats supplies, and can die. +func (s PartySeat) IsHuman() bool { return s.Kind != SeatCompanion } + +// expeditionParty is THE answer to "who is on this expedition". Every other view +// — who gets mail, who sits down in a fight, who eats — is derived from it. +// +// It ALWAYS includes the owner. That is the whole point, and it is not a +// convenience: a solo expedition has **no expedition_party rows at all** (the +// roster only materializes on the first invite — see partyMembers), so any code +// that answers "who is in this party?" by reading the roster table gets *nobody* +// for a solo player, and then quietly falls back to whatever looked sensible at +// the call site. +// +// That is not hypothetical. It is exactly how the hired companion came out at +// **level 1** for every solo player — the one player the feature exists for. The +// level was averaged over the party; the party read as empty; the fallback was 1. +// He then walked into a tier-4 zone as a level-1 body, died on contact, and left +// the leader fighting a boss that had been inflated on his account. A 1500-run +// sweep is what found it, because nothing else could. +// +// So: there is no way to ask this function for the party and be handed an empty +// list. If the expedition exists, it has at least a leader. +func expeditionParty(expeditionID, ownerID string) ([]PartySeat, error) { + rows, err := partyMembers(expeditionID) if err != nil { return nil, err } - if len(members) == 0 { - return []id.UserID{id.UserID(ownerID)}, nil + if len(rows) == 0 { + // Solo: no roster rows. The owner IS the party. + if ownerID == "" { + return nil, nil + } + return []PartySeat{{UserID: id.UserID(ownerID), Kind: SeatLeader}}, nil } - out := make([]id.UserID, 0, len(members)) - for _, m := range members { - out = append(out, id.UserID(m.UserID)) + out := make([]PartySeat, 0, len(rows)) + for _, m := range rows { + kind := SeatMember + switch { + case isCompanionUser(m.UserID): + kind = SeatCompanion + case m.IsLeader(): + kind = SeatLeader + } + out = append(out, PartySeat{UserID: id.UserID(m.UserID), Kind: kind}) } return out, nil } -// partySize is the number of seated players: 1 for a solo expedition. +// partyHumans is the party minus the companion: everyone with a person behind +// them. This is the set that gets mail, earns loot, eats supplies, and can die. +func partyHumans(expeditionID, ownerID string) ([]PartySeat, error) { + seats, err := expeditionParty(expeditionID, ownerID) + if err != nil { + return nil, err + } + out := seats[:0] + for _, s := range seats { + if s.IsHuman() { + out = append(out, s) + } + } + return out, nil +} + +// partyMemberIDs is the whole party as ids, leader first — every body, companion +// included. Callers that want only people want partyHumans. +func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) { + seats, err := expeditionParty(expeditionID, ownerID) + if err != nil { + return nil, err + } + out := make([]id.UserID, 0, len(seats)) + for _, s := range seats { + out = append(out, s.UserID) + } + return out, nil +} + +// partySize is the number of seated *players*: 1 for a solo expedition. +// +// A hired companion is not counted, and both of this function's consumers want +// it that way: +// +// - expeditionBurnRatePct scales the daily supply burn by party size. Pete +// never bought a pack — members buy their own on !expedition accept, and he +// accepts nothing — so counting him would bill the leader a 60% higher burn +// for a mouth that brought its own rations. He is on expenses. +// - the "your party is still waiting on you" gate blocks a leader from +// starting a new run while an extracted party is pending. A roster holding +// nobody but Pete is not a party waiting on anyone, and counting him would +// lock the leader out of the game until they abandoned the run. +// +// The combat roster is a different question with a different answer: Pete IS a +// body in the fight, so CombatSession.RosterSize() counts him and the enemy-HP +// scalar feels him. Seats and mouths are not the same set. func partySize(expeditionID string) (int, error) { var n int err := db.Get().QueryRow( - `SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`, - expeditionID).Scan(&n) + `SELECT COUNT(*) FROM expedition_party + WHERE expedition_id = ? AND user_id <> ?`, + expeditionID, string(companionUserID())).Scan(&n) if err != nil { return 0, err } diff --git a/internal/plugin/expedition_party_cmd.go b/internal/plugin/expedition_party_cmd.go index 01ee72b..4cdab54 100644 --- a/internal/plugin/expedition_party_cmd.go +++ b/internal/plugin/expedition_party_cmd.go @@ -246,6 +246,10 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error { b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID)))) } for _, m := range members { + if isCompanionUser(m.UserID) { + b.WriteString(companionRosterLine(exp.ID)) + continue + } role := "member" if m.IsLeader() { role = "leader" @@ -260,6 +264,9 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error { b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max)) if inviteWindowOpen(exp) { b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._") + if !companionSeated(exp.ID) { + b.WriteString("\n_Short a body? `!expedition hire` brings Pete along._") + } } return p.SendDM(ctx.Sender, b.String()) } diff --git a/internal/plugin/expedition_party_resolve.go b/internal/plugin/expedition_party_resolve.go index f2c0113..18d45a3 100644 --- a/internal/plugin/expedition_party_resolve.go +++ b/internal/plugin/expedition_party_resolve.go @@ -108,13 +108,45 @@ func seatedExpeditionFor(userID id.UserID) (*Expedition, error) { // degrades to the owner rather than dropping the message — a player who misses // their briefing because SQLite hiccuped is a worse outcome than a member who // misses theirs. +// +// A hired companion is dropped here, and this is the chokepoint that keeps him +// out of every DM seam at once: the briefing, the recap, the digest, the +// extraction notice — and, crucially, the per-member side effects that ride the +// fan-out rather than the message. maybeRollPetArrivalOnEmerge would offer Pete +// a pet and park a pending interaction awaiting a reply that never comes; +// maybeFireAnchoredEvent would claim him a daily event slot and DM him a +// choice. He is not a person; he does not get mail. See isCompanionSeat. func expeditionAudience(e *Expedition) []id.UserID { + if e == nil || e.UserID == "" { + return nil + } + seats, err := partyHumans(e.ID, e.UserID) + if err != nil { + slog.Warn("expedition: party roster read failed, DMing owner only", + "expedition", e.ID, "err", err) + return []id.UserID{id.UserID(e.UserID)} + } + out := make([]id.UserID, 0, len(seats)) + for _, s := range seats { + out = append(out, s.UserID) + } + return out +} + +// expeditionSeats is every body that sits down in a fight: the whole roster, +// hired companion included. It is deliberately NOT expeditionAudience — that one +// drops the companion because he does not get mail, and a combat roster built +// from it would seat everyone the leader paid for except the one he paid for. +// +// Mail and seats are different sets. Anything that sends is an audience; +// anything that fights is a seat. +func expeditionSeats(e *Expedition) []id.UserID { if e == nil || e.UserID == "" { return nil } ids, err := partyMemberIDs(e.ID, e.UserID) if err != nil { - slog.Warn("expedition: party roster read failed, DMing owner only", + slog.Warn("expedition: party roster read failed, seating owner only", "expedition", e.ID, "err", err) return []id.UserID{id.UserID(e.UserID)} } diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index cca3fe7..c7caa1f 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") == type SimRunner struct { P *AdventurePlugin Euro *EuroPlugin + // Companion hires Pete into the party on Day 1: "" = none, "auto" = fill the + // missing role, or an explicit class id. He takes a combat seat and no loot, + // which is exactly the thing the sweep is here to measure — whether an extra + // below-median body lifts the trailing case without carrying it. + Companion string } // NewSimRunner initializes a fresh sqlite DB in dataDir and constructs @@ -400,7 +405,42 @@ type SimCombatSummary struct { // SetSimIncludeTrace(true) has been called, and only on the LAST // combat per expedition (the boss room) to keep JSONL size bounded. // Used by J2 caster-survival analysis. - Events []CombatEvent `json:",omitempty"` + Events []simTraceEvent `json:",omitempty"` +} + +// simTraceEvent is CombatEvent with the seat ALWAYS emitted. +// +// CombatEvent.Seat is `omitempty` for wire-compat reasons that are correct for +// persistence and wrong for a trace: seat 0 and "no seat" serialize identically, +// so a party trace silently reads as a solo fight. That is not hypothetical — it +// is what made a hired companion who never took a turn look, in the JSON, like a +// fight that only ever had one seat, and it cost an hour of chasing the wrong bug. +// A diagnostic that cannot tell you who acted is not a diagnostic. +type simTraceEvent struct { + Round int + Seat int // always present, even when 0 + Phase string + Actor string + Action string + Damage int + PlayerHP int + EnemyHP int + Roll int + RollAgainst int + Desc string `json:",omitempty"` +} + +// simTraceEvents converts a TurnLog for the trace. +func simTraceEvents(events []CombatEvent) []simTraceEvent { + out := make([]simTraceEvent, len(events)) + for i, e := range events { + out[i] = simTraceEvent{ + Round: e.Round, Seat: e.Seat, Phase: e.Phase, Actor: e.Actor, Action: e.Action, + Damage: e.Damage, PlayerHP: e.PlayerHP, EnemyHP: e.EnemyHP, + Roll: e.Roll, RollAgainst: e.RollAgainst, Desc: e.Desc, + } + } + return out } // simIncludeTrace gates per-round event capture on SimCombatSummary. @@ -516,6 +556,22 @@ func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneI return res, fmt.Errorf("expedition vanished while seating the party") } } + // Hire the companion on Day 1, after the humans are seated so the role fill + // sees the party it is filling a hole in. He is not in `members` — he buys no + // packs and takes no seat in the human roster — but fightRoster reads + // expeditionSeats, so he shows up in every fight from here on. + if s.Companion != "" { + class, ok := parseCompanionClass(s.Companion) + if !ok { + class = companionRoleFill(companionPartyClasses(exp.ID)) + } + if err := hireCompanion(exp.ID, class, companionPartyLevel(exp.ID)); err != nil { + res.Outcome = "halted" + res.StopCode = "companion:" + err.Error() + return res, err + } + } + roster := append([]id.UserID{uid}, members...) res.PartySize = len(roster) for _, m := range members { @@ -942,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary { out = append(out, s) } if simIncludeTrace && len(out) > 0 { - out[len(out)-1].Events = lastEvents + out[len(out)-1].Events = simTraceEvents(lastEvents) } return out } @@ -1038,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) { turn.Sender = id.UserID(cur.seatUserID(seat)) } + // An engine-driven seat is never dispatched as a chat command. There is no + // character for the handlers to load, and a command arriving from that + // seat's id reads to beginCombatTurn as a player returning to the keyboard + // — which is how the driver used to clear the very latch that was moving + // the seat. Drive it directly instead. + if cur.seatIsEngineDriven(seat) { + if err := p.driveEngineSeat(cur, seat); err != nil { + return false, fmt.Errorf("engine seat %d: %w", seat, err) + } + continue + } + kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat) var dispatchErr error switch kind { @@ -1142,8 +1210,16 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio // which is seat 0. Driving a party member's turn off the leader's HP would heal // the wrong person and re-arm the wrong aura. func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) { - c, _ := LoadDnDCharacter(uid) - if c == nil || sess == nil { + if sess == nil { + return "attack", "" + } + // seatPickSheet, not LoadDnDCharacter: the hired companion has no character row + // and never will, so the raw load returned nil for him and this function bailed + // to "attack" on its first line — every turn, for the whole fight. That one line + // is why a hired Cleric swung a mace while the party died. He fights off the + // same synthetic sheet the rest of his seat is built from. + c := seatPickSheet(sess, uid) + if c == nil { return "attack", "" } st := sess.actorStatusesForSeat(seat) @@ -1157,6 +1233,20 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba } } } + // §1 — a healer with a hurt friend heals the friend. This is what a competent + // player does, and until the engine could target another seat it was not an + // option the picker even had. It runs before the damage picks: a party member + // about to die is a more urgent use of a slot than another Fireball. + // A healer heals whoever is worst off — the friend bleeding out beside them, or + // themselves. An empty target is a self-cast, and `!cast ` with no + // @mention is exactly how a player writes that. + if id, target := simPickHeal(c, uid, sess, seat); id != "" { + if target == "" { + return "cast", id + } + return "cast", id + " @" + target + } + if isSpellcaster(c) && !simMartialFirstClass(c.Class) { // Cleric: Spiritual Weapon is a BuffSelf that fires a spectral // bonus-action attack each round via SpiritWeaponProc/Dmg mods — @@ -1164,7 +1254,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba // otherwise never spends an L2 slot on it. Force the pick once // per fight (BuffSpiritProc==0) so the picker doesn't pretend // it's not a damage option. - if id := simPickSpiritualWeapon(c, uid, st); id != "" { + if id := simPickSpiritualWeapon(c, sess, seat, uid, st); id != "" { return "cast", id } // Once a concentration aura is up, a competent caster maintains it and @@ -1172,7 +1262,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba // slot to re-arm the same aura — so the picker excludes concentration // spells while one is active. auraActive := st.ConcentrationDmg > 0 - if id := simPickSpell(c, uid, auraActive); id != "" { + if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" { return "cast", id } } @@ -1207,14 +1297,14 @@ func simMartialFirstClass(class DnDClass) bool { // above 2nd, so spending a precious L5 to add a single d8 to the proc is // not worth burning the bigger slot's damage potential elsewhere; sim // behaves like a competent player and saves the high slot. -func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string { +func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, st ActorStatuses) string { if c == nil || c.Class != ClassCleric { return "" } if st.BuffSpiritProc > 0 { return "" } - known, err := listKnownSpells(uid) + known, err := seatKnownSpells(sess, seat, uid) if err != nil { return "" } @@ -1228,7 +1318,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st if !prepared { return "" } - slots, _ := getSpellSlots(uid) + slots, _ := seatSpellSlots(sess, seat, uid) const simMaxSlot = 5 for sl := 2; sl <= simMaxSlot; sl++ { pair, ok := slots[sl] @@ -1261,12 +1351,111 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st // - Among feasible candidates, prefer higher slot level (preserves // high-slot supremacy and burns the big slots first); tie-break on // expected damage from the dice string. -func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string { - known, err := listKnownSpells(uid) +// +// simHealAllyThresholdPct is how hurt a friend has to be before a healer spends a +// slot on them rather than on damage. Deliberately lower than the self-heal +// threshold: a heal is a wasted turn if the target was going to live anyway, and +// the party's damage output is what ends the fight. +const simHealAllyThresholdPct = 45 + +// simPickHeal returns the heal spell a competent healer would cast this turn, and +// the seat to put it on — which may be the healer's own. An empty spell id means +// nobody needs healing, or this character cannot heal. +// +// Returning target "" means "cast it on yourself": the caller drops the @mention, +// and the engine's ordinary self-heal path takes it. +// +// It considers the caster's own seat, and it runs for a solo fight, and BOTH of +// those are recent. Until now the rule skipped `i == seat` and bailed on +// `!sess.IsParty()`, which together meant something nobody had said out loud: **no +// autopiloted caster in this game had ever cast a heal on themselves.** A solo +// cleric carried cure_wounds for a whole 30-room run and used it exactly never, +// while dying with a full pool of level-1 slots. That is a strong candidate for +// why the class corpus has cleric at 46–56% against fighter's 82% +// ([[project_d8prereq_baseline]], §6 of the combat-engine plan) — the picker was +// not playing the class. +// +// The healer heals whoever is worst off. Usually that is the friend bleeding out +// next to them. Sometimes it is them. +func simPickHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) { + if sess == nil || c == nil || !isSpellcaster(c) { + return "", "" + } + + // Who is worst off? Only living seats — the engine will not raise the downed, + // and a heal aimed at a corpse is a lost turn. + worst, worstPct := -1, 101 + for i := range sess.RosterSize() { + hp, hpMax := sess.seatHP(i), sess.seatHPMax(i) + if hp <= 0 || hpMax <= 0 { + continue + } + if pct := hp * 100 / hpMax; pct < worstPct { + worst, worstPct = i, pct + } + } + if worst < 0 || worstPct >= simHealAllyThresholdPct { + return "", "" + } + // Healing yourself is not an ally-target at all — it is the plain self-heal the + // engine has always had. Hand the caller no target and let it say so. + if worst == seat { + return simPickHealSpell(c, uid, sess, seat), "" + } + + best := simPickHealSpell(c, uid, sess, seat) + if best == "" { + return "", "" + } + // Target by the seat's own Matrix localpart: splitCastTarget resolves against + // the seats in this fight, so this round-trips without a room lookup. + return best, id.UserID(sess.seatUserID(worst)).Localpart() +} + +// simPickHealSpell is the cheapest heal this caster can actually cast right now, +// or "". Burning a 5th-level slot to top somebody up is not what a competent +// player does, so it takes the lowest castable slot. +func simPickHealSpell(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) string { + known, err := seatKnownSpells(sess, seat, uid) + if err != nil { + return "" + } + slots, _ := seatSpellSlots(sess, seat, uid) + best, bestLevel := "", 99 + for _, k := range known { + if !k.Prepared { + continue + } + sp, ok := lookupSpell(k.SpellID) + if !ok || sp.Effect != EffectSpellHeal || sp.CastTime == CastReaction { + continue + } + onList := false + for _, cl := range sp.Classes { + if cl == c.Class { + onList = true + break + } + } + if !onList || sp.Level == 0 { + continue // heals are slot spells; a level-0 "heal" is not a thing we cast + } + if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 { + continue + } + if sp.Level < bestLevel { + best, bestLevel = sp.ID, sp.Level + } + } + return best +} + +func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string { + known, err := seatKnownSpells(sess, seat, uid) if err != nil || len(known) == 0 { return "" } - slots, _ := getSpellSlots(uid) + slots, _ := seatSpellSlots(sess, seat, uid) type cand struct { id string slot int diff --git a/internal/plugin/pete_retreat_test.go b/internal/plugin/pete_retreat_test.go new file mode 100644 index 0000000..7a214f1 --- /dev/null +++ b/internal/plugin/pete_retreat_test.go @@ -0,0 +1,162 @@ +package plugin + +import ( + "encoding/json" + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// The retreat bulletin: the news can finally say "it went badly and nobody +// died". Before this, every event type Pete could file was a win, a death, or an +// introduction — so a run that simply fell apart was reported as nothing at all, +// and a class that retreated a third of the time just looked absent from the +// feed. +// +// The reason gate is the whole safety property. A death must not ALSO be filed +// as a retreat (it already files its own priority dispatch), and an idle reap +// must not be filed at all — Pete telling the realm that a named player was +// driven from the field, when in truth they closed their laptop, is a lie about +// a person by name. + +func retreatFactFor(t *testing.T, uid id.UserID) map[string]any { + t.Helper() + var payload string + err := db.Get().QueryRow( + `SELECT payload FROM pete_emit_queue WHERE guid LIKE 'retreat:%'`).Scan(&payload) + if err != nil { + t.Fatalf("no retreat fact queued: %v", err) + } + var f map[string]any + if err := json.Unmarshal([]byte(payload), &f); err != nil { + t.Fatal(err) + } + return f +} + +func TestRetreatNews_ReasonGate(t *testing.T) { + cases := []struct { + reason string + want int + why string + }{ + {lossCombatRetreat, 1, "a solo walker who ran out the clock and withdrew is news"}, + {lossCombatFlee, 1, "a party that broke off is news"}, + {lossCombatDeath, 0, "a death already files its own priority dispatch — do not double-report it as a retreat"}, + {lossIdleTimeout, 0, "an idle reap is not a retreat; the player closed their laptop, they did not flee"}, + } + for _, tc := range cases { + t.Run(tc.reason, func(t *testing.T) { + setupEmptyTestDB(t) + enablePeteSeam(t) + uid := id.UserID("@retreat:example.org") + zoneCmdTestCharacter(t, uid, 10) + + emitRetreatNews(uid, tc.reason, ZoneID("underforge"), 3) + + if got := queuedCount(t, "retreat:%"); got != tc.want { + t.Fatalf("reason %q queued %d retreat facts, want %d — %s", + tc.reason, got, tc.want, tc.why) + } + }) + } +} + +// The bulletin has to carry enough for Pete to write a sentence: who, where, how +// far they got. A retreat with no day count is just "someone left". +func TestRetreatNews_CarriesTheStory(t *testing.T) { + setupEmptyTestDB(t) + enablePeteSeam(t) + uid := id.UserID("@retreat-story:example.org") + zoneCmdTestCharacter(t, uid, 10) + + emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 3) + + f := retreatFactFor(t, uid) + if f["event_type"] != "retreat" { + t.Errorf("event_type = %v, want retreat", f["event_type"]) + } + // Bulletin, not priority: a retreat is a bad day, not a funeral. + if f["tier"] != "bulletin" { + t.Errorf("tier = %v, want bulletin", f["tier"]) + } + if f["outcome"] != "retreated" { + t.Errorf("outcome = %v, want retreated", f["outcome"]) + } + if f["count"] != float64(3) { + t.Errorf("count = %v, want 3 (the day it fell apart)", f["count"]) + } + if f["zone"] == nil || f["zone"] == "" { + t.Error("no zone — Pete cannot say where it happened") + } + if f["subject"] == nil || f["subject"] == "" { + t.Error("no subject — Pete cannot say who it happened to") + } + // GUID prefix must equal the event type; Pete's taxonomy keys off it. + if guid, _ := f["guid"].(string); len(guid) < 8 || guid[:8] != "retreat:" { + t.Errorf("guid %q must be prefixed with the event type", guid) + } +} + +// The wiring, not just the emitter. Everything above calls emitRetreatNews +// directly, which proves nothing about the game: the fact only ships if the real +// run-loss chokepoint actually calls it, on a real expedition, and reads the day +// count BEFORE forcedExtractExpedition stamps the row 'abandoned' and takes the +// live fields with it. Drive the chokepoint. +func TestRetreatNews_WiredToTheRunLossChokepoint(t *testing.T) { + setupEmptyTestDB(t) + enablePeteSeam(t) + uid := id.UserID("@retreat-wired:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + + forceExtractExpeditionForRunLoss(uid, lossCombatRetreat) + + if got := queuedCount(t, "retreat:%"); got != 1 { + t.Fatalf("the run-loss chokepoint queued %d retreat facts, want 1 — "+ + "emitRetreatNews passes its own unit tests but is not actually wired to the game", got) + } + f := retreatFactFor(t, uid) + // The day must survive the extract. Read it after forcedExtractExpedition and + // it is gone — the row is 'abandoned' and the live fields are zeroed. + if f["count"] != float64(exp.CurrentDay) { + t.Errorf("count = %v, want %d — the day count was read after the extract wiped it", + f["count"], exp.CurrentDay) + } + // And the expedition really did end; a bulletin about a run still in progress + // would be worse than no bulletin. + if still, _ := getActiveExpedition(uid); still != nil { + t.Error("expedition still active after a run-loss extract") + } +} + +// The opt-out covers the new event type too. A player who asked not to be named +// must not be named when they lose — that is precisely when they'd mind most. +func TestRetreatNews_HonoursOptOut(t *testing.T) { + setupEmptyTestDB(t) + enablePeteSeam(t) + uid := id.UserID("@retreat-shy:example.org") + zoneCmdTestCharacter(t, uid, 10) + setNewsOptout(uid, true) + + emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 2) + + f := retreatFactFor(t, uid) + if f["subject"] != anonName { + t.Fatalf("subject = %v, want %q — the opt-out must cover retreats", f["subject"], anonName) + } + actors, _ := f["actors"].([]any) + for _, a := range actors { + if a != anonName { + t.Fatalf("actors leaked %v past the opt-out", a) + } + } +} diff --git a/internal/plugin/testdata/party_characterization.golden b/internal/plugin/testdata/party_characterization.golden new file mode 100644 index 0000000..6cd8078 --- /dev/null +++ b/internal/plugin/testdata/party_characterization.golden @@ -0,0 +1,1908 @@ +=== duo/even seed=1 === +result: won=true timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=36 + seat[0]: start=160 entry=160 end=59 + seat[1]: start=90 entry=90 end=0 + [00] r1 seat=1 Opening player hit dmg=16 php=90 ehp=329 roll=5/0 desc="" + [01] r1 seat=0 Opening player hit dmg=9 php=160 ehp=320 roll=5/0 desc="" + [02] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=320 roll=11/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=77 ehp=320 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=320 roll=19/0 desc="" + [05] r1 seat=0 Opening environment environmental dmg=5 php=149 ehp=320 roll=0/0 desc="" + [06] r2 seat=1 Clash player hit dmg=16 php=77 ehp=304 roll=4/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=9 php=68 ehp=304 roll=13/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=6 php=143 ehp=304 roll=12/0 desc="" + [09] r2 seat=0 Clash player hit dmg=12 php=143 ehp=292 roll=4/0 desc="" + [10] r3 seat=0 Clash player hit dmg=10 php=143 ehp=282 roll=9/0 desc="" + [11] r3 seat=1 Clash player hit dmg=19 php=68 ehp=263 roll=6/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=8 php=135 ehp=263 roll=17/0 desc="" + [13] r3 seat=1 Clash enemy hit dmg=8 php=60 ehp=263 roll=5/0 desc="" + [14] r3 seat=0 Clash enemy hit dmg=8 php=127 ehp=263 roll=7/0 desc="" + [15] r4 seat=1 Clash player hit dmg=17 php=60 ehp=246 roll=3/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=9 php=51 ehp=246 roll=11/0 desc="" + [17] r4 seat=1 Clash enemy hit dmg=7 php=44 ehp=246 roll=8/0 desc="" + [18] r4 seat=0 Clash player hit dmg=12 php=127 ehp=234 roll=9/0 desc="" + [19] r4 seat=0 Clash environment environmental dmg=5 php=122 ehp=234 roll=0/0 desc="" + [20] r5 seat=1 Decisive player hit dmg=20 php=44 ehp=214 roll=11/0 desc="" + [21] r5 seat=0 Decisive player miss dmg=0 php=122 ehp=214 roll=1/0 desc="fumble" + [22] r5 seat=0 Decisive enemy hit dmg=9 php=113 ehp=214 roll=6/0 desc="" + [23] r5 seat=0 Decisive enemy hit dmg=7 php=106 ehp=214 roll=11/0 desc="" + [24] r6 seat=0 Sudden Death player hit dmg=14 php=106 ehp=200 roll=3/0 desc="" + [25] r6 seat=1 Sudden Death player hit dmg=23 php=44 ehp=177 roll=5/0 desc="" + [26] r6 seat=0 Sudden Death enemy crit dmg=16 php=90 ehp=177 roll=20/0 desc="" + [27] r6 seat=1 Sudden Death enemy hit dmg=8 php=36 ehp=177 roll=2/0 desc="" + [28] r6 seat=1 Sudden Death enemy block dmg=5 php=31 ehp=177 roll=19/0 desc="" + [29] r7 seat=0 Sudden Death player hit dmg=15 php=90 ehp=162 roll=8/0 desc="" + [30] r7 seat=1 Sudden Death player crit dmg=46 php=31 ehp=116 roll=20/0 desc="" + [31] r7 seat=0 Sudden Death enemy hit dmg=10 php=80 ehp=116 roll=14/0 desc="" + [32] r7 seat=1 Sudden Death enemy hit dmg=10 php=21 ehp=116 roll=10/0 desc="" + [33] r8 seat=1 Sudden Death player hit dmg=23 php=21 ehp=93 roll=12/0 desc="" + [34] r8 seat=0 Sudden Death player hit dmg=13 php=80 ehp=80 roll=7/0 desc="" + [35] r8 seat=1 Sudden Death enemy hit dmg=10 php=11 ehp=80 roll=14/0 desc="" + [36] r8 seat=0 Sudden Death enemy hit dmg=7 php=73 ehp=80 roll=8/0 desc="" + [37] r9 seat=1 Sudden Death player hit dmg=19 php=11 ehp=61 roll=7/0 desc="" + [38] r9 seat=0 Sudden Death player hit dmg=11 php=73 ehp=50 roll=18/0 desc="" + [39] r9 seat=1 Sudden Death enemy hit dmg=9 php=2 ehp=50 roll=13/0 desc="" + [40] r9 seat=1 Sudden Death enemy hit dmg=10 php=0 ehp=50 roll=13/0 desc="" + [41] r9 seat=0 Sudden Death enemy block dmg=4 php=69 ehp=50 roll=5/0 desc="" + [42] r10 seat=0 Sudden Death player hit dmg=14 php=69 ehp=36 roll=6/0 desc="" + [43] r10 seat=0 Sudden Death enemy hit dmg=10 php=59 ehp=36 roll=11/0 desc="" + [44] r10 seat=0 exhaust system timeout dmg=0 php=59 ehp=36 roll=0/0 desc="" + +=== duo/even seed=2 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=71 + seat[0]: start=160 entry=160 end=42 + seat[1]: start=90 entry=90 end=7 + [00] r1 seat=1 Opening player hit dmg=16 php=90 ehp=329 roll=12/0 desc="" + [01] r1 seat=0 Opening player hit dmg=9 php=160 ehp=320 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=14 php=146 ehp=320 roll=20/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=320 roll=12/0 desc="" + [04] r2 seat=1 Clash player hit dmg=17 php=83 ehp=303 roll=5/0 desc="" + [05] r2 seat=0 Clash player hit dmg=11 php=146 ehp=292 roll=3/0 desc="" + [06] r2 seat=0 Clash enemy hit dmg=8 php=138 ehp=292 roll=18/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=8 php=130 ehp=292 roll=10/0 desc="" + [08] r3 seat=1 Clash player hit dmg=19 php=83 ehp=273 roll=16/0 desc="" + [09] r3 seat=0 Clash enemy miss dmg=0 php=130 ehp=273 roll=1/0 desc="fumble" + [10] r3 seat=0 Clash enemy hit dmg=8 php=122 ehp=273 roll=9/0 desc="" + [11] r3 seat=0 Clash player hit dmg=11 php=122 ehp=262 roll=16/0 desc="" + [12] r4 seat=1 Clash player miss dmg=0 php=83 ehp=262 roll=1/0 desc="fumble" + [13] r4 seat=0 Clash player hit dmg=10 php=122 ehp=252 roll=19/0 desc="" + [14] r4 seat=0 Clash enemy hit dmg=6 php=116 ehp=252 roll=11/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=9 php=74 ehp=252 roll=7/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=9 php=65 ehp=252 roll=4/0 desc="" + [17] r5 seat=1 Decisive player hit dmg=21 php=65 ehp=231 roll=9/0 desc="" + [18] r5 seat=1 Decisive enemy hit dmg=9 php=56 ehp=231 roll=19/0 desc="" + [19] r5 seat=0 Decisive enemy hit dmg=8 php=108 ehp=231 roll=18/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=12 php=108 ehp=219 roll=8/0 desc="" + [21] r6 seat=1 Sudden Death player block dmg=9 php=56 ehp=210 roll=10/0 desc="" + [22] r6 seat=0 Sudden Death player hit dmg=14 php=108 ehp=196 roll=16/0 desc="" + [23] r6 seat=1 Sudden Death enemy block dmg=4 php=52 ehp=196 roll=4/0 desc="" + [24] r6 seat=1 Sudden Death enemy hit dmg=11 php=41 ehp=196 roll=15/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=10 php=31 ehp=196 roll=8/0 desc="" + [26] r7 seat=1 Sudden Death player hit dmg=20 php=31 ehp=176 roll=8/0 desc="" + [27] r7 seat=0 Sudden Death player hit dmg=11 php=108 ehp=165 roll=6/0 desc="" + [28] r7 seat=0 Sudden Death enemy hit dmg=8 php=100 ehp=165 roll=18/0 desc="" + [29] r7 seat=1 Sudden Death enemy hit dmg=11 php=20 ehp=165 roll=6/0 desc="" + [30] r8 seat=1 Sudden Death player hit dmg=21 php=20 ehp=144 roll=11/0 desc="" + [31] r8 seat=0 Sudden Death player hit dmg=11 php=100 ehp=133 roll=17/0 desc="" + [32] r8 seat=0 Sudden Death enemy crit dmg=20 php=80 ehp=133 roll=20/0 desc="" + [33] r8 seat=1 Sudden Death enemy hit dmg=11 php=9 ehp=133 roll=10/0 desc="" + [34] r8 seat=0 Sudden Death enemy hit dmg=10 php=70 ehp=133 roll=5/0 desc="" + [35] r9 seat=0 Sudden Death player hit dmg=12 php=70 ehp=121 roll=14/0 desc="" + [36] r9 seat=1 Sudden Death player hit dmg=21 php=9 ehp=100 roll=6/0 desc="" + [37] r9 seat=0 Sudden Death enemy hit dmg=10 php=60 ehp=100 roll=13/0 desc="" + [38] r9 seat=0 Sudden Death enemy miss dmg=0 php=60 ehp=100 roll=1/0 desc="fumble" + [39] r10 seat=1 Sudden Death player hit dmg=18 php=9 ehp=82 roll=8/0 desc="" + [40] r10 seat=0 Sudden Death player hit dmg=11 php=60 ehp=71 roll=8/0 desc="" + [41] r10 seat=0 Sudden Death enemy hit dmg=8 php=52 ehp=71 roll=5/0 desc="" + [42] r10 seat=0 Sudden Death enemy hit dmg=10 php=42 ehp=71 roll=8/0 desc="" + [43] r10 seat=1 Sudden Death environment environmental dmg=2 php=7 ehp=71 roll=0/0 desc="" + [44] r10 seat=0 exhaust system timeout dmg=0 php=42 ehp=71 roll=0/0 desc="" + +=== duo/even seed=3 === +result: won=true timedOut=true rounds=9 survivors=true + enemy: start=345 entry=345 end=68 + seat[0]: start=160 entry=160 end=47 + seat[1]: start=90 entry=90 end=8 + [00] r1 seat=1 Opening player hit dmg=14 php=90 ehp=331 roll=11/0 desc="" + [01] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=331 roll=18/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=331 roll=10/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=8 php=82 ehp=331 roll=13/0 desc="" + [04] r1 seat=0 Opening player hit dmg=8 php=148 ehp=323 roll=5/0 desc="" + [05] r2 seat=1 Clash player hit dmg=19 php=82 ehp=304 roll=2/0 desc="" + [06] r2 seat=0 Clash player hit dmg=11 php=148 ehp=293 roll=19/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=8 php=140 ehp=293 roll=2/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=6 php=134 ehp=293 roll=6/0 desc="" + [09] r2 seat=1 Clash environment environmental dmg=6 php=76 ehp=293 roll=0/0 desc="" + [10] r3 seat=1 Clash player hit dmg=19 php=76 ehp=274 roll=14/0 desc="" + [11] r3 seat=0 Clash enemy block dmg=3 php=131 ehp=274 roll=17/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=8 php=123 ehp=274 roll=12/0 desc="" + [13] r3 seat=0 Clash player hit dmg=10 php=123 ehp=264 roll=18/0 desc="" + [14] r4 seat=1 Clash player hit dmg=18 php=76 ehp=246 roll=19/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=9 php=67 ehp=246 roll=13/0 desc="" + [16] r4 seat=0 Clash enemy hit dmg=7 php=116 ehp=246 roll=8/0 desc="" + [17] r4 seat=0 Clash player hit dmg=11 php=116 ehp=235 roll=5/0 desc="" + [18] r5 seat=1 Decisive player hit dmg=17 php=67 ehp=218 roll=7/0 desc="" + [19] r5 seat=0 Decisive player hit dmg=11 php=116 ehp=207 roll=4/0 desc="" + [20] r5 seat=0 Decisive enemy hit dmg=8 php=108 ehp=207 roll=19/0 desc="" + [21] r5 seat=0 Decisive enemy crit dmg=16 php=92 ehp=207 roll=20/0 desc="" + [22] r5 seat=0 Decisive enemy hit dmg=8 php=84 ehp=207 roll=2/0 desc="" + [23] r6 seat=1 Sudden Death player hit dmg=20 php=67 ehp=187 roll=11/0 desc="" + [24] r6 seat=0 Sudden Death player hit dmg=13 php=84 ehp=174 roll=14/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=10 php=57 ehp=174 roll=9/0 desc="" + [26] r6 seat=1 Sudden Death enemy hit dmg=9 php=48 ehp=174 roll=11/0 desc="" + [27] r6 seat=0 Sudden Death enemy hit dmg=10 php=74 ehp=174 roll=12/0 desc="" + [28] r7 seat=1 Sudden Death player hit dmg=22 php=48 ehp=152 roll=3/0 desc="" + [29] r7 seat=1 Sudden Death enemy hit dmg=10 php=38 ehp=152 roll=14/0 desc="" + [30] r7 seat=1 Sudden Death enemy hit dmg=8 php=30 ehp=152 roll=13/0 desc="" + [31] r7 seat=0 Sudden Death player hit dmg=14 php=74 ehp=138 roll=5/0 desc="" + [32] r8 seat=1 Sudden Death player hit dmg=22 php=30 ehp=116 roll=17/0 desc="" + [33] r8 seat=0 Sudden Death player hit dmg=14 php=74 ehp=102 roll=15/0 desc="" + [34] r8 seat=1 Sudden Death enemy hit dmg=11 php=19 ehp=102 roll=15/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=9 php=65 ehp=102 roll=8/0 desc="" + [36] r9 seat=1 Sudden Death player hit dmg=19 php=19 ehp=83 roll=6/0 desc="" + [37] r9 seat=1 Sudden Death enemy hit dmg=11 php=8 ehp=83 roll=15/0 desc="" + [38] r9 seat=0 Sudden Death enemy crit dmg=18 php=47 ehp=83 roll=20/0 desc="" + [39] r9 seat=0 Sudden Death player hit dmg=15 php=47 ehp=68 roll=14/0 desc="" + [40] r9 seat=0 exhaust system timeout dmg=0 php=47 ehp=68 roll=0/0 desc="" + +=== duo/even seed=7 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=50 + seat[0]: start=160 entry=160 end=31 + seat[1]: start=90 entry=90 end=0 + [00] r1 seat=1 Opening player hit dmg=15 php=90 ehp=330 roll=10/0 desc="" + [01] r1 seat=0 Opening player hit dmg=10 php=160 ehp=320 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=5 php=155 ehp=320 roll=16/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=84 ehp=320 roll=19/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=149 ehp=320 roll=11/0 desc="" + [05] r2 seat=1 Clash player hit dmg=16 php=84 ehp=304 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=10 php=149 ehp=294 roll=18/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=8 php=76 ehp=294 roll=4/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=7 php=142 ehp=294 roll=11/0 desc="" + [09] r2 seat=0 Clash enemy hit dmg=8 php=134 ehp=294 roll=13/0 desc="" + [10] r2 seat=0 Clash environment environmental dmg=5 php=129 ehp=294 roll=0/0 desc="" + [11] r3 seat=1 Clash player hit dmg=15 php=76 ehp=279 roll=7/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=7 php=122 ehp=279 roll=2/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=116 ehp=279 roll=15/0 desc="" + [14] r3 seat=0 Clash player hit dmg=12 php=116 ehp=267 roll=11/0 desc="" + [15] r4 seat=1 Clash player hit dmg=18 php=76 ehp=249 roll=18/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=7 php=69 ehp=249 roll=4/0 desc="" + [17] r4 seat=1 Clash enemy hit dmg=7 php=62 ehp=249 roll=8/0 desc="" + [18] r4 seat=0 Clash player block dmg=5 php=116 ehp=244 roll=10/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=20 php=62 ehp=224 roll=3/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=12 php=116 ehp=212 roll=17/0 desc="" + [21] r5 seat=0 Decisive enemy block dmg=4 php=112 ehp=212 roll=4/0 desc="" + [22] r5 seat=0 Decisive enemy hit dmg=8 php=104 ehp=212 roll=5/0 desc="" + [23] r6 seat=1 Sudden Death player crit dmg=36 php=62 ehp=176 roll=20/0 desc="" + [24] r6 seat=0 Sudden Death player hit dmg=14 php=104 ehp=162 roll=13/0 desc="" + [25] r6 seat=1 Sudden Death enemy miss dmg=0 php=62 ehp=162 roll=1/0 desc="fumble" + [26] r6 seat=1 Sudden Death enemy hit dmg=9 php=53 ehp=162 roll=16/0 desc="" + [27] r6 seat=1 Sudden Death enemy hit dmg=11 php=42 ehp=162 roll=19/0 desc="" + [28] r7 seat=1 Sudden Death player miss dmg=0 php=42 ehp=162 roll=1/0 desc="fumble" + [29] r7 seat=0 Sudden Death player hit dmg=11 php=104 ehp=151 roll=8/0 desc="" + [30] r7 seat=0 Sudden Death enemy hit dmg=8 php=96 ehp=151 roll=15/0 desc="" + [31] r7 seat=1 Sudden Death enemy hit dmg=10 php=32 ehp=151 roll=5/0 desc="" + [32] r8 seat=1 Sudden Death player hit dmg=21 php=32 ehp=130 roll=8/0 desc="" + [33] r8 seat=0 Sudden Death player hit dmg=15 php=96 ehp=115 roll=3/0 desc="" + [34] r8 seat=0 Sudden Death enemy hit dmg=7 php=89 ehp=115 roll=14/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=8 php=81 ehp=115 roll=17/0 desc="" + [36] r9 seat=1 Sudden Death player hit dmg=20 php=32 ehp=95 roll=15/0 desc="" + [37] r9 seat=1 Sudden Death enemy hit dmg=11 php=21 ehp=95 roll=14/0 desc="" + [38] r9 seat=1 Sudden Death enemy hit dmg=10 php=11 ehp=95 roll=13/0 desc="" + [39] r9 seat=0 Sudden Death enemy hit dmg=10 php=71 ehp=95 roll=9/0 desc="" + [40] r9 seat=0 Sudden Death player hit dmg=13 php=71 ehp=82 roll=19/0 desc="" + [41] r10 seat=1 Sudden Death player hit dmg=19 php=11 ehp=63 roll=3/0 desc="" + [42] r10 seat=0 Sudden Death player hit dmg=13 php=71 ehp=50 roll=2/0 desc="" + [43] r10 seat=0 Sudden Death enemy crit dmg=20 php=51 ehp=50 roll=20/0 desc="" + [44] r10 seat=1 Sudden Death enemy hit dmg=11 php=0 ehp=50 roll=18/0 desc="" + [45] r10 seat=0 Sudden Death enemy crit dmg=20 php=31 ehp=50 roll=20/0 desc="" + [46] r10 seat=0 exhaust system timeout dmg=0 php=31 ehp=50 roll=0/0 desc="" + +=== duo/even seed=42 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=100 + seat[0]: start=160 entry=160 end=67 + seat[1]: start=90 entry=90 end=0 + [00] r1 seat=1 Opening player hit dmg=12 php=90 ehp=333 roll=13/0 desc="" + [01] r1 seat=0 Opening player hit dmg=8 php=160 ehp=325 roll=5/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=325 roll=15/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=325 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=325 roll=13/0 desc="" + [05] r2 seat=1 Clash player hit dmg=19 php=83 ehp=306 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=9 php=148 ehp=297 roll=12/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=7 php=141 ehp=297 roll=3/0 desc="" + [08] r2 seat=1 Clash enemy hit dmg=7 php=76 ehp=297 roll=5/0 desc="" + [09] r2 seat=0 Clash enemy block dmg=3 php=138 ehp=297 roll=15/0 desc="" + [10] r3 seat=1 Clash player hit dmg=15 php=76 ehp=282 roll=13/0 desc="" + [11] r3 seat=0 Clash player hit dmg=12 php=138 ehp=270 roll=12/0 desc="" + [12] r3 seat=1 Clash enemy hit dmg=9 php=67 ehp=270 roll=15/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=132 ehp=270 roll=4/0 desc="" + [14] r3 seat=0 Clash enemy hit dmg=7 php=125 ehp=270 roll=9/0 desc="" + [15] r4 seat=1 Clash player hit dmg=17 php=67 ehp=253 roll=10/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=8 php=59 ehp=253 roll=15/0 desc="" + [17] r4 seat=1 Clash enemy miss dmg=0 php=59 ehp=253 roll=1/0 desc="fumble" + [18] r4 seat=0 Clash player hit dmg=11 php=125 ehp=242 roll=9/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=17 php=59 ehp=225 roll=3/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=11 php=125 ehp=214 roll=15/0 desc="" + [21] r5 seat=1 Decisive enemy hit dmg=8 php=51 ehp=214 roll=15/0 desc="" + [22] r5 seat=1 Decisive enemy hit dmg=9 php=42 ehp=214 roll=19/0 desc="" + [23] r5 seat=1 Decisive enemy hit dmg=8 php=34 ehp=214 roll=3/0 desc="" + [24] r6 seat=1 Sudden Death player miss dmg=0 php=34 ehp=214 roll=1/0 desc="fumble" + [25] r6 seat=0 Sudden Death enemy miss dmg=0 php=125 ehp=214 roll=1/0 desc="fumble" + [26] r6 seat=0 Sudden Death enemy hit dmg=10 php=115 ehp=214 roll=11/0 desc="" + [27] r6 seat=1 Sudden Death enemy hit dmg=11 php=23 ehp=214 roll=14/0 desc="" + [28] r6 seat=0 Sudden Death player hit dmg=14 php=115 ehp=200 roll=7/0 desc="" + [29] r7 seat=1 Sudden Death player hit dmg=22 php=23 ehp=178 roll=9/0 desc="" + [30] r7 seat=0 Sudden Death player hit dmg=15 php=115 ehp=163 roll=15/0 desc="" + [31] r7 seat=0 Sudden Death enemy hit dmg=8 php=107 ehp=163 roll=6/0 desc="" + [32] r7 seat=0 Sudden Death enemy hit dmg=10 php=97 ehp=163 roll=3/0 desc="" + [33] r7 seat=1 Sudden Death enemy hit dmg=9 php=14 ehp=163 roll=8/0 desc="" + [34] r8 seat=0 Sudden Death player hit dmg=12 php=97 ehp=151 roll=7/0 desc="" + [35] r8 seat=1 Sudden Death player miss dmg=0 php=14 ehp=151 roll=1/0 desc="fumble" + [36] r8 seat=1 Sudden Death enemy hit dmg=11 php=3 ehp=151 roll=16/0 desc="" + [37] r8 seat=0 Sudden Death enemy hit dmg=8 php=89 ehp=151 roll=17/0 desc="" + [38] r9 seat=1 Sudden Death player hit dmg=23 php=3 ehp=128 roll=11/0 desc="" + [39] r9 seat=0 Sudden Death player hit dmg=15 php=89 ehp=113 roll=8/0 desc="" + [40] r9 seat=1 Sudden Death enemy block dmg=5 php=0 ehp=113 roll=11/0 desc="" + [41] r9 seat=0 Sudden Death enemy hit dmg=10 php=79 ehp=113 roll=7/0 desc="" + [42] r9 seat=0 Sudden Death enemy block dmg=4 php=75 ehp=113 roll=4/0 desc="" + [43] r10 seat=0 Sudden Death player hit dmg=13 php=75 ehp=100 roll=9/0 desc="" + [44] r10 seat=0 Sudden Death enemy hit dmg=8 php=67 ehp=100 roll=10/0 desc="" + [45] r10 seat=0 exhaust system timeout dmg=0 php=67 ehp=100 roll=0/0 desc="" + +=== duo/tank+support seed=1 === +result: won=false timedOut=true rounds=9 survivors=true + enemy: start=345 entry=345 end=135 + seat[0]: start=160 entry=160 end=87 + seat[1]: start=110 entry=110 end=0 + [00] r1 seat=0 Opening player hit dmg=10 php=160 ehp=335 roll=5/0 desc="" + [01] r1 seat=1 Opening player hit dmg=7 php=110 ehp=328 roll=5/0 desc="" + [02] r1 seat=1 Opening enemy hit dmg=6 php=104 ehp=328 roll=11/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=5 php=99 ehp=328 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=328 roll=19/0 desc="" + [05] r1 seat=0 Opening environment environmental dmg=5 php=149 ehp=328 roll=0/0 desc="" + [06] r2 seat=1 Clash enemy hit dmg=7 php=92 ehp=328 roll=9/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=7 php=85 ehp=328 roll=13/0 desc="" + [08] r2 seat=1 Clash enemy block dmg=3 php=82 ehp=328 roll=19/0 desc="" + [09] r2 seat=0 Clash player hit dmg=10 php=149 ehp=318 roll=4/0 desc="" + [10] r2 seat=1 Clash player hit dmg=9 php=82 ehp=309 roll=6/0 desc="" + [11] r2 seat=0 Clash environment environmental dmg=2 php=147 ehp=309 roll=0/0 desc="" + [12] r3 seat=1 Clash enemy miss dmg=0 php=82 ehp=309 roll=1/0 desc="fumble" + [13] r3 seat=1 Clash enemy hit dmg=8 php=74 ehp=309 roll=6/0 desc="" + [14] r3 seat=0 Clash player hit dmg=11 php=147 ehp=298 roll=5/0 desc="" + [15] r3 seat=1 Clash player crit dmg=18 php=74 ehp=280 roll=20/0 desc="" + [16] r4 seat=0 Clash player crit dmg=22 php=147 ehp=258 roll=20/0 desc="" + [17] r4 seat=1 Clash enemy hit dmg=8 php=66 ehp=258 roll=2/0 desc="" + [18] r4 seat=0 Clash enemy crit dmg=16 php=131 ehp=258 roll=20/0 desc="" + [19] r4 seat=1 Clash player hit dmg=7 php=66 ehp=251 roll=8/0 desc="" + [20] r4 seat=1 Clash environment environmental dmg=5 php=61 ehp=251 roll=0/0 desc="" + [21] r5 seat=1 Decisive player hit dmg=9 php=61 ehp=242 roll=18/0 desc="" + [22] r5 seat=0 Decisive player hit dmg=11 php=131 ehp=231 roll=19/0 desc="" + [23] r5 seat=1 Decisive enemy hit dmg=9 php=52 ehp=231 roll=12/0 desc="" + [24] r5 seat=0 Decisive enemy hit dmg=7 php=124 ehp=231 roll=8/0 desc="" + [25] r5 seat=0 Decisive enemy hit dmg=8 php=116 ehp=231 roll=2/0 desc="" + [26] r6 seat=1 Sudden Death enemy hit dmg=10 php=42 ehp=231 roll=12/0 desc="" + [27] r6 seat=1 Sudden Death enemy hit dmg=10 php=32 ehp=231 roll=14/0 desc="" + [28] r6 seat=1 Sudden Death enemy hit dmg=10 php=22 ehp=231 roll=13/0 desc="" + [29] r6 seat=1 Sudden Death player hit dmg=11 php=22 ehp=220 roll=16/0 desc="" + [30] r6 seat=0 Sudden Death player hit dmg=14 php=116 ehp=206 roll=10/0 desc="" + [31] r6 seat=0 Sudden Death environment environmental dmg=3 php=113 ehp=206 roll=0/0 desc="" + [32] r7 seat=1 Sudden Death player hit dmg=9 php=22 ehp=197 roll=14/0 desc="" + [33] r7 seat=0 Sudden Death player hit dmg=14 php=113 ehp=183 roll=13/0 desc="" + [34] r7 seat=0 Sudden Death enemy hit dmg=8 php=105 ehp=183 roll=9/0 desc="" + [35] r7 seat=1 Sudden Death enemy hit dmg=9 php=13 ehp=183 roll=7/0 desc="" + [36] r8 seat=1 Sudden Death player hit dmg=10 php=13 ehp=173 roll=16/0 desc="" + [37] r8 seat=0 Sudden Death player hit dmg=14 php=105 ehp=159 roll=19/0 desc="" + [38] r8 seat=0 Sudden Death enemy hit dmg=10 php=95 ehp=159 roll=11/0 desc="" + [39] r8 seat=1 Sudden Death enemy miss dmg=0 php=13 ehp=159 roll=1/0 desc="fumble" + [40] r8 seat=0 Sudden Death enemy hit dmg=8 php=87 ehp=159 roll=6/0 desc="" + [41] r9 seat=0 Sudden Death player hit dmg=14 php=87 ehp=145 roll=11/0 desc="" + [42] r9 seat=1 Sudden Death player hit dmg=10 php=13 ehp=135 roll=13/0 desc="" + [43] r9 seat=1 Sudden Death enemy block dmg=4 php=9 ehp=135 roll=5/0 desc="" + [44] r9 seat=1 Sudden Death enemy crit dmg=18 php=0 ehp=135 roll=20/0 desc="" + [45] r9 seat=0 exhaust system timeout dmg=0 php=87 ehp=135 roll=0/0 desc="" + +=== duo/tank+support seed=2 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=140 + seat[0]: start=160 entry=160 end=5 + seat[1]: start=110 entry=110 end=47 + [00] r1 seat=1 Opening player hit dmg=8 php=110 ehp=337 roll=12/0 desc="" + [01] r1 seat=0 Opening player hit dmg=9 php=160 ehp=328 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=14 php=146 ehp=328 roll=20/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=104 ehp=328 roll=12/0 desc="" + [04] r2 seat=0 Clash player hit dmg=11 php=146 ehp=317 roll=5/0 desc="" + [05] r2 seat=0 Clash enemy crit dmg=14 php=132 ehp=317 roll=20/0 desc="" + [06] r2 seat=1 Clash enemy hit dmg=9 php=95 ehp=317 roll=6/0 desc="" + [07] r2 seat=1 Clash player hit dmg=9 php=95 ehp=308 roll=10/0 desc="" + [08] r3 seat=1 Clash player hit dmg=9 php=95 ehp=299 roll=16/0 desc="" + [09] r3 seat=0 Clash enemy miss dmg=0 php=132 ehp=299 roll=1/0 desc="fumble" + [10] r3 seat=0 Clash enemy hit dmg=8 php=124 ehp=299 roll=9/0 desc="" + [11] r3 seat=0 Clash player hit dmg=11 php=124 ehp=288 roll=16/0 desc="" + [12] r4 seat=1 Clash player miss dmg=0 php=95 ehp=288 roll=1/0 desc="fumble" + [13] r4 seat=0 Clash player hit dmg=10 php=124 ehp=278 roll=19/0 desc="" + [14] r4 seat=0 Clash enemy hit dmg=6 php=118 ehp=278 roll=11/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=8 php=87 ehp=278 roll=7/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=8 php=79 ehp=278 roll=4/0 desc="" + [17] r5 seat=1 Decisive player hit dmg=10 php=79 ehp=268 roll=9/0 desc="" + [18] r5 seat=1 Decisive enemy hit dmg=8 php=71 ehp=268 roll=19/0 desc="" + [19] r5 seat=0 Decisive enemy hit dmg=8 php=110 ehp=268 roll=18/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=12 php=110 ehp=256 roll=8/0 desc="" + [21] r6 seat=1 Sudden Death player block dmg=4 php=71 ehp=252 roll=10/0 desc="" + [22] r6 seat=0 Sudden Death player hit dmg=14 php=110 ehp=238 roll=16/0 desc="" + [23] r6 seat=1 Sudden Death enemy block dmg=4 php=67 ehp=238 roll=4/0 desc="" + [24] r6 seat=1 Sudden Death enemy hit dmg=10 php=57 ehp=238 roll=15/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=10 php=47 ehp=238 roll=8/0 desc="" + [26] r7 seat=0 Sudden Death player hit dmg=13 php=110 ehp=225 roll=8/0 desc="" + [27] r7 seat=0 Sudden Death enemy crit dmg=20 php=90 ehp=225 roll=20/0 desc="" + [28] r7 seat=0 Sudden Death enemy hit dmg=10 php=80 ehp=225 roll=15/0 desc="" + [29] r7 seat=0 Sudden Death enemy hit dmg=10 php=70 ehp=225 roll=16/0 desc="" + [30] r7 seat=1 Sudden Death player hit dmg=9 php=47 ehp=216 roll=10/0 desc="" + [31] r8 seat=0 Sudden Death player hit dmg=15 php=70 ehp=201 roll=5/0 desc="" + [32] r8 seat=1 Sudden Death player crit dmg=24 php=47 ehp=177 roll=20/0 desc="" + [33] r8 seat=0 Sudden Death enemy hit dmg=10 php=60 ehp=177 roll=10/0 desc="" + [34] r8 seat=0 Sudden Death enemy hit dmg=10 php=50 ehp=177 roll=5/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=9 php=41 ehp=177 roll=7/0 desc="" + [36] r9 seat=1 Sudden Death player hit dmg=11 php=47 ehp=166 roll=17/0 desc="" + [37] r9 seat=0 Sudden Death enemy hit dmg=9 php=32 ehp=166 roll=16/0 desc="" + [38] r9 seat=0 Sudden Death enemy hit dmg=9 php=23 ehp=166 roll=3/0 desc="" + [39] r9 seat=0 Sudden Death player block dmg=5 php=23 ehp=161 roll=16/0 desc="" + [40] r10 seat=0 Sudden Death enemy hit dmg=8 php=15 ehp=161 roll=5/0 desc="" + [41] r10 seat=0 Sudden Death enemy hit dmg=10 php=5 ehp=161 roll=8/0 desc="" + [42] r10 seat=1 Sudden Death player hit dmg=8 php=47 ehp=153 roll=2/0 desc="" + [43] r10 seat=0 Sudden Death player hit dmg=13 php=5 ehp=140 roll=14/0 desc="" + [44] r10 seat=0 exhaust system timeout dmg=0 php=5 ehp=140 roll=0/0 desc="" + +=== duo/tank+support seed=3 === +result: won=false timedOut=true rounds=9 survivors=true + enemy: start=345 entry=345 end=141 + seat[0]: start=160 entry=160 end=72 + seat[1]: start=110 entry=110 end=28 + [00] r1 seat=1 Opening player hit dmg=7 php=110 ehp=338 roll=11/0 desc="" + [01] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=338 roll=18/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=338 roll=10/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=103 ehp=338 roll=13/0 desc="" + [04] r1 seat=0 Opening player hit dmg=8 php=148 ehp=330 roll=5/0 desc="" + [05] r2 seat=0 Clash player hit dmg=12 php=148 ehp=318 roll=2/0 desc="" + [06] r2 seat=1 Clash player hit dmg=9 php=103 ehp=309 roll=19/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=8 php=140 ehp=309 roll=2/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=6 php=134 ehp=309 roll=6/0 desc="" + [09] r2 seat=1 Clash environment environmental dmg=6 php=97 ehp=309 roll=0/0 desc="" + [10] r3 seat=1 Clash player hit dmg=9 php=97 ehp=300 roll=14/0 desc="" + [11] r3 seat=0 Clash enemy block dmg=3 php=131 ehp=300 roll=17/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=8 php=123 ehp=300 roll=12/0 desc="" + [13] r3 seat=0 Clash player hit dmg=10 php=123 ehp=290 roll=18/0 desc="" + [14] r4 seat=1 Clash enemy hit dmg=8 php=89 ehp=290 roll=6/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=8 php=81 ehp=290 roll=9/0 desc="" + [16] r4 seat=1 Clash player hit dmg=8 php=81 ehp=282 roll=8/0 desc="" + [17] r4 seat=0 Clash player hit dmg=11 php=123 ehp=271 roll=5/0 desc="" + [18] r5 seat=1 Decisive player hit dmg=8 php=81 ehp=263 roll=7/0 desc="" + [19] r5 seat=0 Decisive player hit dmg=11 php=123 ehp=252 roll=4/0 desc="" + [20] r5 seat=0 Decisive enemy hit dmg=8 php=115 ehp=252 roll=19/0 desc="" + [21] r5 seat=0 Decisive enemy crit dmg=16 php=99 ehp=252 roll=20/0 desc="" + [22] r5 seat=0 Decisive enemy hit dmg=8 php=91 ehp=252 roll=2/0 desc="" + [23] r6 seat=1 Sudden Death player hit dmg=10 php=81 ehp=242 roll=11/0 desc="" + [24] r6 seat=0 Sudden Death player hit dmg=13 php=91 ehp=229 roll=14/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=9 php=72 ehp=229 roll=9/0 desc="" + [26] r6 seat=1 Sudden Death enemy hit dmg=9 php=63 ehp=229 roll=11/0 desc="" + [27] r6 seat=0 Sudden Death enemy hit dmg=10 php=81 ehp=229 roll=12/0 desc="" + [28] r7 seat=1 Sudden Death player hit dmg=11 php=63 ehp=218 roll=3/0 desc="" + [29] r7 seat=1 Sudden Death enemy hit dmg=9 php=54 ehp=218 roll=14/0 desc="" + [30] r7 seat=1 Sudden Death enemy hit dmg=8 php=46 ehp=218 roll=13/0 desc="" + [31] r7 seat=0 Sudden Death player hit dmg=14 php=81 ehp=204 roll=5/0 desc="" + [32] r8 seat=0 Sudden Death player hit dmg=14 php=81 ehp=190 roll=17/0 desc="" + [33] r8 seat=1 Sudden Death player hit dmg=11 php=46 ehp=179 roll=15/0 desc="" + [34] r8 seat=1 Sudden Death enemy hit dmg=10 php=36 ehp=179 roll=15/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=9 php=72 ehp=179 roll=8/0 desc="" + [36] r9 seat=1 Sudden Death enemy miss dmg=0 php=36 ehp=179 roll=1/0 desc="fumble" + [37] r9 seat=1 Sudden Death enemy hit dmg=8 php=28 ehp=179 roll=12/0 desc="" + [38] r9 seat=1 Sudden Death player hit dmg=12 php=28 ehp=167 roll=6/0 desc="" + [39] r9 seat=0 Sudden Death player crit dmg=26 php=72 ehp=141 roll=20/0 desc="" + [40] r9 seat=0 exhaust system timeout dmg=0 php=72 ehp=141 roll=0/0 desc="" + +=== duo/tank+support seed=7 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=142 + seat[0]: start=160 entry=160 end=31 + seat[1]: start=110 entry=110 end=27 + [00] r1 seat=1 Opening player hit dmg=7 php=110 ehp=338 roll=10/0 desc="" + [01] r1 seat=0 Opening player hit dmg=10 php=160 ehp=328 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=5 php=155 ehp=328 roll=16/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=5 php=105 ehp=328 roll=19/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=149 ehp=328 roll=11/0 desc="" + [05] r2 seat=1 Clash player hit dmg=8 php=105 ehp=320 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=10 php=149 ehp=310 roll=18/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=7 php=98 ehp=310 roll=4/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=7 php=142 ehp=310 roll=11/0 desc="" + [09] r2 seat=0 Clash enemy hit dmg=8 php=134 ehp=310 roll=13/0 desc="" + [10] r2 seat=0 Clash environment environmental dmg=5 php=129 ehp=310 roll=0/0 desc="" + [11] r3 seat=1 Clash player hit dmg=7 php=98 ehp=303 roll=7/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=7 php=122 ehp=303 roll=2/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=116 ehp=303 roll=15/0 desc="" + [14] r3 seat=0 Clash player hit dmg=12 php=116 ehp=291 roll=11/0 desc="" + [15] r4 seat=1 Clash player hit dmg=9 php=98 ehp=282 roll=18/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=7 php=91 ehp=282 roll=4/0 desc="" + [17] r4 seat=1 Clash enemy hit dmg=7 php=84 ehp=282 roll=8/0 desc="" + [18] r4 seat=0 Clash player block dmg=5 php=116 ehp=277 roll=10/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=10 php=84 ehp=267 roll=3/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=12 php=116 ehp=255 roll=17/0 desc="" + [21] r5 seat=0 Decisive enemy block dmg=4 php=112 ehp=255 roll=4/0 desc="" + [22] r5 seat=0 Decisive enemy hit dmg=8 php=104 ehp=255 roll=5/0 desc="" + [23] r6 seat=1 Sudden Death player crit dmg=18 php=84 ehp=237 roll=20/0 desc="" + [24] r6 seat=0 Sudden Death player hit dmg=14 php=104 ehp=223 roll=13/0 desc="" + [25] r6 seat=1 Sudden Death enemy miss dmg=0 php=84 ehp=223 roll=1/0 desc="fumble" + [26] r6 seat=1 Sudden Death enemy hit dmg=8 php=76 ehp=223 roll=16/0 desc="" + [27] r6 seat=1 Sudden Death enemy hit dmg=10 php=66 ehp=223 roll=19/0 desc="" + [28] r7 seat=1 Sudden Death player miss dmg=0 php=66 ehp=223 roll=1/0 desc="fumble" + [29] r7 seat=0 Sudden Death player hit dmg=11 php=104 ehp=212 roll=8/0 desc="" + [30] r7 seat=0 Sudden Death enemy hit dmg=8 php=96 ehp=212 roll=15/0 desc="" + [31] r7 seat=1 Sudden Death enemy hit dmg=9 php=57 ehp=212 roll=5/0 desc="" + [32] r8 seat=1 Sudden Death player hit dmg=10 php=57 ehp=202 roll=8/0 desc="" + [33] r8 seat=0 Sudden Death player hit dmg=15 php=96 ehp=187 roll=3/0 desc="" + [34] r8 seat=0 Sudden Death enemy hit dmg=7 php=89 ehp=187 roll=14/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=8 php=81 ehp=187 roll=17/0 desc="" + [36] r9 seat=1 Sudden Death player hit dmg=10 php=57 ehp=177 roll=15/0 desc="" + [37] r9 seat=1 Sudden Death enemy hit dmg=10 php=47 ehp=177 roll=14/0 desc="" + [38] r9 seat=1 Sudden Death enemy hit dmg=10 php=37 ehp=177 roll=13/0 desc="" + [39] r9 seat=0 Sudden Death enemy hit dmg=10 php=71 ehp=177 roll=9/0 desc="" + [40] r9 seat=0 Sudden Death player hit dmg=13 php=71 ehp=164 roll=19/0 desc="" + [41] r10 seat=0 Sudden Death player hit dmg=12 php=71 ehp=152 roll=3/0 desc="" + [42] r10 seat=1 Sudden Death player hit dmg=10 php=37 ehp=142 roll=2/0 desc="" + [43] r10 seat=0 Sudden Death enemy crit dmg=20 php=51 ehp=142 roll=20/0 desc="" + [44] r10 seat=1 Sudden Death enemy hit dmg=10 php=27 ehp=142 roll=18/0 desc="" + [45] r10 seat=0 Sudden Death enemy crit dmg=20 php=31 ehp=142 roll=20/0 desc="" + [46] r10 seat=0 exhaust system timeout dmg=0 php=31 ehp=142 roll=0/0 desc="" + +=== duo/tank+support seed=42 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=140 + seat[0]: start=160 entry=160 end=47 + seat[1]: start=110 entry=110 end=9 + [00] r1 seat=0 Opening player hit dmg=8 php=160 ehp=337 roll=13/0 desc="" + [01] r1 seat=1 Opening player hit dmg=6 php=110 ehp=331 roll=5/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=331 roll=15/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=103 ehp=331 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=331 roll=13/0 desc="" + [05] r2 seat=1 Clash player hit dmg=9 php=103 ehp=322 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=9 php=148 ehp=313 roll=12/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=7 php=141 ehp=313 roll=3/0 desc="" + [08] r2 seat=1 Clash enemy hit dmg=7 php=96 ehp=313 roll=5/0 desc="" + [09] r2 seat=0 Clash enemy block dmg=3 php=138 ehp=313 roll=15/0 desc="" + [10] r3 seat=1 Clash player hit dmg=7 php=96 ehp=306 roll=13/0 desc="" + [11] r3 seat=0 Clash player hit dmg=12 php=138 ehp=294 roll=12/0 desc="" + [12] r3 seat=1 Clash enemy hit dmg=9 php=87 ehp=294 roll=15/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=132 ehp=294 roll=4/0 desc="" + [14] r3 seat=0 Clash enemy hit dmg=7 php=125 ehp=294 roll=9/0 desc="" + [15] r4 seat=1 Clash player hit dmg=8 php=87 ehp=286 roll=10/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=8 php=79 ehp=286 roll=15/0 desc="" + [17] r4 seat=1 Clash enemy miss dmg=0 php=79 ehp=286 roll=1/0 desc="fumble" + [18] r4 seat=0 Clash player hit dmg=11 php=125 ehp=275 roll=9/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=8 php=79 ehp=267 roll=3/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=11 php=125 ehp=256 roll=15/0 desc="" + [21] r5 seat=1 Decisive enemy hit dmg=7 php=72 ehp=256 roll=15/0 desc="" + [22] r5 seat=1 Decisive enemy hit dmg=9 php=63 ehp=256 roll=19/0 desc="" + [23] r5 seat=1 Decisive enemy hit dmg=8 php=55 ehp=256 roll=3/0 desc="" + [24] r6 seat=0 Sudden Death enemy hit dmg=10 php=115 ehp=256 roll=4/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=9 php=46 ehp=256 roll=18/0 desc="" + [26] r6 seat=1 Sudden Death enemy hit dmg=9 php=37 ehp=256 roll=17/0 desc="" + [27] r6 seat=1 Sudden Death player crit dmg=22 php=37 ehp=234 roll=20/0 desc="" + [28] r6 seat=0 Sudden Death player hit dmg=12 php=115 ehp=222 roll=11/0 desc="" + [29] r7 seat=1 Sudden Death player hit dmg=10 php=37 ehp=212 roll=14/0 desc="" + [30] r7 seat=0 Sudden Death player hit dmg=11 php=115 ehp=201 roll=6/0 desc="" + [31] r7 seat=0 Sudden Death enemy hit dmg=10 php=105 ehp=201 roll=3/0 desc="" + [32] r7 seat=1 Sudden Death enemy hit dmg=8 php=29 ehp=201 roll=8/0 desc="" + [33] r7 seat=0 Sudden Death enemy hit dmg=8 php=97 ehp=201 roll=4/0 desc="" + [34] r7 seat=1 Sudden Death environment environmental dmg=3 php=26 ehp=201 roll=0/0 desc="" + [35] r8 seat=1 Sudden Death player hit dmg=11 php=26 ehp=190 roll=16/0 desc="" + [36] r8 seat=0 Sudden Death player hit dmg=12 php=97 ehp=178 roll=17/0 desc="" + [37] r8 seat=0 Sudden Death enemy hit dmg=9 php=88 ehp=178 roll=10/0 desc="" + [38] r8 seat=1 Sudden Death enemy hit dmg=9 php=17 ehp=178 roll=16/0 desc="" + [39] r9 seat=0 Sudden Death player block dmg=7 php=88 ehp=171 roll=11/0 desc="" + [40] r9 seat=1 Sudden Death player hit dmg=9 php=17 ehp=162 roll=9/0 desc="" + [41] r9 seat=1 Sudden Death enemy miss dmg=0 php=17 ehp=162 roll=1/0 desc="fumble" + [42] r9 seat=1 Sudden Death enemy hit dmg=8 php=9 ehp=162 roll=12/0 desc="" + [43] r9 seat=0 Sudden Death enemy hit dmg=9 php=79 ehp=162 roll=9/0 desc="" + [44] r10 seat=1 Sudden Death player hit dmg=10 php=9 ehp=152 roll=14/0 desc="" + [45] r10 seat=0 Sudden Death player hit dmg=12 php=79 ehp=140 roll=19/0 desc="" + [46] r10 seat=0 Sudden Death enemy miss dmg=0 php=79 ehp=140 roll=1/0 desc="fumble" + [47] r10 seat=0 Sudden Death enemy hit dmg=9 php=70 ehp=140 roll=14/0 desc="" + [48] r10 seat=0 Sudden Death enemy crit dmg=20 php=50 ehp=140 roll=20/0 desc="" + [49] r10 seat=0 Sudden Death environment environmental dmg=3 php=47 ehp=140 roll=0/0 desc="" + [50] r10 seat=0 exhaust system timeout dmg=0 php=47 ehp=140 roll=0/0 desc="" + +=== duo/median+weak seed=1 === +result: won=false timedOut=true rounds=9 survivors=true + enemy: start=345 entry=345 end=205 + seat[0]: start=160 entry=160 end=71 + seat[1]: start=70 entry=70 end=0 + [00] r1 seat=0 Opening player hit dmg=10 php=160 ehp=335 roll=5/0 desc="" + [01] r1 seat=1 Opening player hit dmg=5 php=70 ehp=330 roll=5/0 desc="" + [02] r1 seat=1 Opening enemy hit dmg=7 php=63 ehp=330 roll=11/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=57 ehp=330 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=330 roll=19/0 desc="" + [05] r1 seat=0 Opening environment environmental dmg=5 php=149 ehp=330 roll=0/0 desc="" + [06] r2 seat=1 Clash enemy hit dmg=8 php=49 ehp=330 roll=9/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=8 php=41 ehp=330 roll=13/0 desc="" + [08] r2 seat=1 Clash enemy block dmg=3 php=38 ehp=330 roll=19/0 desc="" + [09] r2 seat=0 Clash player hit dmg=10 php=149 ehp=320 roll=4/0 desc="" + [10] r2 seat=1 Clash player hit dmg=7 php=38 ehp=313 roll=6/0 desc="" + [11] r2 seat=0 Clash environment environmental dmg=2 php=147 ehp=313 roll=0/0 desc="" + [12] r3 seat=1 Clash enemy miss dmg=0 php=38 ehp=313 roll=1/0 desc="fumble" + [13] r3 seat=1 Clash enemy hit dmg=9 php=29 ehp=313 roll=6/0 desc="" + [14] r3 seat=0 Clash player hit dmg=11 php=147 ehp=302 roll=5/0 desc="" + [15] r3 seat=1 Clash player crit dmg=14 php=29 ehp=288 roll=20/0 desc="" + [16] r4 seat=0 Clash player crit dmg=22 php=147 ehp=266 roll=20/0 desc="" + [17] r4 seat=1 Clash enemy hit dmg=9 php=20 ehp=266 roll=2/0 desc="" + [18] r4 seat=0 Clash enemy crit dmg=16 php=131 ehp=266 roll=20/0 desc="" + [19] r4 seat=1 Clash player hit dmg=5 php=20 ehp=261 roll=8/0 desc="" + [20] r4 seat=1 Clash environment environmental dmg=5 php=15 ehp=261 roll=0/0 desc="" + [21] r5 seat=1 Decisive player hit dmg=6 php=15 ehp=255 roll=18/0 desc="" + [22] r5 seat=0 Decisive player hit dmg=11 php=131 ehp=244 roll=19/0 desc="" + [23] r5 seat=1 Decisive enemy hit dmg=10 php=5 ehp=244 roll=12/0 desc="" + [24] r5 seat=0 Decisive enemy hit dmg=7 php=124 ehp=244 roll=8/0 desc="" + [25] r5 seat=0 Decisive enemy hit dmg=8 php=116 ehp=244 roll=2/0 desc="" + [26] r6 seat=1 Sudden Death enemy hit dmg=10 php=0 ehp=244 roll=12/0 desc="" + [27] r6 seat=0 Sudden Death enemy hit dmg=9 php=107 ehp=244 roll=14/0 desc="" + [28] r6 seat=0 Sudden Death enemy hit dmg=9 php=98 ehp=244 roll=13/0 desc="" + [29] r6 seat=0 Sudden Death player hit dmg=15 php=98 ehp=229 roll=16/0 desc="" + [30] r7 seat=0 Sudden Death enemy hit dmg=10 php=88 ehp=229 roll=5/0 desc="" + [31] r7 seat=0 Sudden Death player miss dmg=0 php=88 ehp=229 roll=1/0 desc="fumble" + [32] r8 seat=0 Sudden Death enemy hit dmg=8 php=80 ehp=229 roll=6/0 desc="" + [33] r8 seat=0 Sudden Death player hit dmg=12 php=80 ehp=217 roll=9/0 desc="" + [34] r9 seat=0 Sudden Death player hit dmg=12 php=80 ehp=205 roll=10/0 desc="" + [35] r9 seat=0 Sudden Death enemy hit dmg=9 php=71 ehp=205 roll=17/0 desc="" + [36] r9 seat=0 exhaust system timeout dmg=0 php=71 ehp=205 roll=0/0 desc="" + +=== duo/median+weak seed=2 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=167 + seat[0]: start=160 entry=160 end=5 + seat[1]: start=70 entry=70 end=1 + [00] r1 seat=1 Opening player hit dmg=6 php=70 ehp=339 roll=12/0 desc="" + [01] r1 seat=0 Opening player hit dmg=9 php=160 ehp=330 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=14 php=146 ehp=330 roll=20/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=63 ehp=330 roll=12/0 desc="" + [04] r2 seat=0 Clash player hit dmg=11 php=146 ehp=319 roll=5/0 desc="" + [05] r2 seat=0 Clash enemy crit dmg=14 php=132 ehp=319 roll=20/0 desc="" + [06] r2 seat=1 Clash enemy hit dmg=9 php=54 ehp=319 roll=6/0 desc="" + [07] r2 seat=1 Clash player hit dmg=6 php=54 ehp=313 roll=10/0 desc="" + [08] r3 seat=1 Clash player hit dmg=6 php=54 ehp=307 roll=16/0 desc="" + [09] r3 seat=0 Clash enemy miss dmg=0 php=132 ehp=307 roll=1/0 desc="fumble" + [10] r3 seat=0 Clash enemy hit dmg=8 php=124 ehp=307 roll=9/0 desc="" + [11] r3 seat=0 Clash player hit dmg=11 php=124 ehp=296 roll=16/0 desc="" + [12] r4 seat=1 Clash player miss dmg=0 php=54 ehp=296 roll=1/0 desc="fumble" + [13] r4 seat=0 Clash player hit dmg=10 php=124 ehp=286 roll=19/0 desc="" + [14] r4 seat=0 Clash enemy hit dmg=6 php=118 ehp=286 roll=11/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=9 php=45 ehp=286 roll=7/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=9 php=36 ehp=286 roll=4/0 desc="" + [17] r5 seat=1 Decisive player hit dmg=7 php=36 ehp=279 roll=9/0 desc="" + [18] r5 seat=1 Decisive enemy hit dmg=9 php=27 ehp=279 roll=19/0 desc="" + [19] r5 seat=0 Decisive enemy hit dmg=8 php=110 ehp=279 roll=18/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=12 php=110 ehp=267 roll=8/0 desc="" + [21] r6 seat=1 Sudden Death player block dmg=3 php=27 ehp=264 roll=10/0 desc="" + [22] r6 seat=0 Sudden Death player hit dmg=14 php=110 ehp=250 roll=16/0 desc="" + [23] r6 seat=1 Sudden Death enemy block dmg=4 php=23 ehp=250 roll=4/0 desc="" + [24] r6 seat=1 Sudden Death enemy hit dmg=11 php=12 ehp=250 roll=15/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=11 php=1 ehp=250 roll=8/0 desc="" + [26] r7 seat=0 Sudden Death player hit dmg=13 php=110 ehp=237 roll=8/0 desc="" + [27] r7 seat=0 Sudden Death enemy crit dmg=20 php=90 ehp=237 roll=20/0 desc="" + [28] r7 seat=0 Sudden Death enemy hit dmg=10 php=80 ehp=237 roll=15/0 desc="" + [29] r7 seat=0 Sudden Death enemy hit dmg=10 php=70 ehp=237 roll=16/0 desc="" + [30] r7 seat=1 Sudden Death player hit dmg=7 php=1 ehp=230 roll=10/0 desc="" + [31] r8 seat=0 Sudden Death player hit dmg=15 php=70 ehp=215 roll=5/0 desc="" + [32] r8 seat=1 Sudden Death player crit dmg=16 php=1 ehp=199 roll=20/0 desc="" + [33] r8 seat=0 Sudden Death enemy hit dmg=10 php=60 ehp=199 roll=10/0 desc="" + [34] r8 seat=0 Sudden Death enemy hit dmg=10 php=50 ehp=199 roll=5/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=9 php=41 ehp=199 roll=7/0 desc="" + [36] r9 seat=1 Sudden Death player hit dmg=8 php=1 ehp=191 roll=17/0 desc="" + [37] r9 seat=0 Sudden Death enemy hit dmg=9 php=32 ehp=191 roll=16/0 desc="" + [38] r9 seat=0 Sudden Death enemy hit dmg=9 php=23 ehp=191 roll=3/0 desc="" + [39] r9 seat=0 Sudden Death player block dmg=5 php=23 ehp=186 roll=16/0 desc="" + [40] r10 seat=0 Sudden Death enemy hit dmg=8 php=15 ehp=186 roll=5/0 desc="" + [41] r10 seat=0 Sudden Death enemy hit dmg=10 php=5 ehp=186 roll=8/0 desc="" + [42] r10 seat=1 Sudden Death player hit dmg=6 php=1 ehp=180 roll=2/0 desc="" + [43] r10 seat=0 Sudden Death player hit dmg=13 php=5 ehp=167 roll=14/0 desc="" + [44] r10 seat=0 exhaust system timeout dmg=0 php=5 ehp=167 roll=0/0 desc="" + +=== duo/median+weak seed=3 === +result: won=false timedOut=true rounds=9 survivors=true + enemy: start=345 entry=345 end=184 + seat[0]: start=160 entry=160 end=54 + seat[1]: start=70 entry=70 end=0 + [00] r1 seat=1 Opening player hit dmg=5 php=70 ehp=340 roll=11/0 desc="" + [01] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=340 roll=18/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=340 roll=10/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=8 php=62 ehp=340 roll=13/0 desc="" + [04] r1 seat=0 Opening player hit dmg=8 php=148 ehp=332 roll=5/0 desc="" + [05] r2 seat=0 Clash player hit dmg=12 php=148 ehp=320 roll=2/0 desc="" + [06] r2 seat=1 Clash player hit dmg=6 php=62 ehp=314 roll=19/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=8 php=140 ehp=314 roll=2/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=6 php=134 ehp=314 roll=6/0 desc="" + [09] r2 seat=1 Clash environment environmental dmg=6 php=56 ehp=314 roll=0/0 desc="" + [10] r3 seat=1 Clash player hit dmg=7 php=56 ehp=307 roll=14/0 desc="" + [11] r3 seat=0 Clash enemy block dmg=3 php=131 ehp=307 roll=17/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=8 php=123 ehp=307 roll=12/0 desc="" + [13] r3 seat=0 Clash player hit dmg=10 php=123 ehp=297 roll=18/0 desc="" + [14] r4 seat=1 Clash enemy hit dmg=9 php=47 ehp=297 roll=6/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=8 php=39 ehp=297 roll=9/0 desc="" + [16] r4 seat=1 Clash player hit dmg=6 php=39 ehp=291 roll=8/0 desc="" + [17] r4 seat=0 Clash player hit dmg=11 php=123 ehp=280 roll=5/0 desc="" + [18] r5 seat=1 Decisive player hit dmg=6 php=39 ehp=274 roll=7/0 desc="" + [19] r5 seat=0 Decisive player hit dmg=11 php=123 ehp=263 roll=4/0 desc="" + [20] r5 seat=0 Decisive enemy hit dmg=8 php=115 ehp=263 roll=19/0 desc="" + [21] r5 seat=0 Decisive enemy crit dmg=16 php=99 ehp=263 roll=20/0 desc="" + [22] r5 seat=0 Decisive enemy hit dmg=8 php=91 ehp=263 roll=2/0 desc="" + [23] r6 seat=1 Sudden Death player hit dmg=7 php=39 ehp=256 roll=11/0 desc="" + [24] r6 seat=0 Sudden Death player hit dmg=13 php=91 ehp=243 roll=14/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=10 php=29 ehp=243 roll=9/0 desc="" + [26] r6 seat=1 Sudden Death enemy hit dmg=9 php=20 ehp=243 roll=11/0 desc="" + [27] r6 seat=0 Sudden Death enemy hit dmg=10 php=81 ehp=243 roll=12/0 desc="" + [28] r7 seat=1 Sudden Death player hit dmg=8 php=20 ehp=235 roll=3/0 desc="" + [29] r7 seat=1 Sudden Death enemy hit dmg=10 php=10 ehp=235 roll=14/0 desc="" + [30] r7 seat=1 Sudden Death enemy hit dmg=9 php=1 ehp=235 roll=13/0 desc="" + [31] r7 seat=0 Sudden Death player hit dmg=14 php=81 ehp=221 roll=5/0 desc="" + [32] r8 seat=0 Sudden Death player hit dmg=14 php=81 ehp=207 roll=17/0 desc="" + [33] r8 seat=1 Sudden Death player hit dmg=8 php=1 ehp=199 roll=15/0 desc="" + [34] r8 seat=1 Sudden Death enemy hit dmg=11 php=0 ehp=199 roll=15/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=9 php=72 ehp=199 roll=8/0 desc="" + [36] r9 seat=0 Sudden Death player hit dmg=15 php=72 ehp=184 roll=17/0 desc="" + [37] r9 seat=0 Sudden Death enemy crit dmg=18 php=54 ehp=184 roll=20/0 desc="" + [38] r9 seat=0 exhaust system timeout dmg=0 php=54 ehp=184 roll=0/0 desc="" + +=== duo/median+weak seed=7 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=175 + seat[0]: start=160 entry=160 end=55 + seat[1]: start=70 entry=70 end=0 + [00] r1 seat=1 Opening player hit dmg=5 php=70 ehp=340 roll=10/0 desc="" + [01] r1 seat=0 Opening player hit dmg=10 php=160 ehp=330 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=5 php=155 ehp=330 roll=16/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=64 ehp=330 roll=19/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=149 ehp=330 roll=11/0 desc="" + [05] r2 seat=1 Clash player hit dmg=5 php=64 ehp=325 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=10 php=149 ehp=315 roll=18/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=8 php=56 ehp=315 roll=4/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=7 php=142 ehp=315 roll=11/0 desc="" + [09] r2 seat=0 Clash enemy hit dmg=8 php=134 ehp=315 roll=13/0 desc="" + [10] r2 seat=0 Clash environment environmental dmg=5 php=129 ehp=315 roll=0/0 desc="" + [11] r3 seat=1 Clash player hit dmg=5 php=56 ehp=310 roll=7/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=7 php=122 ehp=310 roll=2/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=116 ehp=310 roll=15/0 desc="" + [14] r3 seat=0 Clash player hit dmg=12 php=116 ehp=298 roll=11/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=7 php=49 ehp=298 roll=15/0 desc="" + [16] r4 seat=0 Clash enemy hit dmg=8 php=108 ehp=298 roll=13/0 desc="" + [17] r4 seat=0 Clash player hit dmg=10 php=108 ehp=288 roll=8/0 desc="" + [18] r4 seat=1 Clash player block dmg=3 php=49 ehp=285 roll=10/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=7 php=49 ehp=278 roll=3/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=12 php=108 ehp=266 roll=17/0 desc="" + [21] r5 seat=0 Decisive enemy block dmg=4 php=104 ehp=266 roll=4/0 desc="" + [22] r5 seat=0 Decisive enemy hit dmg=8 php=96 ehp=266 roll=5/0 desc="" + [23] r6 seat=1 Sudden Death player crit dmg=12 php=49 ehp=254 roll=20/0 desc="" + [24] r6 seat=0 Sudden Death player hit dmg=14 php=96 ehp=240 roll=13/0 desc="" + [25] r6 seat=1 Sudden Death enemy miss dmg=0 php=49 ehp=240 roll=1/0 desc="fumble" + [26] r6 seat=1 Sudden Death enemy hit dmg=9 php=40 ehp=240 roll=16/0 desc="" + [27] r6 seat=1 Sudden Death enemy hit dmg=11 php=29 ehp=240 roll=19/0 desc="" + [28] r7 seat=1 Sudden Death player miss dmg=0 php=29 ehp=240 roll=1/0 desc="fumble" + [29] r7 seat=0 Sudden Death player hit dmg=11 php=96 ehp=229 roll=8/0 desc="" + [30] r7 seat=0 Sudden Death enemy hit dmg=8 php=88 ehp=229 roll=15/0 desc="" + [31] r7 seat=1 Sudden Death enemy hit dmg=10 php=19 ehp=229 roll=5/0 desc="" + [32] r8 seat=1 Sudden Death player hit dmg=7 php=19 ehp=222 roll=8/0 desc="" + [33] r8 seat=0 Sudden Death player hit dmg=15 php=88 ehp=207 roll=3/0 desc="" + [34] r8 seat=0 Sudden Death enemy hit dmg=7 php=81 ehp=207 roll=14/0 desc="" + [35] r8 seat=0 Sudden Death enemy hit dmg=8 php=73 ehp=207 roll=17/0 desc="" + [36] r9 seat=1 Sudden Death player hit dmg=7 php=19 ehp=200 roll=15/0 desc="" + [37] r9 seat=1 Sudden Death enemy hit dmg=11 php=8 ehp=200 roll=14/0 desc="" + [38] r9 seat=1 Sudden Death enemy hit dmg=11 php=0 ehp=200 roll=13/0 desc="" + [39] r9 seat=0 Sudden Death enemy hit dmg=10 php=63 ehp=200 roll=9/0 desc="" + [40] r9 seat=0 Sudden Death player hit dmg=13 php=63 ehp=187 roll=19/0 desc="" + [41] r10 seat=0 Sudden Death player hit dmg=12 php=63 ehp=175 roll=15/0 desc="" + [42] r10 seat=0 Sudden Death enemy hit dmg=8 php=55 ehp=175 roll=3/0 desc="" + [43] r10 seat=0 exhaust system timeout dmg=0 php=55 ehp=175 roll=0/0 desc="" + +=== duo/median+weak seed=42 === +result: won=false timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=186 + seat[0]: start=160 entry=160 end=79 + seat[1]: start=70 entry=70 end=0 + [00] r1 seat=0 Opening player hit dmg=8 php=160 ehp=337 roll=13/0 desc="" + [01] r1 seat=1 Opening player hit dmg=4 php=70 ehp=333 roll=5/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=333 roll=15/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=63 ehp=333 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=333 roll=13/0 desc="" + [05] r2 seat=1 Clash player hit dmg=7 php=63 ehp=326 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=9 php=148 ehp=317 roll=12/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=7 php=141 ehp=317 roll=3/0 desc="" + [08] r2 seat=1 Clash enemy hit dmg=7 php=56 ehp=317 roll=5/0 desc="" + [09] r2 seat=0 Clash enemy block dmg=3 php=138 ehp=317 roll=15/0 desc="" + [10] r3 seat=1 Clash player hit dmg=5 php=56 ehp=312 roll=13/0 desc="" + [11] r3 seat=0 Clash player hit dmg=12 php=138 ehp=300 roll=12/0 desc="" + [12] r3 seat=1 Clash enemy hit dmg=10 php=46 ehp=300 roll=15/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=132 ehp=300 roll=4/0 desc="" + [14] r3 seat=0 Clash enemy hit dmg=7 php=125 ehp=300 roll=9/0 desc="" + [15] r4 seat=1 Clash player hit dmg=6 php=46 ehp=294 roll=10/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=8 php=38 ehp=294 roll=15/0 desc="" + [17] r4 seat=1 Clash enemy miss dmg=0 php=38 ehp=294 roll=1/0 desc="fumble" + [18] r4 seat=0 Clash player hit dmg=11 php=125 ehp=283 roll=9/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=6 php=38 ehp=277 roll=3/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=11 php=125 ehp=266 roll=15/0 desc="" + [21] r5 seat=1 Decisive enemy hit dmg=8 php=30 ehp=266 roll=15/0 desc="" + [22] r5 seat=1 Decisive enemy hit dmg=9 php=21 ehp=266 roll=19/0 desc="" + [23] r5 seat=1 Decisive enemy hit dmg=8 php=13 ehp=266 roll=3/0 desc="" + [24] r6 seat=0 Sudden Death enemy hit dmg=10 php=115 ehp=266 roll=4/0 desc="" + [25] r6 seat=1 Sudden Death enemy hit dmg=9 php=4 ehp=266 roll=18/0 desc="" + [26] r6 seat=1 Sudden Death enemy hit dmg=10 php=0 ehp=266 roll=17/0 desc="" + [27] r6 seat=0 Sudden Death player crit dmg=28 php=115 ehp=238 roll=20/0 desc="" + [28] r7 seat=0 Sudden Death player hit dmg=14 php=115 ehp=224 roll=9/0 desc="" + [29] r7 seat=0 Sudden Death enemy hit dmg=10 php=105 ehp=224 roll=15/0 desc="" + [30] r8 seat=0 Sudden Death player hit dmg=14 php=105 ehp=210 roll=3/0 desc="" + [31] r8 seat=0 Sudden Death enemy hit dmg=9 php=96 ehp=210 roll=18/0 desc="" + [32] r9 seat=0 Sudden Death enemy hit dmg=8 php=88 ehp=210 roll=18/0 desc="" + [33] r9 seat=0 Sudden Death player hit dmg=12 php=88 ehp=198 roll=3/0 desc="" + [34] r10 seat=0 Sudden Death player hit dmg=12 php=88 ehp=186 roll=17/0 desc="" + [35] r10 seat=0 Sudden Death enemy hit dmg=9 php=79 ehp=186 roll=10/0 desc="" + [36] r10 seat=0 exhaust system timeout dmg=0 php=79 ehp=186 roll=0/0 desc="" + +=== duo/glass falls early seed=1 === +result: won=false timedOut=false rounds=7 survivors=false + enemy: start=172 entry=172 end=64 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player hit dmg=15 php=12 ehp=157 roll=5/0 desc="" + [01] r1 seat=0 Opening player hit dmg=10 php=160 ehp=147 roll=5/0 desc="" + [02] r1 seat=1 Opening enemy hit dmg=22 php=0 ehp=147 roll=11/0 desc="" + [03] r1 seat=0 Opening enemy hit dmg=15 php=145 ehp=147 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=17 php=128 ehp=147 roll=19/0 desc="" + [05] r1 seat=0 Opening environment environmental dmg=5 php=123 ehp=147 roll=0/0 desc="" + [06] r2 seat=0 Clash player hit dmg=11 php=123 ehp=136 roll=18/0 desc="" + [07] r2 seat=0 Clash enemy crit dmg=46 php=77 ehp=136 roll=20/0 desc="" + [08] r3 seat=0 Clash player hit dmg=10 php=77 ehp=126 roll=8/0 desc="" + [09] r3 seat=0 Clash enemy hit dmg=20 php=57 ehp=126 roll=19/0 desc="" + [10] r4 seat=0 Decisive enemy hit dmg=22 php=35 ehp=126 roll=13/0 desc="" + [11] r4 seat=0 Decisive player hit dmg=14 php=35 ehp=112 roll=7/0 desc="" + [12] r5 seat=0 Sudden Death enemy miss dmg=0 php=35 ehp=112 roll=1/0 desc="fumble" + [13] r5 seat=0 Sudden Death player crit dmg=32 php=35 ehp=80 roll=20/0 desc="" + [14] r6 seat=0 Sudden Death player hit dmg=16 php=35 ehp=64 roll=3/0 desc="" + [15] r6 seat=0 Sudden Death enemy hit dmg=27 php=8 ehp=64 roll=19/0 desc="" + [16] r7 seat=0 Sudden Death enemy block dmg=12 php=0 ehp=64 roll=18/0 desc="" + +=== duo/glass falls early seed=2 === +result: won=false timedOut=false rounds=7 survivors=false + enemy: start=172 entry=172 end=95 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player hit dmg=15 php=12 ehp=157 roll=12/0 desc="" + [01] r1 seat=0 Opening player hit dmg=10 php=160 ehp=147 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=40 php=120 ehp=147 roll=20/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=22 php=0 ehp=147 roll=12/0 desc="" + [04] r2 seat=0 Clash player hit dmg=12 php=120 ehp=135 roll=4/0 desc="" + [05] r2 seat=0 Clash enemy hit dmg=23 php=97 ehp=135 roll=2/0 desc="" + [06] r3 seat=0 Clash player hit dmg=11 php=97 ehp=124 roll=2/0 desc="" + [07] r3 seat=0 Clash enemy hit dmg=23 php=74 ehp=124 roll=17/0 desc="" + [08] r4 seat=0 Decisive enemy hit dmg=21 php=53 ehp=124 roll=7/0 desc="" + [09] r4 seat=0 Decisive player miss dmg=0 php=53 ehp=124 roll=1/0 desc="fumble" + [10] r5 seat=0 Sudden Death player hit dmg=13 php=53 ehp=111 roll=5/0 desc="" + [11] r5 seat=0 Sudden Death enemy hit dmg=25 php=28 ehp=111 roll=15/0 desc="" + [12] r6 seat=0 Sudden Death player hit dmg=16 php=28 ehp=95 roll=2/0 desc="" + [13] r6 seat=0 Sudden Death enemy hit dmg=25 php=3 ehp=95 roll=16/0 desc="" + [14] r7 seat=0 Sudden Death enemy hit dmg=23 php=0 ehp=95 roll=9/0 desc="" + +=== duo/glass falls early seed=3 === +result: won=false timedOut=false rounds=7 survivors=false + enemy: start=172 entry=172 end=101 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player hit dmg=12 php=12 ehp=160 roll=11/0 desc="" + [01] r1 seat=0 Opening enemy hit dmg=17 php=143 ehp=160 roll=18/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=19 php=124 ehp=160 roll=10/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=25 php=0 ehp=160 roll=13/0 desc="" + [04] r1 seat=0 Opening player hit dmg=9 php=124 ehp=151 roll=5/0 desc="" + [05] r2 seat=0 Clash player hit dmg=12 php=124 ehp=139 roll=16/0 desc="" + [06] r2 seat=0 Clash enemy hit dmg=20 php=104 ehp=139 roll=2/0 desc="" + [07] r2 seat=0 Clash environment environmental dmg=6 php=98 ehp=139 roll=0/0 desc="" + [08] r3 seat=0 Clash player hit dmg=11 php=98 ehp=128 roll=6/0 desc="" + [09] r3 seat=0 Clash enemy hit dmg=18 php=80 ehp=128 roll=4/0 desc="" + [10] r4 seat=0 Clash player hit dmg=13 php=80 ehp=115 roll=12/0 desc="" + [11] r4 seat=0 Clash enemy block dmg=9 php=71 ehp=115 roll=17/0 desc="" + [12] r5 seat=0 Decisive player miss dmg=0 php=71 ehp=115 roll=1/0 desc="fumble" + [13] r5 seat=0 Decisive enemy hit dmg=26 php=45 ehp=115 roll=7/0 desc="" + [14] r6 seat=0 Sudden Death player hit dmg=14 php=45 ehp=101 roll=16/0 desc="" + [15] r6 seat=0 Sudden Death enemy hit dmg=26 php=19 ehp=101 roll=9/0 desc="" + [16] r7 seat=0 Sudden Death enemy hit dmg=29 php=0 ehp=101 roll=11/0 desc="" + +=== duo/glass falls early seed=7 === +result: won=false timedOut=false rounds=7 survivors=false + enemy: start=172 entry=172 end=80 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player hit dmg=14 php=12 ehp=158 roll=10/0 desc="" + [01] r1 seat=0 Opening player hit dmg=11 php=160 ehp=147 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=15 php=145 ehp=147 roll=16/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=19 php=0 ehp=147 roll=19/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=16 php=129 ehp=147 roll=11/0 desc="" + [05] r2 seat=0 Clash enemy hit dmg=24 php=105 ehp=147 roll=13/0 desc="" + [06] r2 seat=0 Clash player hit dmg=13 php=105 ehp=134 roll=8/0 desc="" + [07] r3 seat=0 Clash player hit dmg=14 php=105 ehp=120 roll=11/0 desc="" + [08] r3 seat=0 Clash enemy crit dmg=46 php=59 ehp=120 roll=20/0 desc="" + [09] r4 seat=0 Clash player hit dmg=13 php=59 ehp=107 roll=16/0 desc="" + [10] r4 seat=0 Clash enemy hit dmg=20 php=39 ehp=107 roll=10/0 desc="" + [11] r5 seat=0 Decisive player hit dmg=12 php=39 ehp=95 roll=16/0 desc="" + [12] r5 seat=0 Decisive enemy miss dmg=0 php=39 ehp=95 roll=1/0 desc="fumble" + [13] r6 seat=0 Sudden Death enemy hit dmg=24 php=15 ehp=95 roll=9/0 desc="" + [14] r6 seat=0 Sudden Death player hit dmg=15 php=15 ehp=80 roll=18/0 desc="" + [15] r7 seat=0 Sudden Death enemy hit dmg=23 php=0 ehp=80 roll=8/0 desc="" + +=== duo/glass falls early seed=42 === +result: won=false timedOut=false rounds=6 survivors=false + enemy: start=172 entry=172 end=101 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player hit dmg=11 php=12 ehp=161 roll=13/0 desc="" + [01] r1 seat=0 Opening player hit dmg=9 php=160 ehp=152 roll=5/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=16 php=144 ehp=152 roll=15/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=23 php=0 ehp=152 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=18 php=126 ehp=152 roll=13/0 desc="" + [05] r2 seat=0 Clash enemy hit dmg=22 php=104 ehp=152 roll=2/0 desc="" + [06] r2 seat=0 Clash player block dmg=7 php=104 ehp=145 roll=9/0 desc="" + [07] r3 seat=0 Clash player hit dmg=11 php=104 ehp=134 roll=16/0 desc="" + [08] r3 seat=0 Clash enemy crit dmg=48 php=56 ehp=134 roll=20/0 desc="" + [09] r4 seat=0 Clash player hit dmg=14 php=56 ehp=120 roll=5/0 desc="" + [10] r4 seat=0 Clash enemy hit dmg=18 php=38 ehp=120 roll=13/0 desc="" + [11] r5 seat=0 Decisive player block dmg=6 php=38 ehp=114 roll=14/0 desc="" + [12] r5 seat=0 Decisive enemy hit dmg=20 php=18 ehp=114 roll=4/0 desc="" + [13] r6 seat=0 Sudden Death player hit dmg=13 php=18 ehp=101 roll=9/0 desc="" + [14] r6 seat=0 Sudden Death enemy crit dmg=58 php=0 ehp=101 roll=20/0 desc="" + +=== trio/even seed=1 === +result: won=true timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=10 + seat[0]: start=160 entry=160 end=58 + seat[1]: start=90 entry=90 end=0 + seat[2]: start=110 entry=110 end=0 + [00] r1 seat=1 Opening player hit dmg=12 php=90 ehp=333 roll=11/0 desc="" + [01] r1 seat=0 Opening player hit dmg=8 php=160 ehp=325 roll=3/0 desc="" + [02] r1 seat=2 Opening enemy hit dmg=6 php=104 ehp=325 roll=11/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=84 ehp=325 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=325 roll=19/0 desc="" + [05] r1 seat=0 Opening enemy hit dmg=5 php=149 ehp=325 roll=15/0 desc="" + [06] r1 seat=1 Opening enemy hit dmg=6 php=78 ehp=325 roll=5/0 desc="" + [07] r1 seat=2 Opening player hit dmg=6 php=104 ehp=319 roll=4/0 desc="" + [08] r2 seat=1 Clash player hit dmg=15 php=78 ehp=304 roll=4/0 desc="" + [09] r2 seat=0 Clash player hit dmg=12 php=149 ehp=292 roll=6/0 desc="" + [10] r2 seat=2 Clash player hit dmg=7 php=104 ehp=285 roll=15/0 desc="" + [11] r2 seat=2 Clash enemy hit dmg=7 php=97 ehp=285 roll=13/0 desc="" + [12] r2 seat=0 Clash enemy hit dmg=7 php=142 ehp=285 roll=8/0 desc="" + [13] r2 seat=0 Clash enemy hit dmg=6 php=136 ehp=285 roll=11/0 desc="" + [14] r2 seat=1 Clash enemy miss dmg=0 php=78 ehp=285 roll=1/0 desc="fumble" + [15] r2 seat=2 Clash enemy hit dmg=8 php=89 ehp=285 roll=7/0 desc="" + [16] r3 seat=1 Clash player hit dmg=18 php=78 ehp=267 roll=16/0 desc="" + [17] r3 seat=2 Clash player hit dmg=8 php=89 ehp=259 roll=11/0 desc="" + [18] r3 seat=0 Clash player hit dmg=9 php=136 ehp=250 roll=8/0 desc="" + [19] r3 seat=2 Clash enemy hit dmg=8 php=81 ehp=250 roll=9/0 desc="" + [20] r3 seat=1 Clash enemy hit dmg=8 php=70 ehp=250 roll=16/0 desc="" + [21] r3 seat=0 Clash enemy hit dmg=7 php=129 ehp=250 roll=3/0 desc="" + [22] r3 seat=1 Clash enemy hit dmg=8 php=62 ehp=250 roll=19/0 desc="" + [23] r3 seat=0 Clash enemy hit dmg=8 php=121 ehp=250 roll=12/0 desc="" + [24] r3 seat=0 Clash environment environmental dmg=3 php=118 ehp=250 roll=0/0 desc="" + [25] r4 seat=1 Clash player hit dmg=17 php=62 ehp=233 roll=14/0 desc="" + [26] r4 seat=2 Clash player hit dmg=8 php=81 ehp=225 roll=10/0 desc="" + [27] r4 seat=0 Clash player hit dmg=10 php=118 ehp=215 roll=11/0 desc="" + [28] r4 seat=1 Clash enemy miss dmg=0 php=62 ehp=215 roll=1/0 desc="fumble" + [29] r4 seat=0 Clash enemy hit dmg=7 php=111 ehp=215 roll=14/0 desc="" + [30] r4 seat=2 Clash enemy hit dmg=8 php=73 ehp=215 roll=13/0 desc="" + [31] r4 seat=2 Clash enemy hit dmg=7 php=66 ehp=215 roll=15/0 desc="" + [32] r4 seat=0 Clash enemy crit dmg=12 php=99 ehp=215 roll=20/0 desc="" + [33] r5 seat=1 Decisive player hit dmg=20 php=62 ehp=195 roll=13/0 desc="" + [34] r5 seat=0 Decisive enemy hit dmg=7 php=92 ehp=195 roll=9/0 desc="" + [35] r5 seat=2 Decisive enemy hit dmg=9 php=57 ehp=195 roll=5/0 desc="" + [36] r5 seat=0 Decisive enemy hit dmg=9 php=83 ehp=195 roll=12/0 desc="" + [37] r5 seat=0 Decisive enemy hit dmg=7 php=76 ehp=195 roll=17/0 desc="" + [38] r5 seat=2 Decisive enemy hit dmg=9 php=48 ehp=195 roll=7/0 desc="" + [39] r5 seat=2 Decisive player hit dmg=8 php=48 ehp=187 roll=8/0 desc="" + [40] r5 seat=0 Decisive player hit dmg=11 php=76 ehp=176 roll=10/0 desc="" + [41] r6 seat=1 Sudden Death player hit dmg=19 php=62 ehp=157 roll=18/0 desc="" + [42] r6 seat=0 Sudden Death player hit dmg=14 php=76 ehp=143 roll=18/0 desc="" + [43] r6 seat=1 Sudden Death enemy hit dmg=9 php=53 ehp=143 roll=13/0 desc="" + [44] r6 seat=0 Sudden Death enemy miss dmg=0 php=76 ehp=143 roll=1/0 desc="fumble" + [45] r6 seat=1 Sudden Death enemy hit dmg=9 php=44 ehp=143 roll=2/0 desc="" + [46] r6 seat=1 Sudden Death enemy hit dmg=10 php=34 ehp=143 roll=2/0 desc="" + [47] r6 seat=1 Sudden Death enemy hit dmg=11 php=23 ehp=143 roll=11/0 desc="" + [48] r6 seat=2 Sudden Death player hit dmg=11 php=48 ehp=132 roll=11/0 desc="" + [49] r7 seat=1 Sudden Death player hit dmg=18 php=23 ehp=114 roll=3/0 desc="" + [50] r7 seat=2 Sudden Death player hit dmg=9 php=48 ehp=105 roll=4/0 desc="" + [51] r7 seat=0 Sudden Death player hit dmg=14 php=76 ehp=91 roll=15/0 desc="" + [52] r7 seat=2 Sudden Death enemy hit dmg=9 php=39 ehp=91 roll=11/0 desc="" + [53] r7 seat=2 Sudden Death enemy crit dmg=20 php=19 ehp=91 roll=20/0 desc="" + [54] r7 seat=1 Sudden Death enemy hit dmg=11 php=12 ehp=91 roll=5/0 desc="" + [55] r7 seat=2 Sudden Death enemy hit dmg=9 php=10 ehp=91 roll=5/0 desc="" + [56] r7 seat=2 Sudden Death enemy hit dmg=10 php=0 ehp=91 roll=2/0 desc="" + [57] r8 seat=1 Sudden Death player hit dmg=19 php=12 ehp=72 roll=19/0 desc="" + [58] r8 seat=0 Sudden Death enemy hit dmg=9 php=67 ehp=72 roll=13/0 desc="" + [59] r8 seat=1 Sudden Death enemy miss dmg=0 php=12 ehp=72 roll=1/0 desc="fumble" + [60] r8 seat=0 Sudden Death player hit dmg=15 php=67 ehp=57 roll=6/0 desc="" + [61] r9 seat=1 Sudden Death player hit dmg=23 php=12 ehp=34 roll=3/0 desc="" + [62] r9 seat=1 Sudden Death enemy hit dmg=11 php=1 ehp=34 roll=14/0 desc="" + [63] r9 seat=1 Sudden Death enemy hit dmg=9 php=0 ehp=34 roll=17/0 desc="" + [64] r9 seat=0 Sudden Death player hit dmg=12 php=67 ehp=22 roll=4/0 desc="" + [65] r10 seat=0 Sudden Death player hit dmg=12 php=67 ehp=10 roll=9/0 desc="" + [66] r10 seat=0 Sudden Death enemy hit dmg=9 php=58 ehp=10 roll=9/0 desc="" + [67] r10 seat=0 exhaust system timeout dmg=0 php=58 ehp=10 roll=0/0 desc="" + +=== trio/even seed=2 === +result: won=false timedOut=true rounds=8 survivors=true + enemy: start=345 entry=345 end=76 + seat[0]: start=160 entry=160 end=71 + seat[1]: start=90 entry=90 end=0 + seat[2]: start=110 entry=110 end=1 + [00] r1 seat=1 Opening player hit dmg=15 php=90 ehp=330 roll=16/0 desc="" + [01] r1 seat=2 Opening player hit dmg=7 php=110 ehp=323 roll=7/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=14 php=146 ehp=323 roll=20/0 desc="" + [03] r1 seat=0 Opening enemy hit dmg=6 php=140 ehp=323 roll=12/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=7 php=133 ehp=323 roll=18/0 desc="" + [05] r1 seat=2 Opening enemy hit dmg=6 php=104 ehp=323 roll=7/0 desc="" + [06] r1 seat=2 Opening enemy hit dmg=7 php=97 ehp=323 roll=2/0 desc="" + [07] r1 seat=0 Opening player hit dmg=10 php=133 ehp=313 roll=15/0 desc="" + [08] r2 seat=1 Clash player hit dmg=17 php=90 ehp=296 roll=3/0 desc="" + [09] r2 seat=2 Clash player hit dmg=9 php=97 ehp=287 roll=16/0 desc="" + [10] r2 seat=0 Clash enemy hit dmg=7 php=126 ehp=287 roll=12/0 desc="" + [11] r2 seat=1 Clash enemy hit dmg=9 php=81 ehp=287 roll=13/0 desc="" + [12] r2 seat=0 Clash enemy hit dmg=7 php=119 ehp=287 roll=5/0 desc="" + [13] r2 seat=2 Clash enemy hit dmg=7 php=90 ehp=287 roll=17/0 desc="" + [14] r2 seat=0 Clash enemy hit dmg=7 php=112 ehp=287 roll=19/0 desc="" + [15] r2 seat=0 Clash player hit dmg=12 php=112 ehp=275 roll=2/0 desc="" + [16] r3 seat=2 Clash player hit dmg=7 php=90 ehp=268 roll=9/0 desc="" + [17] r3 seat=1 Clash player hit dmg=15 php=81 ehp=253 roll=15/0 desc="" + [18] r3 seat=1 Clash enemy hit dmg=8 php=73 ehp=253 roll=2/0 desc="" + [19] r3 seat=0 Clash enemy hit dmg=7 php=105 ehp=253 roll=7/0 desc="" + [20] r3 seat=1 Clash enemy hit dmg=8 php=65 ehp=253 roll=3/0 desc="" + [21] r3 seat=1 Clash enemy hit dmg=9 php=56 ehp=253 roll=8/0 desc="" + [22] r3 seat=2 Clash enemy hit dmg=8 php=82 ehp=253 roll=18/0 desc="" + [23] r3 seat=0 Clash player hit dmg=11 php=105 ehp=242 roll=19/0 desc="" + [24] r3 seat=2 Clash environment environmental dmg=5 php=77 ehp=242 roll=0/0 desc="" + [25] r4 seat=2 Decisive player hit dmg=8 php=77 ehp=234 roll=18/0 desc="" + [26] r4 seat=1 Decisive player hit dmg=16 php=56 ehp=218 roll=14/0 desc="" + [27] r4 seat=0 Decisive player hit dmg=12 php=105 ehp=206 roll=8/0 desc="" + [28] r4 seat=0 Decisive enemy hit dmg=7 php=98 ehp=206 roll=11/0 desc="" + [29] r4 seat=2 Decisive enemy hit dmg=9 php=68 ehp=206 roll=10/0 desc="" + [30] r4 seat=2 Decisive enemy block dmg=3 php=65 ehp=206 roll=15/0 desc="" + [31] r4 seat=2 Decisive enemy hit dmg=8 php=57 ehp=206 roll=11/0 desc="" + [32] r4 seat=1 Decisive enemy hit dmg=8 php=48 ehp=206 roll=2/0 desc="" + [33] r5 seat=2 Sudden Death player hit dmg=10 php=57 ehp=196 roll=7/0 desc="" + [34] r5 seat=1 Sudden Death player hit dmg=19 php=48 ehp=177 roll=19/0 desc="" + [35] r5 seat=0 Sudden Death player hit dmg=14 php=98 ehp=163 roll=15/0 desc="" + [36] r5 seat=1 Sudden Death enemy hit dmg=11 php=37 ehp=163 roll=10/0 desc="" + [37] r5 seat=1 Sudden Death enemy hit dmg=11 php=26 ehp=163 roll=5/0 desc="" + [38] r5 seat=1 Sudden Death enemy hit dmg=10 php=16 ehp=163 roll=7/0 desc="" + [39] r5 seat=2 Sudden Death enemy hit dmg=8 php=49 ehp=163 roll=5/0 desc="" + [40] r5 seat=2 Sudden Death enemy hit dmg=10 php=39 ehp=163 roll=6/0 desc="" + [41] r6 seat=1 Sudden Death player hit dmg=20 php=16 ehp=143 roll=4/0 desc="" + [42] r6 seat=2 Sudden Death player hit dmg=11 php=39 ehp=132 roll=12/0 desc="" + [43] r6 seat=0 Sudden Death player hit dmg=11 php=98 ehp=121 roll=12/0 desc="" + [44] r6 seat=1 Sudden Death enemy hit dmg=10 php=6 ehp=121 roll=10/0 desc="" + [45] r6 seat=1 Sudden Death enemy hit dmg=8 php=0 ehp=121 roll=10/0 desc="" + [46] r6 seat=2 Sudden Death enemy block dmg=4 php=35 ehp=121 roll=13/0 desc="" + [47] r6 seat=0 Sudden Death enemy hit dmg=9 php=89 ehp=121 roll=14/0 desc="" + [48] r6 seat=0 Sudden Death enemy hit dmg=8 php=81 ehp=121 roll=6/0 desc="" + [49] r7 seat=2 Sudden Death player hit dmg=10 php=35 ehp=111 roll=7/0 desc="" + [50] r7 seat=0 Sudden Death player hit dmg=11 php=81 ehp=100 roll=4/0 desc="" + [51] r7 seat=2 Sudden Death enemy hit dmg=8 php=27 ehp=100 roll=2/0 desc="" + [52] r7 seat=0 Sudden Death enemy hit dmg=10 php=71 ehp=100 roll=13/0 desc="" + [53] r8 seat=2 Sudden Death player hit dmg=11 php=27 ehp=89 roll=18/0 desc="" + [54] r8 seat=0 Sudden Death player hit dmg=13 php=71 ehp=76 roll=4/0 desc="" + [55] r8 seat=2 Sudden Death enemy hit dmg=9 php=18 ehp=76 roll=17/0 desc="" + [56] r8 seat=2 Sudden Death enemy hit dmg=9 php=9 ehp=76 roll=12/0 desc="" + [57] r8 seat=2 Sudden Death enemy hit dmg=8 php=1 ehp=76 roll=19/0 desc="" + [58] r8 seat=0 exhaust system timeout dmg=0 php=71 ehp=76 roll=0/0 desc="" + +=== trio/even seed=3 === +result: won=true timedOut=false rounds=10 survivors=true + enemy: start=345 entry=345 end=0 + seat[0]: start=160 entry=160 end=32 + seat[1]: start=90 entry=90 end=0 + seat[2]: start=110 entry=110 end=0 + [00] r1 seat=1 Opening player hit dmg=15 php=90 ehp=330 roll=8/0 desc="" + [01] r1 seat=2 Opening player hit dmg=6 php=110 ehp=324 roll=18/0 desc="" + [02] r1 seat=0 Opening player hit dmg=9 php=160 ehp=315 roll=8/0 desc="" + [03] r1 seat=0 Opening enemy hit dmg=5 php=155 ehp=315 roll=16/0 desc="" + [04] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=315 roll=8/0 desc="" + [05] r1 seat=1 Opening enemy hit dmg=6 php=77 ehp=315 roll=15/0 desc="" + [06] r1 seat=0 Opening enemy hit dmg=6 php=149 ehp=315 roll=12/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=6 php=143 ehp=315 roll=13/0 desc="" + [08] r1 seat=1 Opening environment environmental dmg=6 php=71 ehp=315 roll=0/0 desc="" + [09] r2 seat=1 Clash player hit dmg=15 php=71 ehp=300 roll=4/0 desc="" + [10] r2 seat=2 Clash enemy hit dmg=7 php=103 ehp=300 roll=12/0 desc="" + [11] r2 seat=1 Clash enemy hit dmg=9 php=62 ehp=300 roll=14/0 desc="" + [12] r2 seat=1 Clash enemy block dmg=3 php=59 ehp=300 roll=17/0 desc="" + [13] r2 seat=0 Clash enemy hit dmg=8 php=135 ehp=300 roll=12/0 desc="" + [14] r2 seat=2 Clash enemy miss dmg=0 php=103 ehp=300 roll=1/0 desc="fumble" + [15] r2 seat=0 Clash player hit dmg=12 php=135 ehp=288 roll=7/0 desc="" + [16] r2 seat=2 Clash player hit dmg=7 php=103 ehp=281 roll=8/0 desc="" + [17] r3 seat=2 Clash player hit dmg=8 php=103 ehp=273 roll=19/0 desc="" + [18] r3 seat=1 Clash player hit dmg=19 php=59 ehp=254 roll=18/0 desc="" + [19] r3 seat=0 Clash player hit dmg=12 php=135 ehp=242 roll=11/0 desc="" + [20] r3 seat=2 Clash enemy hit dmg=8 php=95 ehp=242 roll=5/0 desc="" + [21] r3 seat=2 Clash enemy hit dmg=7 php=88 ehp=242 roll=7/0 desc="" + [22] r3 seat=0 Clash enemy hit dmg=8 php=127 ehp=242 roll=4/0 desc="" + [23] r3 seat=2 Clash enemy hit dmg=7 php=81 ehp=242 roll=16/0 desc="" + [24] r3 seat=2 Clash enemy hit dmg=7 php=74 ehp=242 roll=8/0 desc="" + [25] r3 seat=0 Clash environment environmental dmg=3 php=124 ehp=242 roll=0/0 desc="" + [26] r4 seat=1 Clash player hit dmg=17 php=59 ehp=225 roll=5/0 desc="" + [27] r4 seat=0 Clash player hit dmg=10 php=124 ehp=215 roll=14/0 desc="" + [28] r4 seat=2 Clash enemy hit dmg=7 php=67 ehp=215 roll=10/0 desc="" + [29] r4 seat=2 Clash enemy hit dmg=8 php=59 ehp=215 roll=4/0 desc="" + [30] r4 seat=1 Clash enemy hit dmg=9 php=50 ehp=215 roll=4/0 desc="" + [31] r4 seat=0 Clash enemy hit dmg=8 php=116 ehp=215 roll=12/0 desc="" + [32] r4 seat=0 Clash enemy hit dmg=8 php=108 ehp=215 roll=11/0 desc="" + [33] r4 seat=2 Clash player hit dmg=9 php=59 ehp=206 roll=3/0 desc="" + [34] r5 seat=1 Decisive player hit dmg=20 php=50 ehp=186 roll=5/0 desc="" + [35] r5 seat=2 Decisive player hit dmg=8 php=59 ehp=178 roll=10/0 desc="" + [36] r5 seat=0 Decisive player hit dmg=11 php=108 ehp=167 roll=17/0 desc="" + [37] r5 seat=2 Decisive enemy hit dmg=9 php=50 ehp=167 roll=17/0 desc="" + [38] r5 seat=2 Decisive enemy hit dmg=9 php=41 ehp=167 roll=13/0 desc="" + [39] r5 seat=2 Decisive enemy hit dmg=8 php=33 ehp=167 roll=8/0 desc="" + [40] r5 seat=1 Decisive enemy hit dmg=7 php=43 ehp=167 roll=6/0 desc="" + [41] r5 seat=0 Decisive enemy hit dmg=7 php=101 ehp=167 roll=16/0 desc="" + [42] r6 seat=1 Sudden Death player crit dmg=44 php=43 ehp=123 roll=20/0 desc="" + [43] r6 seat=2 Sudden Death player hit dmg=11 php=33 ehp=112 roll=14/0 desc="" + [44] r6 seat=1 Sudden Death enemy hit dmg=8 php=35 ehp=112 roll=11/0 desc="" + [45] r6 seat=1 Sudden Death enemy hit dmg=10 php=25 ehp=112 roll=9/0 desc="" + [46] r6 seat=0 Sudden Death enemy hit dmg=10 php=91 ehp=112 roll=5/0 desc="" + [47] r6 seat=0 Sudden Death enemy hit dmg=10 php=81 ehp=112 roll=14/0 desc="" + [48] r6 seat=0 Sudden Death enemy hit dmg=8 php=73 ehp=112 roll=7/0 desc="" + [49] r6 seat=0 Sudden Death player hit dmg=11 php=73 ehp=101 roll=11/0 desc="" + [50] r6 seat=1 Sudden Death environment environmental dmg=4 php=21 ehp=101 roll=0/0 desc="" + [51] r7 seat=1 Sudden Death player hit dmg=20 php=21 ehp=81 roll=15/0 desc="" + [52] r7 seat=0 Sudden Death player hit dmg=15 php=73 ehp=66 roll=7/0 desc="" + [53] r7 seat=2 Sudden Death player hit dmg=9 php=33 ehp=57 roll=4/0 desc="" + [54] r7 seat=1 Sudden Death enemy hit dmg=9 php=12 ehp=57 roll=5/0 desc="" + [55] r7 seat=1 Sudden Death enemy hit dmg=9 php=3 ehp=57 roll=15/0 desc="" + [56] r7 seat=2 Sudden Death enemy hit dmg=10 php=23 ehp=57 roll=14/0 desc="" + [57] r7 seat=1 Sudden Death enemy hit dmg=11 php=0 ehp=57 roll=19/0 desc="" + [58] r7 seat=2 Sudden Death enemy hit dmg=9 php=14 ehp=57 roll=15/0 desc="" + [59] r8 seat=2 Sudden Death player hit dmg=11 php=14 ehp=46 roll=14/0 desc="" + [60] r8 seat=0 Sudden Death player hit dmg=11 php=73 ehp=35 roll=17/0 desc="" + [61] r8 seat=0 Sudden Death enemy hit dmg=8 php=65 ehp=35 roll=15/0 desc="" + [62] r8 seat=2 Sudden Death enemy hit dmg=8 php=6 ehp=35 roll=16/0 desc="" + [63] r8 seat=0 Sudden Death enemy block dmg=4 php=61 ehp=35 roll=9/0 desc="" + [64] r9 seat=2 Sudden Death player hit dmg=9 php=6 ehp=26 roll=16/0 desc="" + [65] r9 seat=0 Sudden Death player hit dmg=14 php=61 ehp=12 roll=6/0 desc="" + [66] r9 seat=0 Sudden Death enemy hit dmg=9 php=52 ehp=12 roll=3/0 desc="" + [67] r9 seat=2 Sudden Death enemy hit dmg=8 php=0 ehp=12 roll=5/0 desc="" + [68] r9 seat=0 Sudden Death enemy hit dmg=10 php=42 ehp=12 roll=10/0 desc="" + [69] r10 seat=0 Sudden Death enemy hit dmg=10 php=32 ehp=12 roll=5/0 desc="" + [70] r10 seat=0 Sudden Death player crit dmg=26 php=32 ehp=0 roll=20/0 desc="" + +=== trio/even seed=7 === +result: won=true timedOut=false rounds=10 survivors=true + enemy: start=345 entry=345 end=0 + seat[0]: start=160 entry=160 end=9 + seat[1]: start=90 entry=90 end=12 + seat[2]: start=110 entry=110 end=0 + [00] r1 seat=1 Opening player hit dmg=13 php=90 ehp=332 roll=6/0 desc="" + [01] r1 seat=2 Opening player hit dmg=7 php=110 ehp=325 roll=16/0 desc="" + [02] r1 seat=0 Opening player hit dmg=8 php=160 ehp=317 roll=16/0 desc="" + [03] r1 seat=2 Opening enemy block dmg=3 php=107 ehp=317 roll=11/0 desc="" + [04] r1 seat=2 Opening enemy hit dmg=5 php=102 ehp=317 roll=14/0 desc="" + [05] r1 seat=2 Opening enemy hit dmg=7 php=95 ehp=317 roll=13/0 desc="" + [06] r1 seat=2 Opening enemy hit dmg=7 php=88 ehp=317 roll=18/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=317 roll=9/0 desc="" + [08] r1 seat=1 Opening environment environmental dmg=3 php=87 ehp=317 roll=0/0 desc="" + [09] r2 seat=1 Clash player hit dmg=15 php=87 ehp=302 roll=17/0 desc="" + [10] r2 seat=2 Clash player hit dmg=7 php=88 ehp=295 roll=8/0 desc="" + [11] r2 seat=1 Clash enemy hit dmg=9 php=78 ehp=295 roll=9/0 desc="" + [12] r2 seat=0 Clash enemy hit dmg=7 php=147 ehp=295 roll=10/0 desc="" + [13] r2 seat=0 Clash enemy hit dmg=7 php=140 ehp=295 roll=6/0 desc="" + [14] r2 seat=1 Clash enemy hit dmg=8 php=70 ehp=295 roll=16/0 desc="" + [15] r2 seat=0 Clash enemy hit dmg=8 php=132 ehp=295 roll=11/0 desc="" + [16] r2 seat=0 Clash player hit dmg=10 php=132 ehp=285 roll=18/0 desc="" + [17] r3 seat=2 Clash player hit dmg=9 php=88 ehp=276 roll=13/0 desc="" + [18] r3 seat=2 Clash enemy hit dmg=7 php=81 ehp=276 roll=8/0 desc="" + [19] r3 seat=1 Clash enemy hit dmg=7 php=63 ehp=276 roll=8/0 desc="" + [20] r3 seat=2 Clash enemy hit dmg=7 php=74 ehp=276 roll=10/0 desc="" + [21] r3 seat=0 Clash enemy hit dmg=8 php=124 ehp=276 roll=3/0 desc="" + [22] r3 seat=2 Clash enemy hit dmg=7 php=67 ehp=276 roll=10/0 desc="" + [23] r3 seat=0 Clash player hit dmg=11 php=124 ehp=265 roll=4/0 desc="" + [24] r3 seat=1 Clash player hit dmg=15 php=63 ehp=250 roll=6/0 desc="" + [25] r4 seat=1 Clash player crit dmg=30 php=63 ehp=220 roll=20/0 desc="" + [26] r4 seat=2 Clash player hit dmg=9 php=67 ehp=211 roll=13/0 desc="" + [27] r4 seat=1 Clash enemy crit dmg=14 php=49 ehp=211 roll=20/0 desc="" + [28] r4 seat=2 Clash enemy hit dmg=7 php=60 ehp=211 roll=2/0 desc="" + [29] r4 seat=2 Clash enemy miss dmg=0 php=60 ehp=211 roll=1/0 desc="fumble" + [30] r4 seat=0 Clash enemy hit dmg=8 php=116 ehp=211 roll=10/0 desc="" + [31] r4 seat=2 Clash enemy hit dmg=7 php=53 ehp=211 roll=17/0 desc="" + [32] r4 seat=0 Clash player hit dmg=9 php=116 ehp=202 roll=8/0 desc="" + [33] r5 seat=1 Decisive player hit dmg=16 php=49 ehp=186 roll=18/0 desc="" + [34] r5 seat=0 Decisive player hit dmg=13 php=116 ehp=173 roll=4/0 desc="" + [35] r5 seat=2 Decisive player hit dmg=9 php=53 ehp=164 roll=8/0 desc="" + [36] r5 seat=0 Decisive enemy hit dmg=9 php=107 ehp=164 roll=3/0 desc="" + [37] r5 seat=1 Decisive enemy hit dmg=7 php=42 ehp=164 roll=14/0 desc="" + [38] r5 seat=2 Decisive enemy hit dmg=7 php=46 ehp=164 roll=17/0 desc="" + [39] r5 seat=2 Decisive enemy block dmg=3 php=43 ehp=164 roll=7/0 desc="" + [40] r5 seat=2 Decisive enemy hit dmg=9 php=34 ehp=164 roll=17/0 desc="" + [41] r6 seat=2 Sudden Death player hit dmg=9 php=34 ehp=155 roll=13/0 desc="" + [42] r6 seat=1 Sudden Death player hit dmg=21 php=42 ehp=134 roll=5/0 desc="" + [43] r6 seat=0 Sudden Death enemy hit dmg=10 php=97 ehp=134 roll=12/0 desc="" + [44] r6 seat=0 Sudden Death enemy hit dmg=8 php=89 ehp=134 roll=6/0 desc="" + [45] r6 seat=0 Sudden Death enemy block dmg=4 php=85 ehp=134 roll=2/0 desc="" + [46] r6 seat=0 Sudden Death enemy hit dmg=8 php=77 ehp=134 roll=19/0 desc="" + [47] r6 seat=2 Sudden Death enemy hit dmg=8 php=26 ehp=134 roll=9/0 desc="" + [48] r6 seat=0 Sudden Death player hit dmg=13 php=77 ehp=121 roll=2/0 desc="" + [49] r7 seat=1 Sudden Death player hit dmg=22 php=42 ehp=99 roll=16/0 desc="" + [50] r7 seat=0 Sudden Death player hit dmg=12 php=77 ehp=87 roll=6/0 desc="" + [51] r7 seat=2 Sudden Death player hit dmg=11 php=26 ehp=76 roll=8/0 desc="" + [52] r7 seat=2 Sudden Death enemy hit dmg=8 php=18 ehp=76 roll=4/0 desc="" + [53] r7 seat=0 Sudden Death enemy hit dmg=10 php=67 ehp=76 roll=11/0 desc="" + [54] r7 seat=0 Sudden Death enemy hit dmg=10 php=57 ehp=76 roll=2/0 desc="" + [55] r7 seat=2 Sudden Death enemy crit dmg=16 php=2 ehp=76 roll=20/0 desc="" + [56] r7 seat=0 Sudden Death enemy hit dmg=10 php=47 ehp=76 roll=11/0 desc="" + [57] r8 seat=1 Sudden Death player hit dmg=19 php=42 ehp=57 roll=7/0 desc="" + [58] r8 seat=2 Sudden Death player hit dmg=11 php=2 ehp=46 roll=8/0 desc="" + [59] r8 seat=1 Sudden Death enemy hit dmg=9 php=33 ehp=46 roll=4/0 desc="" + [60] r8 seat=0 Sudden Death enemy hit dmg=10 php=37 ehp=46 roll=14/0 desc="" + [61] r8 seat=1 Sudden Death enemy hit dmg=10 php=23 ehp=46 roll=12/0 desc="" + [62] r8 seat=2 Sudden Death enemy hit dmg=10 php=0 ehp=46 roll=5/0 desc="" + [63] r8 seat=0 Sudden Death enemy hit dmg=9 php=28 ehp=46 roll=15/0 desc="" + [64] r8 seat=0 Sudden Death player hit dmg=12 php=28 ehp=34 roll=14/0 desc="" + [65] r9 seat=1 Sudden Death player hit dmg=23 php=23 ehp=11 roll=2/0 desc="" + [66] r9 seat=1 Sudden Death enemy hit dmg=11 php=12 ehp=11 roll=7/0 desc="" + [67] r9 seat=0 Sudden Death enemy hit dmg=10 php=18 ehp=11 roll=13/0 desc="" + [68] r9 seat=0 Sudden Death enemy hit dmg=9 php=9 ehp=11 roll=10/0 desc="" + [69] r9 seat=0 Sudden Death player miss dmg=0 php=9 ehp=11 roll=1/0 desc="fumble" + [70] r10 seat=1 Sudden Death player hit dmg=24 php=12 ehp=0 roll=5/0 desc="" + +=== trio/even seed=42 === +result: won=true timedOut=true rounds=8 survivors=true + enemy: start=345 entry=345 end=20 + seat[0]: start=160 entry=160 end=63 + seat[1]: start=90 entry=90 end=0 + seat[2]: start=110 entry=110 end=5 + [00] r1 seat=1 Opening player miss dmg=0 php=90 ehp=345 roll=1/0 desc="fumble" + [01] r1 seat=0 Opening player hit dmg=8 php=160 ehp=337 roll=17/0 desc="" + [02] r1 seat=2 Opening player hit dmg=6 php=110 ehp=331 roll=19/0 desc="" + [03] r1 seat=2 Opening enemy hit dmg=7 php=103 ehp=331 roll=18/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=7 php=153 ehp=331 roll=19/0 desc="" + [05] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=331 roll=2/0 desc="" + [06] r1 seat=1 Opening enemy hit dmg=7 php=76 ehp=331 roll=2/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=6 php=147 ehp=331 roll=2/0 desc="" + [08] r1 seat=2 Opening environment environmental dmg=6 php=97 ehp=331 roll=0/0 desc="" + [09] r2 seat=1 Clash player hit dmg=17 php=76 ehp=314 roll=10/0 desc="" + [10] r2 seat=0 Clash player hit dmg=10 php=147 ehp=304 roll=15/0 desc="" + [11] r2 seat=2 Clash player hit dmg=10 php=97 ehp=294 roll=16/0 desc="" + [12] r2 seat=0 Clash enemy block dmg=4 php=143 ehp=294 roll=5/0 desc="" + [13] r2 seat=1 Clash enemy hit dmg=7 php=69 ehp=294 roll=6/0 desc="" + [14] r2 seat=0 Clash enemy miss dmg=0 php=143 ehp=294 roll=1/0 desc="fumble" + [15] r2 seat=2 Clash enemy hit dmg=9 php=88 ehp=294 roll=15/0 desc="" + [16] r2 seat=1 Clash enemy hit dmg=7 php=62 ehp=294 roll=4/0 desc="" + [17] r3 seat=1 Clash player hit dmg=19 php=62 ehp=275 roll=6/0 desc="" + [18] r3 seat=0 Clash player hit dmg=10 php=143 ehp=265 roll=4/0 desc="" + [19] r3 seat=2 Clash player hit dmg=8 php=88 ehp=257 roll=15/0 desc="" + [20] r3 seat=2 Clash enemy hit dmg=7 php=81 ehp=257 roll=5/0 desc="" + [21] r3 seat=2 Clash enemy hit dmg=8 php=73 ehp=257 roll=10/0 desc="" + [22] r3 seat=1 Clash enemy miss dmg=0 php=62 ehp=257 roll=1/0 desc="fumble" + [23] r3 seat=0 Clash enemy hit dmg=7 php=136 ehp=257 roll=17/0 desc="" + [24] r3 seat=2 Clash enemy hit dmg=7 php=66 ehp=257 roll=15/0 desc="" + [25] r4 seat=2 Decisive player hit dmg=10 php=66 ehp=247 roll=4/0 desc="" + [26] r4 seat=1 Decisive player hit dmg=17 php=62 ehp=230 roll=17/0 desc="" + [27] r4 seat=0 Decisive enemy block dmg=3 php=133 ehp=230 roll=4/0 desc="" + [28] r4 seat=0 Decisive enemy hit dmg=7 php=126 ehp=230 roll=15/0 desc="" + [29] r4 seat=0 Decisive enemy hit dmg=8 php=118 ehp=230 roll=3/0 desc="" + [30] r4 seat=2 Decisive enemy hit dmg=8 php=58 ehp=230 roll=13/0 desc="" + [31] r4 seat=0 Decisive enemy hit dmg=8 php=110 ehp=230 roll=7/0 desc="" + [32] r4 seat=0 Decisive player hit dmg=10 php=110 ehp=220 roll=7/0 desc="" + [33] r5 seat=2 Sudden Death player hit dmg=10 php=58 ehp=210 roll=13/0 desc="" + [34] r5 seat=1 Sudden Death player hit dmg=18 php=62 ehp=192 roll=15/0 desc="" + [35] r5 seat=0 Sudden Death player hit dmg=12 php=110 ehp=180 roll=7/0 desc="" + [36] r5 seat=0 Sudden Death enemy hit dmg=8 php=102 ehp=180 roll=8/0 desc="" + [37] r5 seat=0 Sudden Death enemy hit dmg=8 php=94 ehp=180 roll=4/0 desc="" + [38] r5 seat=2 Sudden Death enemy miss dmg=0 php=58 ehp=180 roll=1/0 desc="fumble" + [39] r5 seat=1 Sudden Death enemy hit dmg=9 php=53 ehp=180 roll=3/0 desc="" + [40] r5 seat=2 Sudden Death enemy hit dmg=10 php=48 ehp=180 roll=16/0 desc="" + [41] r6 seat=1 Sudden Death player hit dmg=19 php=53 ehp=161 roll=16/0 desc="" + [42] r6 seat=0 Sudden Death player hit dmg=14 php=94 ehp=147 roll=6/0 desc="" + [43] r6 seat=2 Sudden Death player hit dmg=10 php=48 ehp=137 roll=7/0 desc="" + [44] r6 seat=1 Sudden Death enemy block dmg=5 php=48 ehp=137 roll=11/0 desc="" + [45] r6 seat=1 Sudden Death enemy hit dmg=11 php=37 ehp=137 roll=7/0 desc="" + [46] r6 seat=0 Sudden Death enemy block dmg=4 php=90 ehp=137 roll=4/0 desc="" + [47] r6 seat=2 Sudden Death enemy hit dmg=9 php=39 ehp=137 roll=4/0 desc="" + [48] r6 seat=0 Sudden Death enemy hit dmg=9 php=81 ehp=137 roll=9/0 desc="" + [49] r7 seat=1 Sudden Death player crit dmg=38 php=37 ehp=99 roll=20/0 desc="" + [50] r7 seat=0 Sudden Death player hit dmg=13 php=81 ehp=86 roll=9/0 desc="" + [51] r7 seat=2 Sudden Death player hit dmg=10 php=39 ehp=76 roll=7/0 desc="" + [52] r7 seat=2 Sudden Death enemy block dmg=5 php=34 ehp=76 roll=11/0 desc="" + [53] r7 seat=1 Sudden Death enemy hit dmg=10 php=27 ehp=76 roll=16/0 desc="" + [54] r7 seat=0 Sudden Death enemy hit dmg=10 php=71 ehp=76 roll=11/0 desc="" + [55] r7 seat=1 Sudden Death enemy hit dmg=9 php=18 ehp=76 roll=15/0 desc="" + [56] r7 seat=1 Sudden Death enemy hit dmg=10 php=8 ehp=76 roll=15/0 desc="" + [57] r8 seat=1 Sudden Death player hit dmg=24 php=8 ehp=52 roll=4/0 desc="" + [58] r8 seat=0 Sudden Death player hit dmg=12 php=71 ehp=40 roll=19/0 desc="" + [59] r8 seat=2 Sudden Death player crit dmg=20 php=34 ehp=20 roll=20/0 desc="" + [60] r8 seat=1 Sudden Death enemy hit dmg=11 php=0 ehp=20 roll=18/0 desc="" + [61] r8 seat=2 Sudden Death enemy hit dmg=10 php=24 ehp=20 roll=17/0 desc="" + [62] r8 seat=2 Sudden Death enemy hit dmg=10 php=14 ehp=20 roll=15/0 desc="" + [63] r8 seat=0 Sudden Death enemy hit dmg=8 php=63 ehp=20 roll=10/0 desc="" + [64] r8 seat=2 Sudden Death enemy hit dmg=9 php=5 ehp=20 roll=17/0 desc="" + [65] r8 seat=0 exhaust system timeout dmg=0 php=63 ehp=20 roll=0/0 desc="" + +=== trio/one weak seat seed=1 === +result: won=true timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=23 + seat[0]: start=160 entry=160 end=33 + seat[1]: start=90 entry=90 end=0 + seat[2]: start=70 entry=70 end=0 + [00] r1 seat=1 Opening player hit dmg=12 php=90 ehp=333 roll=11/0 desc="" + [01] r1 seat=0 Opening player hit dmg=8 php=160 ehp=325 roll=3/0 desc="" + [02] r1 seat=2 Opening enemy hit dmg=7 php=63 ehp=325 roll=11/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=84 ehp=325 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=325 roll=19/0 desc="" + [05] r1 seat=0 Opening enemy hit dmg=5 php=149 ehp=325 roll=15/0 desc="" + [06] r1 seat=1 Opening enemy hit dmg=6 php=78 ehp=325 roll=5/0 desc="" + [07] r1 seat=2 Opening player hit dmg=4 php=63 ehp=321 roll=4/0 desc="" + [08] r2 seat=1 Clash player hit dmg=15 php=78 ehp=306 roll=4/0 desc="" + [09] r2 seat=0 Clash player hit dmg=12 php=149 ehp=294 roll=6/0 desc="" + [10] r2 seat=2 Clash player hit dmg=5 php=63 ehp=289 roll=15/0 desc="" + [11] r2 seat=2 Clash enemy hit dmg=8 php=55 ehp=289 roll=13/0 desc="" + [12] r2 seat=0 Clash enemy hit dmg=7 php=142 ehp=289 roll=8/0 desc="" + [13] r2 seat=0 Clash enemy hit dmg=6 php=136 ehp=289 roll=11/0 desc="" + [14] r2 seat=1 Clash enemy miss dmg=0 php=78 ehp=289 roll=1/0 desc="fumble" + [15] r2 seat=2 Clash enemy hit dmg=9 php=46 ehp=289 roll=7/0 desc="" + [16] r3 seat=1 Clash player hit dmg=18 php=78 ehp=271 roll=16/0 desc="" + [17] r3 seat=2 Clash player hit dmg=5 php=46 ehp=266 roll=11/0 desc="" + [18] r3 seat=0 Clash player hit dmg=9 php=136 ehp=257 roll=8/0 desc="" + [19] r3 seat=2 Clash enemy hit dmg=9 php=37 ehp=257 roll=9/0 desc="" + [20] r3 seat=1 Clash enemy hit dmg=8 php=70 ehp=257 roll=16/0 desc="" + [21] r3 seat=0 Clash enemy hit dmg=7 php=129 ehp=257 roll=3/0 desc="" + [22] r3 seat=1 Clash enemy hit dmg=8 php=62 ehp=257 roll=19/0 desc="" + [23] r3 seat=0 Clash enemy hit dmg=8 php=121 ehp=257 roll=12/0 desc="" + [24] r3 seat=0 Clash environment environmental dmg=3 php=118 ehp=257 roll=0/0 desc="" + [25] r4 seat=1 Clash player hit dmg=17 php=62 ehp=240 roll=14/0 desc="" + [26] r4 seat=2 Clash player hit dmg=6 php=37 ehp=234 roll=10/0 desc="" + [27] r4 seat=0 Clash player hit dmg=10 php=118 ehp=224 roll=11/0 desc="" + [28] r4 seat=1 Clash enemy miss dmg=0 php=62 ehp=224 roll=1/0 desc="fumble" + [29] r4 seat=0 Clash enemy hit dmg=7 php=111 ehp=224 roll=14/0 desc="" + [30] r4 seat=2 Clash enemy hit dmg=9 php=28 ehp=224 roll=13/0 desc="" + [31] r4 seat=2 Clash enemy hit dmg=8 php=20 ehp=224 roll=15/0 desc="" + [32] r4 seat=0 Clash enemy crit dmg=12 php=99 ehp=224 roll=20/0 desc="" + [33] r5 seat=1 Decisive player hit dmg=20 php=62 ehp=204 roll=13/0 desc="" + [34] r5 seat=0 Decisive enemy hit dmg=7 php=92 ehp=204 roll=9/0 desc="" + [35] r5 seat=2 Decisive enemy hit dmg=10 php=10 ehp=204 roll=5/0 desc="" + [36] r5 seat=0 Decisive enemy hit dmg=9 php=83 ehp=204 roll=12/0 desc="" + [37] r5 seat=0 Decisive enemy hit dmg=7 php=76 ehp=204 roll=17/0 desc="" + [38] r5 seat=2 Decisive enemy hit dmg=10 php=0 ehp=204 roll=7/0 desc="" + [39] r5 seat=0 Decisive player hit dmg=10 php=76 ehp=194 roll=8/0 desc="" + [40] r6 seat=1 Sudden Death player hit dmg=22 php=62 ehp=172 roll=16/0 desc="" + [41] r6 seat=0 Sudden Death player hit dmg=12 php=76 ehp=160 roll=4/0 desc="" + [42] r6 seat=1 Sudden Death enemy block dmg=5 php=57 ehp=160 roll=18/0 desc="" + [43] r6 seat=1 Sudden Death enemy hit dmg=11 php=46 ehp=160 roll=6/0 desc="" + [44] r6 seat=0 Sudden Death enemy hit dmg=8 php=68 ehp=160 roll=10/0 desc="" + [45] r7 seat=1 Sudden Death player hit dmg=21 php=46 ehp=139 roll=7/0 desc="" + [46] r7 seat=0 Sudden Death player hit dmg=13 php=68 ehp=126 roll=12/0 desc="" + [47] r7 seat=1 Sudden Death enemy hit dmg=11 php=35 ehp=126 roll=3/0 desc="" + [48] r7 seat=1 Sudden Death enemy crit dmg=8 php=27 ehp=126 roll=20/0 desc="" + [49] r8 seat=1 Sudden Death player hit dmg=19 php=27 ehp=107 roll=7/0 desc="" + [50] r8 seat=1 Sudden Death enemy hit dmg=10 php=17 ehp=107 roll=15/0 desc="" + [51] r8 seat=0 Sudden Death enemy hit dmg=9 php=59 ehp=107 roll=7/0 desc="" + [52] r8 seat=0 Sudden Death player hit dmg=12 php=59 ehp=95 roll=19/0 desc="" + [53] r9 seat=1 Sudden Death player hit dmg=22 php=17 ehp=73 roll=2/0 desc="" + [54] r9 seat=0 Sudden Death enemy hit dmg=9 php=50 ehp=73 roll=18/0 desc="" + [55] r9 seat=0 Sudden Death enemy hit dmg=9 php=41 ehp=73 roll=3/0 desc="" + [56] r9 seat=1 Sudden Death enemy hit dmg=10 php=7 ehp=73 roll=11/0 desc="" + [57] r9 seat=0 Sudden Death player hit dmg=15 php=41 ehp=58 roll=6/0 desc="" + [58] r10 seat=1 Sudden Death player hit dmg=23 php=7 ehp=35 roll=3/0 desc="" + [59] r10 seat=1 Sudden Death enemy hit dmg=11 php=0 ehp=35 roll=14/0 desc="" + [60] r10 seat=0 Sudden Death enemy hit dmg=8 php=33 ehp=35 roll=17/0 desc="" + [61] r10 seat=0 Sudden Death player hit dmg=12 php=33 ehp=23 roll=4/0 desc="" + [62] r10 seat=0 exhaust system timeout dmg=0 php=33 ehp=23 roll=0/0 desc="" + +=== trio/one weak seat seed=2 === +result: won=false timedOut=true rounds=8 survivors=true + enemy: start=345 entry=345 end=115 + seat[0]: start=160 entry=160 end=74 + seat[1]: start=90 entry=90 end=25 + seat[2]: start=70 entry=70 end=0 + [00] r1 seat=1 Opening player hit dmg=15 php=90 ehp=330 roll=16/0 desc="" + [01] r1 seat=2 Opening player hit dmg=5 php=70 ehp=325 roll=7/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=14 php=146 ehp=325 roll=20/0 desc="" + [03] r1 seat=0 Opening enemy hit dmg=6 php=140 ehp=325 roll=12/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=7 php=133 ehp=325 roll=18/0 desc="" + [05] r1 seat=2 Opening enemy hit dmg=7 php=63 ehp=325 roll=7/0 desc="" + [06] r1 seat=2 Opening enemy hit dmg=7 php=56 ehp=325 roll=2/0 desc="" + [07] r1 seat=0 Opening player hit dmg=10 php=133 ehp=315 roll=15/0 desc="" + [08] r2 seat=1 Clash player hit dmg=17 php=90 ehp=298 roll=3/0 desc="" + [09] r2 seat=0 Clash enemy hit dmg=8 php=125 ehp=298 roll=16/0 desc="" + [10] r2 seat=1 Clash enemy miss dmg=0 php=90 ehp=298 roll=1/0 desc="fumble" + [11] r2 seat=0 Clash enemy hit dmg=8 php=117 ehp=298 roll=9/0 desc="" + [12] r2 seat=2 Clash enemy hit dmg=10 php=46 ehp=298 roll=4/0 desc="" + [13] r2 seat=2 Clash enemy hit dmg=8 php=38 ehp=298 roll=15/0 desc="" + [14] r2 seat=2 Clash player miss dmg=0 php=38 ehp=298 roll=1/0 desc="fumble" + [15] r2 seat=0 Clash player miss dmg=0 php=117 ehp=298 roll=1/0 desc="fumble" + [16] r3 seat=0 Clash player hit dmg=11 php=117 ehp=287 roll=7/0 desc="" + [17] r3 seat=1 Clash player hit dmg=18 php=90 ehp=269 roll=5/0 desc="" + [18] r3 seat=0 Clash enemy hit dmg=6 php=111 ehp=269 roll=9/0 desc="" + [19] r3 seat=2 Clash enemy hit dmg=9 php=29 ehp=269 roll=18/0 desc="" + [20] r3 seat=1 Clash enemy hit dmg=7 php=83 ehp=269 roll=8/0 desc="" + [21] r3 seat=2 Clash enemy block dmg=4 php=25 ehp=269 roll=11/0 desc="" + [22] r3 seat=2 Clash enemy hit dmg=9 php=16 ehp=269 roll=10/0 desc="" + [23] r3 seat=2 Clash player hit dmg=6 php=16 ehp=263 roll=17/0 desc="" + [24] r4 seat=1 Decisive player hit dmg=19 php=83 ehp=244 roll=15/0 desc="" + [25] r4 seat=0 Decisive player hit dmg=10 php=111 ehp=234 roll=15/0 desc="" + [26] r4 seat=2 Decisive player hit dmg=5 php=16 ehp=229 roll=7/0 desc="" + [27] r4 seat=2 Decisive enemy hit dmg=10 php=6 ehp=229 roll=15/0 desc="" + [28] r4 seat=1 Decisive enemy hit dmg=9 php=74 ehp=229 roll=8/0 desc="" + [29] r4 seat=1 Decisive enemy hit dmg=8 php=66 ehp=229 roll=8/0 desc="" + [30] r4 seat=1 Decisive enemy hit dmg=8 php=58 ehp=229 roll=3/0 desc="" + [31] r4 seat=2 Decisive enemy hit dmg=7 php=0 ehp=229 roll=6/0 desc="" + [32] r5 seat=0 Sudden Death player hit dmg=11 php=111 ehp=218 roll=16/0 desc="" + [33] r5 seat=0 Sudden Death enemy hit dmg=8 php=103 ehp=218 roll=6/0 desc="" + [34] r5 seat=1 Sudden Death enemy block dmg=4 php=54 ehp=218 roll=7/0 desc="" + [35] r5 seat=1 Sudden Death player hit dmg=19 php=54 ehp=199 roll=19/0 desc="" + [36] r6 seat=1 Sudden Death player hit dmg=24 php=54 ehp=175 roll=5/0 desc="" + [37] r6 seat=0 Sudden Death player hit dmg=14 php=103 ehp=161 roll=9/0 desc="" + [38] r6 seat=1 Sudden Death enemy hit dmg=9 php=45 ehp=161 roll=17/0 desc="" + [39] r6 seat=1 Sudden Death enemy hit dmg=10 php=35 ehp=161 roll=15/0 desc="" + [40] r7 seat=1 Sudden Death player hit dmg=20 php=35 ehp=141 roll=3/0 desc="" + [41] r7 seat=0 Sudden Death player block dmg=5 php=103 ehp=136 roll=16/0 desc="" + [42] r7 seat=0 Sudden Death enemy hit dmg=10 php=93 ehp=136 roll=14/0 desc="" + [43] r7 seat=0 Sudden Death enemy hit dmg=10 php=83 ehp=136 roll=10/0 desc="" + [44] r8 seat=1 Sudden Death player hit dmg=21 php=35 ehp=115 roll=10/0 desc="" + [45] r8 seat=0 Sudden Death player miss dmg=0 php=83 ehp=115 roll=1/0 desc="fumble" + [46] r8 seat=0 Sudden Death enemy hit dmg=9 php=74 ehp=115 roll=8/0 desc="" + [47] r8 seat=1 Sudden Death enemy hit dmg=10 php=25 ehp=115 roll=14/0 desc="" + [48] r8 seat=0 exhaust system timeout dmg=0 php=74 ehp=115 roll=0/0 desc="" + +=== trio/one weak seat seed=3 === +result: won=true timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=18 + seat[0]: start=160 entry=160 end=52 + seat[1]: start=90 entry=90 end=0 + seat[2]: start=70 entry=70 end=0 + [00] r1 seat=1 Opening player hit dmg=15 php=90 ehp=330 roll=8/0 desc="" + [01] r1 seat=2 Opening player hit dmg=5 php=70 ehp=325 roll=18/0 desc="" + [02] r1 seat=0 Opening player hit dmg=9 php=160 ehp=316 roll=8/0 desc="" + [03] r1 seat=0 Opening enemy hit dmg=5 php=155 ehp=316 roll=16/0 desc="" + [04] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=316 roll=8/0 desc="" + [05] r1 seat=1 Opening enemy hit dmg=6 php=77 ehp=316 roll=15/0 desc="" + [06] r1 seat=0 Opening enemy hit dmg=6 php=149 ehp=316 roll=12/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=6 php=143 ehp=316 roll=13/0 desc="" + [08] r1 seat=1 Opening environment environmental dmg=6 php=71 ehp=316 roll=0/0 desc="" + [09] r2 seat=1 Clash player hit dmg=15 php=71 ehp=301 roll=4/0 desc="" + [10] r2 seat=2 Clash enemy hit dmg=8 php=62 ehp=301 roll=12/0 desc="" + [11] r2 seat=1 Clash enemy hit dmg=9 php=62 ehp=301 roll=14/0 desc="" + [12] r2 seat=1 Clash enemy block dmg=3 php=59 ehp=301 roll=17/0 desc="" + [13] r2 seat=0 Clash enemy hit dmg=8 php=135 ehp=301 roll=12/0 desc="" + [14] r2 seat=2 Clash enemy miss dmg=0 php=62 ehp=301 roll=1/0 desc="fumble" + [15] r2 seat=0 Clash player hit dmg=12 php=135 ehp=289 roll=7/0 desc="" + [16] r2 seat=2 Clash player hit dmg=5 php=62 ehp=284 roll=8/0 desc="" + [17] r3 seat=2 Clash player hit dmg=6 php=62 ehp=278 roll=19/0 desc="" + [18] r3 seat=1 Clash player hit dmg=19 php=59 ehp=259 roll=18/0 desc="" + [19] r3 seat=0 Clash player hit dmg=12 php=135 ehp=247 roll=11/0 desc="" + [20] r3 seat=2 Clash enemy hit dmg=8 php=54 ehp=247 roll=5/0 desc="" + [21] r3 seat=2 Clash enemy hit dmg=8 php=46 ehp=247 roll=7/0 desc="" + [22] r3 seat=0 Clash enemy hit dmg=8 php=127 ehp=247 roll=4/0 desc="" + [23] r3 seat=2 Clash enemy hit dmg=8 php=38 ehp=247 roll=16/0 desc="" + [24] r3 seat=2 Clash enemy hit dmg=8 php=30 ehp=247 roll=8/0 desc="" + [25] r3 seat=0 Clash environment environmental dmg=3 php=124 ehp=247 roll=0/0 desc="" + [26] r4 seat=1 Clash player hit dmg=17 php=59 ehp=230 roll=5/0 desc="" + [27] r4 seat=0 Clash player hit dmg=10 php=124 ehp=220 roll=14/0 desc="" + [28] r4 seat=2 Clash enemy hit dmg=8 php=22 ehp=220 roll=10/0 desc="" + [29] r4 seat=2 Clash enemy hit dmg=9 php=13 ehp=220 roll=4/0 desc="" + [30] r4 seat=1 Clash enemy hit dmg=9 php=50 ehp=220 roll=4/0 desc="" + [31] r4 seat=0 Clash enemy hit dmg=8 php=116 ehp=220 roll=12/0 desc="" + [32] r4 seat=0 Clash enemy hit dmg=8 php=108 ehp=220 roll=11/0 desc="" + [33] r4 seat=2 Clash player hit dmg=6 php=13 ehp=214 roll=3/0 desc="" + [34] r5 seat=1 Decisive player hit dmg=20 php=50 ehp=194 roll=5/0 desc="" + [35] r5 seat=0 Decisive player hit dmg=11 php=108 ehp=183 roll=10/0 desc="" + [36] r5 seat=2 Decisive player hit dmg=6 php=13 ehp=177 roll=17/0 desc="" + [37] r5 seat=2 Decisive enemy hit dmg=9 php=4 ehp=177 roll=17/0 desc="" + [38] r5 seat=2 Decisive enemy hit dmg=9 php=0 ehp=177 roll=13/0 desc="" + [39] r5 seat=1 Decisive enemy hit dmg=8 php=42 ehp=177 roll=8/0 desc="" + [40] r5 seat=0 Decisive enemy hit dmg=7 php=101 ehp=177 roll=6/0 desc="" + [41] r5 seat=0 Decisive enemy hit dmg=7 php=94 ehp=177 roll=16/0 desc="" + [42] r6 seat=1 Sudden Death player hit dmg=19 php=42 ehp=158 roll=10/0 desc="" + [43] r6 seat=1 Sudden Death enemy hit dmg=11 php=31 ehp=158 roll=14/0 desc="" + [44] r6 seat=1 Sudden Death enemy hit dmg=10 php=21 ehp=158 roll=18/0 desc="" + [45] r6 seat=0 Sudden Death player hit dmg=13 php=94 ehp=145 roll=9/0 desc="" + [46] r6 seat=0 Sudden Death environment environmental dmg=3 php=91 ehp=145 roll=0/0 desc="" + [47] r7 seat=1 Sudden Death player hit dmg=18 php=21 ehp=127 roll=4/0 desc="" + [48] r7 seat=0 Sudden Death player hit dmg=14 php=91 ehp=113 roll=3/0 desc="" + [49] r7 seat=1 Sudden Death enemy hit dmg=8 php=13 ehp=113 roll=8/0 desc="" + [50] r7 seat=0 Sudden Death enemy hit dmg=10 php=81 ehp=113 roll=12/0 desc="" + [51] r7 seat=0 Sudden Death enemy hit dmg=8 php=73 ehp=113 roll=9/0 desc="" + [52] r8 seat=1 Sudden Death player hit dmg=19 php=13 ehp=94 roll=5/0 desc="" + [53] r8 seat=0 Sudden Death player hit dmg=14 php=73 ehp=80 roll=12/0 desc="" + [54] r8 seat=0 Sudden Death enemy hit dmg=9 php=64 ehp=80 roll=19/0 desc="" + [55] r8 seat=0 Sudden Death enemy hit dmg=8 php=56 ehp=80 roll=9/0 desc="" + [56] r9 seat=1 Sudden Death player hit dmg=21 php=13 ehp=59 roll=15/0 desc="" + [57] r9 seat=1 Sudden Death enemy hit dmg=9 php=4 ehp=59 roll=16/0 desc="" + [58] r9 seat=1 Sudden Death enemy hit dmg=9 php=0 ehp=59 roll=17/0 desc="" + [59] r9 seat=0 Sudden Death player crit dmg=28 php=56 ehp=31 roll=20/0 desc="" + [60] r10 seat=0 Sudden Death enemy block dmg=4 php=52 ehp=31 roll=9/0 desc="" + [61] r10 seat=0 Sudden Death player hit dmg=13 php=52 ehp=18 roll=11/0 desc="" + [62] r10 seat=0 exhaust system timeout dmg=0 php=52 ehp=18 roll=0/0 desc="" + +=== trio/one weak seat seed=7 === +result: won=true timedOut=true rounds=10 survivors=true + enemy: start=345 entry=345 end=7 + seat[0]: start=160 entry=160 end=6 + seat[1]: start=90 entry=90 end=18 + seat[2]: start=70 entry=70 end=0 + [00] r1 seat=1 Opening player hit dmg=13 php=90 ehp=332 roll=6/0 desc="" + [01] r1 seat=2 Opening player hit dmg=5 php=70 ehp=327 roll=16/0 desc="" + [02] r1 seat=0 Opening player hit dmg=8 php=160 ehp=319 roll=16/0 desc="" + [03] r1 seat=2 Opening enemy block dmg=4 php=66 ehp=319 roll=11/0 desc="" + [04] r1 seat=2 Opening enemy hit dmg=6 php=60 ehp=319 roll=14/0 desc="" + [05] r1 seat=2 Opening enemy hit dmg=7 php=53 ehp=319 roll=13/0 desc="" + [06] r1 seat=2 Opening enemy hit dmg=7 php=46 ehp=319 roll=18/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=319 roll=9/0 desc="" + [08] r1 seat=1 Opening environment environmental dmg=3 php=87 ehp=319 roll=0/0 desc="" + [09] r2 seat=1 Clash player hit dmg=15 php=87 ehp=304 roll=17/0 desc="" + [10] r2 seat=2 Clash player hit dmg=5 php=46 ehp=299 roll=8/0 desc="" + [11] r2 seat=1 Clash enemy hit dmg=9 php=78 ehp=299 roll=9/0 desc="" + [12] r2 seat=0 Clash enemy hit dmg=7 php=147 ehp=299 roll=10/0 desc="" + [13] r2 seat=0 Clash enemy hit dmg=7 php=140 ehp=299 roll=6/0 desc="" + [14] r2 seat=1 Clash enemy hit dmg=8 php=70 ehp=299 roll=16/0 desc="" + [15] r2 seat=0 Clash enemy hit dmg=8 php=132 ehp=299 roll=11/0 desc="" + [16] r2 seat=0 Clash player hit dmg=10 php=132 ehp=289 roll=18/0 desc="" + [17] r3 seat=2 Clash player hit dmg=7 php=46 ehp=282 roll=13/0 desc="" + [18] r3 seat=2 Clash enemy hit dmg=7 php=39 ehp=282 roll=8/0 desc="" + [19] r3 seat=1 Clash enemy hit dmg=7 php=63 ehp=282 roll=8/0 desc="" + [20] r3 seat=2 Clash enemy hit dmg=7 php=32 ehp=282 roll=10/0 desc="" + [21] r3 seat=0 Clash enemy hit dmg=8 php=124 ehp=282 roll=3/0 desc="" + [22] r3 seat=2 Clash enemy hit dmg=8 php=24 ehp=282 roll=10/0 desc="" + [23] r3 seat=0 Clash player hit dmg=11 php=124 ehp=271 roll=4/0 desc="" + [24] r3 seat=1 Clash player hit dmg=15 php=63 ehp=256 roll=6/0 desc="" + [25] r4 seat=1 Clash player crit dmg=30 php=63 ehp=226 roll=20/0 desc="" + [26] r4 seat=2 Clash player hit dmg=6 php=24 ehp=220 roll=13/0 desc="" + [27] r4 seat=1 Clash enemy crit dmg=14 php=49 ehp=220 roll=20/0 desc="" + [28] r4 seat=2 Clash enemy hit dmg=7 php=17 ehp=220 roll=2/0 desc="" + [29] r4 seat=2 Clash enemy miss dmg=0 php=17 ehp=220 roll=1/0 desc="fumble" + [30] r4 seat=0 Clash enemy hit dmg=8 php=116 ehp=220 roll=10/0 desc="" + [31] r4 seat=2 Clash enemy hit dmg=7 php=10 ehp=220 roll=17/0 desc="" + [32] r4 seat=0 Clash player hit dmg=9 php=116 ehp=211 roll=8/0 desc="" + [33] r5 seat=1 Decisive player hit dmg=16 php=49 ehp=195 roll=18/0 desc="" + [34] r5 seat=0 Decisive player hit dmg=13 php=116 ehp=182 roll=4/0 desc="" + [35] r5 seat=2 Decisive player hit dmg=7 php=10 ehp=175 roll=8/0 desc="" + [36] r5 seat=0 Decisive enemy hit dmg=9 php=107 ehp=175 roll=3/0 desc="" + [37] r5 seat=1 Decisive enemy hit dmg=7 php=42 ehp=175 roll=14/0 desc="" + [38] r5 seat=2 Decisive enemy hit dmg=8 php=2 ehp=175 roll=17/0 desc="" + [39] r5 seat=2 Decisive enemy block dmg=4 php=0 ehp=175 roll=7/0 desc="" + [40] r5 seat=0 Decisive enemy hit dmg=8 php=99 ehp=175 roll=17/0 desc="" + [41] r6 seat=1 Sudden Death player hit dmg=18 php=42 ehp=157 roll=15/0 desc="" + [42] r6 seat=1 Sudden Death enemy hit dmg=10 php=32 ehp=157 roll=5/0 desc="" + [43] r6 seat=0 Sudden Death enemy hit dmg=8 php=91 ehp=157 roll=14/0 desc="" + [44] r6 seat=0 Sudden Death player hit dmg=11 php=91 ehp=146 roll=6/0 desc="" + [45] r7 seat=1 Sudden Death player crit dmg=46 php=32 ehp=100 roll=20/0 desc="" + [46] r7 seat=0 Sudden Death enemy hit dmg=10 php=81 ehp=100 roll=18/0 desc="" + [47] r7 seat=0 Sudden Death enemy crit dmg=20 php=61 ehp=100 roll=20/0 desc="" + [48] r7 seat=0 Sudden Death enemy hit dmg=9 php=52 ehp=100 roll=5/0 desc="" + [49] r7 seat=0 Sudden Death player hit dmg=12 php=52 ehp=88 roll=8/0 desc="" + [50] r8 seat=1 Sudden Death player hit dmg=18 php=32 ehp=70 roll=4/0 desc="" + [51] r8 seat=0 Sudden Death player hit dmg=11 php=52 ehp=59 roll=4/0 desc="" + [52] r8 seat=0 Sudden Death enemy hit dmg=8 php=44 ehp=59 roll=6/0 desc="" + [53] r8 seat=1 Sudden Death enemy block dmg=4 php=28 ehp=59 roll=17/0 desc="" + [54] r9 seat=1 Sudden Death player hit dmg=18 php=28 ehp=41 roll=9/0 desc="" + [55] r9 seat=0 Sudden Death player miss dmg=0 php=44 ehp=41 roll=1/0 desc="fumble" + [56] r9 seat=0 Sudden Death enemy hit dmg=10 php=34 ehp=41 roll=17/0 desc="" + [57] r9 seat=0 Sudden Death enemy hit dmg=10 php=24 ehp=41 roll=8/0 desc="" + [58] r10 seat=1 Sudden Death player hit dmg=19 php=28 ehp=22 roll=13/0 desc="" + [59] r10 seat=1 Sudden Death enemy hit dmg=10 php=18 ehp=22 roll=16/0 desc="" + [60] r10 seat=0 Sudden Death enemy hit dmg=10 php=14 ehp=22 roll=11/0 desc="" + [61] r10 seat=0 Sudden Death enemy hit dmg=8 php=6 ehp=22 roll=14/0 desc="" + [62] r10 seat=0 Sudden Death player hit dmg=15 php=6 ehp=7 roll=9/0 desc="" + [63] r10 seat=0 exhaust system timeout dmg=0 php=6 ehp=7 roll=0/0 desc="" + +=== trio/one weak seat seed=42 === +result: won=false timedOut=true rounds=8 survivors=true + enemy: start=345 entry=345 end=99 + seat[0]: start=160 entry=160 end=54 + seat[1]: start=90 entry=90 end=0 + seat[2]: start=70 entry=70 end=0 + [00] r1 seat=1 Opening player miss dmg=0 php=90 ehp=345 roll=1/0 desc="fumble" + [01] r1 seat=0 Opening player hit dmg=8 php=160 ehp=337 roll=17/0 desc="" + [02] r1 seat=2 Opening player hit dmg=5 php=70 ehp=332 roll=19/0 desc="" + [03] r1 seat=2 Opening enemy hit dmg=7 php=63 ehp=332 roll=18/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=7 php=153 ehp=332 roll=19/0 desc="" + [05] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=332 roll=2/0 desc="" + [06] r1 seat=1 Opening enemy hit dmg=7 php=76 ehp=332 roll=2/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=6 php=147 ehp=332 roll=2/0 desc="" + [08] r1 seat=2 Opening environment environmental dmg=6 php=57 ehp=332 roll=0/0 desc="" + [09] r2 seat=1 Clash player hit dmg=17 php=76 ehp=315 roll=10/0 desc="" + [10] r2 seat=0 Clash player hit dmg=10 php=147 ehp=305 roll=15/0 desc="" + [11] r2 seat=2 Clash player hit dmg=7 php=57 ehp=298 roll=16/0 desc="" + [12] r2 seat=0 Clash enemy block dmg=4 php=143 ehp=298 roll=5/0 desc="" + [13] r2 seat=1 Clash enemy hit dmg=7 php=69 ehp=298 roll=6/0 desc="" + [14] r2 seat=0 Clash enemy miss dmg=0 php=143 ehp=298 roll=1/0 desc="fumble" + [15] r2 seat=2 Clash enemy hit dmg=10 php=47 ehp=298 roll=15/0 desc="" + [16] r2 seat=1 Clash enemy hit dmg=7 php=62 ehp=298 roll=4/0 desc="" + [17] r3 seat=1 Clash player hit dmg=19 php=62 ehp=279 roll=6/0 desc="" + [18] r3 seat=0 Clash player hit dmg=10 php=143 ehp=269 roll=4/0 desc="" + [19] r3 seat=2 Clash player hit dmg=6 php=47 ehp=263 roll=15/0 desc="" + [20] r3 seat=2 Clash enemy hit dmg=8 php=39 ehp=263 roll=5/0 desc="" + [21] r3 seat=2 Clash enemy hit dmg=9 php=30 ehp=263 roll=10/0 desc="" + [22] r3 seat=1 Clash enemy miss dmg=0 php=62 ehp=263 roll=1/0 desc="fumble" + [23] r3 seat=0 Clash enemy hit dmg=7 php=136 ehp=263 roll=17/0 desc="" + [24] r3 seat=2 Clash enemy hit dmg=8 php=22 ehp=263 roll=15/0 desc="" + [25] r4 seat=1 Decisive player hit dmg=20 php=62 ehp=243 roll=4/0 desc="" + [26] r4 seat=2 Decisive player hit dmg=6 php=22 ehp=237 roll=17/0 desc="" + [27] r4 seat=0 Decisive enemy block dmg=3 php=133 ehp=237 roll=4/0 desc="" + [28] r4 seat=0 Decisive enemy hit dmg=7 php=126 ehp=237 roll=15/0 desc="" + [29] r4 seat=0 Decisive enemy hit dmg=8 php=118 ehp=237 roll=3/0 desc="" + [30] r4 seat=2 Decisive enemy hit dmg=8 php=14 ehp=237 roll=13/0 desc="" + [31] r4 seat=0 Decisive enemy hit dmg=8 php=110 ehp=237 roll=7/0 desc="" + [32] r4 seat=0 Decisive player hit dmg=10 php=110 ehp=227 roll=7/0 desc="" + [33] r5 seat=1 Sudden Death player hit dmg=21 php=62 ehp=206 roll=13/0 desc="" + [34] r5 seat=2 Sudden Death player hit dmg=6 php=14 ehp=200 roll=15/0 desc="" + [35] r5 seat=0 Sudden Death player hit dmg=12 php=110 ehp=188 roll=7/0 desc="" + [36] r5 seat=0 Sudden Death enemy hit dmg=8 php=102 ehp=188 roll=8/0 desc="" + [37] r5 seat=0 Sudden Death enemy hit dmg=8 php=94 ehp=188 roll=4/0 desc="" + [38] r5 seat=2 Sudden Death enemy miss dmg=0 php=14 ehp=188 roll=1/0 desc="fumble" + [39] r5 seat=1 Sudden Death enemy hit dmg=9 php=53 ehp=188 roll=3/0 desc="" + [40] r5 seat=2 Sudden Death enemy hit dmg=11 php=3 ehp=188 roll=16/0 desc="" + [41] r6 seat=1 Sudden Death player hit dmg=19 php=53 ehp=169 roll=16/0 desc="" + [42] r6 seat=0 Sudden Death player hit dmg=14 php=94 ehp=155 roll=6/0 desc="" + [43] r6 seat=2 Sudden Death player hit dmg=7 php=3 ehp=148 roll=7/0 desc="" + [44] r6 seat=1 Sudden Death enemy block dmg=5 php=48 ehp=148 roll=11/0 desc="" + [45] r6 seat=1 Sudden Death enemy hit dmg=11 php=37 ehp=148 roll=7/0 desc="" + [46] r6 seat=0 Sudden Death enemy block dmg=4 php=90 ehp=148 roll=4/0 desc="" + [47] r6 seat=2 Sudden Death enemy hit dmg=10 php=0 ehp=148 roll=4/0 desc="" + [48] r6 seat=1 Sudden Death enemy hit dmg=10 php=27 ehp=148 roll=9/0 desc="" + [49] r7 seat=1 Sudden Death player hit dmg=18 php=27 ehp=130 roll=14/0 desc="" + [50] r7 seat=0 Sudden Death player miss dmg=0 php=90 ehp=130 roll=1/0 desc="fumble" + [51] r7 seat=1 Sudden Death enemy hit dmg=10 php=17 ehp=130 roll=14/0 desc="" + [52] r7 seat=0 Sudden Death enemy crit dmg=20 php=70 ehp=130 roll=20/0 desc="" + [53] r7 seat=1 Sudden Death enemy hit dmg=8 php=9 ehp=130 roll=7/0 desc="" + [54] r8 seat=1 Sudden Death player hit dmg=18 php=9 ehp=112 roll=8/0 desc="" + [55] r8 seat=0 Sudden Death player hit dmg=13 php=70 ehp=99 roll=15/0 desc="" + [56] r8 seat=1 Sudden Death enemy hit dmg=10 php=0 ehp=99 roll=11/0 desc="" + [57] r8 seat=0 Sudden Death enemy crit dmg=16 php=54 ehp=99 roll=20/0 desc="" + [58] r8 seat=0 exhaust system timeout dmg=0 php=54 ehp=99 roll=0/0 desc="" + +=== trio/two glass seats seed=1 === +result: won=false timedOut=false rounds=7 survivors=false + enemy: start=172 entry=172 end=67 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + seat[2]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player hit dmg=11 php=12 ehp=161 roll=11/0 desc="" + [01] r1 seat=2 Opening player hit dmg=12 php=12 ehp=149 roll=3/0 desc="" + [02] r1 seat=0 Opening player hit dmg=10 php=160 ehp=139 roll=11/0 desc="" + [03] r1 seat=2 Opening enemy hit dmg=20 php=0 ehp=139 roll=12/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=19 php=141 ehp=139 roll=2/0 desc="" + [05] r1 seat=1 Opening enemy hit dmg=23 php=0 ehp=139 roll=7/0 desc="" + [06] r1 seat=0 Opening enemy hit dmg=17 php=124 ehp=139 roll=8/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=16 php=108 ehp=139 roll=4/0 desc="" + [08] r2 seat=0 Clash player hit dmg=10 php=108 ehp=129 roll=8/0 desc="" + [09] r2 seat=0 Clash enemy hit dmg=20 php=88 ehp=129 roll=19/0 desc="" + [10] r3 seat=0 Clash enemy hit dmg=18 php=70 ehp=129 roll=5/0 desc="" + [11] r3 seat=0 Clash player hit dmg=11 php=70 ehp=118 roll=8/0 desc="" + [12] r4 seat=0 Clash enemy crit dmg=48 php=22 ehp=118 roll=20/0 desc="" + [13] r4 seat=0 Clash player hit dmg=12 php=22 ehp=106 roll=14/0 desc="" + [14] r5 seat=0 Decisive player hit dmg=13 php=22 ehp=93 roll=16/0 desc="" + [15] r5 seat=0 Decisive enemy hit dmg=21 php=1 ehp=93 roll=11/0 desc="" + [16] r6 seat=0 Sudden Death player hit dmg=13 php=1 ehp=80 roll=16/0 desc="" + [17] r6 seat=0 Sudden Death enemy miss dmg=0 php=1 ehp=80 roll=1/0 desc="fumble" + [18] r7 seat=0 Sudden Death player hit dmg=13 php=1 ehp=67 roll=11/0 desc="" + [19] r7 seat=0 Sudden Death enemy block dmg=12 php=0 ehp=67 roll=7/0 desc="" + +=== trio/two glass seats seed=2 === +result: won=false timedOut=false rounds=4 survivors=false + enemy: start=172 entry=172 end=80 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + seat[2]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player hit dmg=14 php=12 ehp=158 roll=16/0 desc="" + [01] r1 seat=2 Opening player hit dmg=14 php=12 ehp=144 roll=7/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=40 php=120 ehp=144 roll=20/0 desc="" + [03] r1 seat=0 Opening enemy hit dmg=18 php=102 ehp=144 roll=12/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=20 php=82 ehp=144 roll=18/0 desc="" + [05] r1 seat=2 Opening enemy hit dmg=21 php=0 ehp=144 roll=7/0 desc="" + [06] r1 seat=0 Opening enemy hit dmg=19 php=63 ehp=144 roll=2/0 desc="" + [07] r1 seat=0 Opening player hit dmg=11 php=63 ehp=133 roll=15/0 desc="" + [08] r2 seat=1 Clash player hit dmg=14 php=12 ehp=119 roll=15/0 desc="" + [09] r2 seat=0 Clash player hit dmg=14 php=63 ehp=105 roll=12/0 desc="" + [10] r2 seat=0 Clash enemy hit dmg=20 php=43 ehp=105 roll=12/0 desc="" + [11] r2 seat=1 Clash enemy hit dmg=31 php=0 ehp=105 roll=13/0 desc="" + [12] r2 seat=0 Clash enemy hit dmg=20 php=23 ehp=105 roll=5/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=22 php=1 ehp=105 roll=6/0 desc="" + [14] r3 seat=0 Clash player hit dmg=11 php=1 ehp=94 roll=11/0 desc="" + [15] r4 seat=0 Decisive player hit dmg=14 php=1 ehp=80 roll=17/0 desc="" + [16] r4 seat=0 Decisive enemy hit dmg=25 php=0 ehp=80 roll=2/0 desc="" + +=== trio/two glass seats seed=3 === +result: won=false timedOut=false rounds=6 survivors=false + enemy: start=172 entry=172 end=66 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + seat[2]: start=12 entry=12 end=0 + [00] r1 seat=2 Opening player hit dmg=14 php=12 ehp=158 roll=8/0 desc="" + [01] r1 seat=1 Opening player hit dmg=12 php=12 ehp=146 roll=18/0 desc="" + [02] r1 seat=0 Opening player hit dmg=11 php=160 ehp=135 roll=8/0 desc="" + [03] r1 seat=0 Opening enemy hit dmg=16 php=144 ehp=135 roll=16/0 desc="" + [04] r1 seat=1 Opening enemy hit dmg=23 php=0 ehp=135 roll=8/0 desc="" + [05] r1 seat=0 Opening enemy hit dmg=16 php=128 ehp=135 roll=15/0 desc="" + [06] r1 seat=0 Opening enemy hit dmg=17 php=111 ehp=135 roll=12/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=18 php=93 ehp=135 roll=13/0 desc="" + [08] r1 seat=2 Opening environment environmental dmg=6 php=6 ehp=135 roll=0/0 desc="" + [09] r2 seat=2 Clash player hit dmg=14 php=6 ehp=121 roll=4/0 desc="" + [10] r2 seat=0 Clash player crit dmg=28 php=93 ehp=93 roll=20/0 desc="" + [11] r2 seat=2 Clash enemy hit dmg=25 php=0 ehp=93 roll=14/0 desc="" + [12] r2 seat=0 Clash enemy hit dmg=18 php=75 ehp=93 roll=9/0 desc="" + [13] r2 seat=0 Clash environment environmental dmg=2 php=73 ehp=93 roll=0/0 desc="" + [14] r3 seat=0 Clash player hit dmg=14 php=73 ehp=79 roll=7/0 desc="" + [15] r3 seat=0 Clash enemy hit dmg=18 php=55 ehp=79 roll=8/0 desc="" + [16] r4 seat=0 Decisive player hit dmg=13 php=55 ehp=66 roll=9/0 desc="" + [17] r4 seat=0 Decisive enemy hit dmg=22 php=33 ehp=66 roll=8/0 desc="" + [18] r5 seat=0 Sudden Death player miss dmg=0 php=33 ehp=66 roll=1/0 desc="fumble" + [19] r5 seat=0 Sudden Death enemy hit dmg=29 php=4 ehp=66 roll=15/0 desc="" + [20] r6 seat=0 Sudden Death enemy hit dmg=24 php=0 ehp=66 roll=16/0 desc="" + +=== trio/two glass seats seed=7 === +result: won=false timedOut=false rounds=6 survivors=false + enemy: start=172 entry=172 end=97 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + seat[2]: start=12 entry=12 end=0 + [00] r1 seat=2 Opening player hit dmg=12 php=12 ehp=160 roll=6/0 desc="" + [01] r1 seat=1 Opening player hit dmg=14 php=12 ehp=146 roll=16/0 desc="" + [02] r1 seat=0 Opening player hit dmg=9 php=160 ehp=137 roll=16/0 desc="" + [03] r1 seat=2 Opening enemy block dmg=12 php=0 ehp=137 roll=11/0 desc="" + [04] r1 seat=1 Opening enemy hit dmg=19 php=0 ehp=137 roll=14/0 desc="" + [05] r1 seat=0 Opening enemy hit dmg=19 php=141 ehp=137 roll=13/0 desc="" + [06] r1 seat=0 Opening enemy hit dmg=19 php=122 ehp=137 roll=18/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=17 php=105 ehp=137 roll=9/0 desc="" + [08] r2 seat=0 Clash player hit dmg=13 php=105 ehp=124 roll=11/0 desc="" + [09] r2 seat=0 Clash enemy hit dmg=19 php=86 ehp=124 roll=17/0 desc="" + [10] r3 seat=0 Clash player hit dmg=14 php=86 ehp=110 roll=3/0 desc="" + [11] r3 seat=0 Clash enemy hit dmg=24 php=62 ehp=110 roll=16/0 desc="" + [12] r4 seat=0 Clash player miss dmg=0 php=62 ehp=110 roll=1/0 desc="fumble" + [13] r4 seat=0 Clash enemy hit dmg=23 php=39 ehp=110 roll=11/0 desc="" + [14] r5 seat=0 Decisive player hit dmg=13 php=39 ehp=97 roll=18/0 desc="" + [15] r5 seat=0 Decisive enemy block dmg=13 php=26 ehp=97 roll=5/0 desc="" + [16] r6 seat=0 Sudden Death enemy hit dmg=26 php=0 ehp=97 roll=4/0 desc="" + +=== trio/two glass seats seed=42 === +result: won=false timedOut=false rounds=6 survivors=false + enemy: start=172 entry=172 end=107 + seat[0]: start=160 entry=160 end=0 + seat[1]: start=12 entry=12 end=0 + seat[2]: start=12 entry=12 end=0 + [00] r1 seat=1 Opening player miss dmg=0 php=12 ehp=172 roll=1/0 desc="fumble" + [01] r1 seat=2 Opening player hit dmg=12 php=12 ehp=160 roll=17/0 desc="" + [02] r1 seat=0 Opening player hit dmg=9 php=160 ehp=151 roll=19/0 desc="" + [03] r1 seat=2 Opening enemy hit dmg=23 php=0 ehp=151 roll=18/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=19 php=141 ehp=151 roll=19/0 desc="" + [05] r1 seat=0 Opening enemy hit dmg=18 php=123 ehp=151 roll=2/0 desc="" + [06] r1 seat=1 Opening enemy hit dmg=22 php=0 ehp=151 roll=2/0 desc="" + [07] r1 seat=0 Opening enemy hit dmg=18 php=105 ehp=151 roll=2/0 desc="" + [08] r2 seat=0 Clash player miss dmg=0 php=105 ehp=151 roll=1/0 desc="fumble" + [09] r2 seat=0 Clash enemy hit dmg=20 php=85 ehp=151 roll=16/0 desc="" + [10] r3 seat=0 Clash player crit dmg=24 php=85 ehp=127 roll=20/0 desc="" + [11] r3 seat=0 Clash enemy hit dmg=18 php=67 ehp=127 roll=7/0 desc="" + [12] r3 seat=0 Clash environment environmental dmg=4 php=63 ehp=127 roll=0/0 desc="" + [13] r4 seat=0 Decisive player block dmg=6 php=63 ehp=121 roll=14/0 desc="" + [14] r4 seat=0 Decisive enemy hit dmg=20 php=43 ehp=121 roll=4/0 desc="" + [15] r5 seat=0 Sudden Death player miss dmg=0 php=43 ehp=121 roll=1/0 desc="fumble" + [16] r5 seat=0 Sudden Death enemy hit dmg=29 php=14 ehp=121 roll=6/0 desc="" + [17] r6 seat=0 Sudden Death player hit dmg=14 php=14 ehp=107 roll=5/0 desc="" + [18] r6 seat=0 Sudden Death enemy crit dmg=52 php=0 ehp=107 roll=20/0 desc="" + +=== duo/vs ability enemy seed=1 === +result: won=true timedOut=false rounds=5 survivors=true + enemy: start=138 entry=138 end=0 + seat[0]: start=160 entry=160 end=122 + seat[1]: start=90 entry=90 end=44 + [00] r1 seat=1 Opening player hit dmg=18 php=90 ehp=120 roll=5/0 desc="" + [01] r1 seat=0 Opening player hit dmg=10 php=160 ehp=110 roll=5/0 desc="" + [02] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=110 roll=11/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=77 ehp=110 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=110 roll=19/0 desc="" + [05] r1 seat=0 Opening environment environmental dmg=5 php=149 ehp=110 roll=0/0 desc="" + [06] r2 seat=1 Clash player hit dmg=18 php=77 ehp=92 roll=4/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=9 php=68 ehp=92 roll=13/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=6 php=143 ehp=92 roll=12/0 desc="" + [09] r2 seat=0 Clash player hit dmg=13 php=143 ehp=79 roll=4/0 desc="" + [10] r3 seat=0 Clash player hit dmg=12 php=143 ehp=67 roll=9/0 desc="" + [11] r3 seat=1 Clash player hit dmg=22 php=68 ehp=45 roll=6/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=8 php=135 ehp=45 roll=17/0 desc="" + [13] r3 seat=1 Clash enemy hit dmg=8 php=60 ehp=45 roll=5/0 desc="" + [14] r3 seat=0 Clash enemy hit dmg=8 php=127 ehp=45 roll=7/0 desc="" + [15] r4 seat=1 Clash player hit dmg=19 php=60 ehp=26 roll=3/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=9 php=51 ehp=26 roll=11/0 desc="" + [17] r4 seat=1 Clash enemy hit dmg=7 php=44 ehp=26 roll=8/0 desc="" + [18] r4 seat=0 Clash player hit dmg=13 php=127 ehp=13 roll=9/0 desc="" + [19] r4 seat=0 Clash environment environmental dmg=5 php=122 ehp=13 roll=0/0 desc="" + [20] r5 seat=1 Decisive player hit dmg=22 php=44 ehp=0 roll=11/0 desc="" + +=== duo/vs ability enemy seed=2 === +result: won=true timedOut=false rounds=5 survivors=true + enemy: start=138 entry=138 end=0 + seat[0]: start=160 entry=160 end=108 + seat[1]: start=90 entry=90 end=56 + [00] r1 seat=1 Opening player hit dmg=18 php=90 ehp=120 roll=12/0 desc="" + [01] r1 seat=0 Opening player hit dmg=10 php=160 ehp=110 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy crit dmg=14 php=146 ehp=110 roll=20/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=110 roll=12/0 desc="" + [04] r2 seat=1 Clash player hit dmg=19 php=83 ehp=91 roll=5/0 desc="" + [05] r2 seat=0 Clash player hit dmg=12 php=146 ehp=79 roll=3/0 desc="" + [06] r2 seat=0 Clash enemy hit dmg=8 php=138 ehp=79 roll=18/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=8 php=130 ehp=79 roll=10/0 desc="" + [08] r3 seat=1 Clash player hit dmg=21 php=83 ehp=58 roll=16/0 desc="" + [09] r3 seat=0 Clash enemy miss dmg=0 php=130 ehp=58 roll=1/0 desc="fumble" + [10] r3 seat=0 Clash enemy hit dmg=8 php=122 ehp=58 roll=9/0 desc="" + [11] r3 seat=0 Clash player hit dmg=12 php=122 ehp=46 roll=16/0 desc="" + [12] r4 seat=1 Clash player miss dmg=0 php=83 ehp=46 roll=1/0 desc="fumble" + [13] r4 seat=0 Clash player hit dmg=11 php=122 ehp=35 roll=19/0 desc="" + [14] r4 seat=0 Clash enemy hit dmg=6 php=116 ehp=35 roll=11/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=9 php=74 ehp=35 roll=7/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=9 php=65 ehp=35 roll=4/0 desc="" + [17] r5 seat=1 Decisive player hit dmg=23 php=65 ehp=12 roll=9/0 desc="" + [18] r5 seat=1 Decisive enemy hit dmg=9 php=56 ehp=12 roll=19/0 desc="" + [19] r5 seat=0 Decisive enemy hit dmg=8 php=108 ehp=12 roll=18/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=13 php=108 ehp=0 roll=8/0 desc="" + +=== duo/vs ability enemy seed=3 === +result: won=true timedOut=false rounds=5 survivors=true + enemy: start=138 entry=138 end=0 + seat[0]: start=160 entry=160 end=116 + seat[1]: start=90 entry=90 end=67 + [00] r1 seat=1 Opening player hit dmg=15 php=90 ehp=123 roll=11/0 desc="" + [01] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=123 roll=18/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=123 roll=10/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=8 php=82 ehp=123 roll=13/0 desc="" + [04] r1 seat=0 Opening player hit dmg=9 php=148 ehp=114 roll=5/0 desc="" + [05] r2 seat=1 Clash player hit dmg=21 php=82 ehp=93 roll=2/0 desc="" + [06] r2 seat=0 Clash player hit dmg=13 php=148 ehp=80 roll=19/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=8 php=140 ehp=80 roll=2/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=6 php=134 ehp=80 roll=6/0 desc="" + [09] r2 seat=1 Clash environment environmental dmg=6 php=76 ehp=80 roll=0/0 desc="" + [10] r3 seat=1 Clash player hit dmg=21 php=76 ehp=59 roll=14/0 desc="" + [11] r3 seat=0 Clash enemy block dmg=3 php=131 ehp=59 roll=17/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=8 php=123 ehp=59 roll=12/0 desc="" + [13] r3 seat=0 Clash player hit dmg=12 php=123 ehp=47 roll=18/0 desc="" + [14] r4 seat=1 Clash player hit dmg=20 php=76 ehp=27 roll=19/0 desc="" + [15] r4 seat=1 Clash enemy hit dmg=9 php=67 ehp=27 roll=13/0 desc="" + [16] r4 seat=0 Clash enemy hit dmg=7 php=116 ehp=27 roll=8/0 desc="" + [17] r4 seat=0 Clash player hit dmg=13 php=116 ehp=14 roll=5/0 desc="" + [18] r5 seat=1 Decisive player hit dmg=19 php=67 ehp=0 roll=7/0 desc="" + +=== duo/vs ability enemy seed=7 === +result: won=true timedOut=false rounds=5 survivors=true + enemy: start=138 entry=138 end=0 + seat[0]: start=160 entry=160 end=116 + seat[1]: start=90 entry=90 end=62 + [00] r1 seat=1 Opening player hit dmg=17 php=90 ehp=121 roll=10/0 desc="" + [01] r1 seat=0 Opening player hit dmg=11 php=160 ehp=110 roll=3/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=5 php=155 ehp=110 roll=16/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=6 php=84 ehp=110 roll=19/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=149 ehp=110 roll=11/0 desc="" + [05] r2 seat=1 Clash player hit dmg=18 php=84 ehp=92 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=12 php=149 ehp=80 roll=18/0 desc="" + [07] r2 seat=1 Clash enemy hit dmg=8 php=76 ehp=80 roll=4/0 desc="" + [08] r2 seat=0 Clash enemy hit dmg=7 php=142 ehp=80 roll=11/0 desc="" + [09] r2 seat=0 Clash enemy hit dmg=8 php=134 ehp=80 roll=13/0 desc="" + [10] r2 seat=0 Clash environment environmental dmg=5 php=129 ehp=80 roll=0/0 desc="" + [11] r3 seat=1 Clash player hit dmg=17 php=76 ehp=63 roll=7/0 desc="" + [12] r3 seat=0 Clash enemy hit dmg=7 php=122 ehp=63 roll=2/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=116 ehp=63 roll=15/0 desc="" + [14] r3 seat=0 Clash player hit dmg=13 php=116 ehp=50 roll=11/0 desc="" + [15] r4 seat=1 Clash player hit dmg=20 php=76 ehp=30 roll=18/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=7 php=69 ehp=30 roll=4/0 desc="" + [17] r4 seat=1 Clash enemy hit dmg=7 php=62 ehp=30 roll=8/0 desc="" + [18] r4 seat=0 Clash player block dmg=6 php=116 ehp=24 roll=10/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=22 php=62 ehp=2 roll=3/0 desc="" + [20] r5 seat=0 Decisive player hit dmg=13 php=116 ehp=0 roll=17/0 desc="" + +=== duo/vs ability enemy seed=42 === +result: won=true timedOut=false rounds=5 survivors=true + enemy: start=138 entry=138 end=0 + seat[0]: start=160 entry=160 end=125 + seat[1]: start=90 entry=90 end=59 + [00] r1 seat=1 Opening player hit dmg=14 php=90 ehp=124 roll=13/0 desc="" + [01] r1 seat=0 Opening player hit dmg=9 php=160 ehp=115 roll=5/0 desc="" + [02] r1 seat=0 Opening enemy hit dmg=6 php=154 ehp=115 roll=15/0 desc="" + [03] r1 seat=1 Opening enemy hit dmg=7 php=83 ehp=115 roll=6/0 desc="" + [04] r1 seat=0 Opening enemy hit dmg=6 php=148 ehp=115 roll=13/0 desc="" + [05] r2 seat=1 Clash player hit dmg=22 php=83 ehp=93 roll=6/0 desc="" + [06] r2 seat=0 Clash player hit dmg=10 php=148 ehp=83 roll=12/0 desc="" + [07] r2 seat=0 Clash enemy hit dmg=7 php=141 ehp=83 roll=3/0 desc="" + [08] r2 seat=1 Clash enemy hit dmg=7 php=76 ehp=83 roll=5/0 desc="" + [09] r2 seat=0 Clash enemy block dmg=3 php=138 ehp=83 roll=15/0 desc="" + [10] r3 seat=1 Clash player hit dmg=17 php=76 ehp=66 roll=13/0 desc="" + [11] r3 seat=0 Clash player hit dmg=14 php=138 ehp=52 roll=12/0 desc="" + [12] r3 seat=1 Clash enemy hit dmg=9 php=67 ehp=52 roll=15/0 desc="" + [13] r3 seat=0 Clash enemy hit dmg=6 php=132 ehp=52 roll=4/0 desc="" + [14] r3 seat=0 Clash enemy hit dmg=7 php=125 ehp=52 roll=9/0 desc="" + [15] r4 seat=1 Clash player hit dmg=20 php=67 ehp=32 roll=10/0 desc="" + [16] r4 seat=1 Clash enemy hit dmg=8 php=59 ehp=32 roll=15/0 desc="" + [17] r4 seat=1 Clash enemy miss dmg=0 php=59 ehp=32 roll=1/0 desc="fumble" + [18] r4 seat=0 Clash player hit dmg=13 php=125 ehp=19 roll=9/0 desc="" + [19] r5 seat=1 Decisive player hit dmg=19 php=59 ehp=0 roll=3/0 desc="" + +=== solo/one-seat roster seed=1 === +result: won=true timedOut=true rounds=8 survivors=true + enemy: start=60 entry=60 end=1 + seat[0]: start=100 entry=100 end=23 + [00] r1 seat=0 Opening enemy hit dmg=6 php=94 ehp=60 roll=9/0 desc="" + [01] r1 seat=0 Opening player hit dmg=9 php=94 ehp=51 roll=11/0 desc="" + [02] r2 seat=0 Clash player hit dmg=13 php=94 ehp=38 roll=8/0 desc="" + [03] r2 seat=0 Clash enemy hit dmg=7 php=87 ehp=38 roll=6/0 desc="" + [04] r3 seat=0 Clash player hit dmg=11 php=87 ehp=27 roll=6/0 desc="" + [05] r3 seat=0 Clash enemy hit dmg=8 php=79 ehp=27 roll=12/0 desc="" + [06] r4 seat=0 Decisive enemy hit dmg=8 php=71 ehp=27 roll=4/0 desc="" + [07] r4 seat=0 Decisive player hit dmg=13 php=71 ehp=14 roll=2/0 desc="" + [08] r5 seat=0 Sudden Death player hit dmg=13 php=71 ehp=1 roll=8/0 desc="" + [09] r5 seat=0 Sudden Death enemy hit dmg=9 php=62 ehp=1 roll=19/0 desc="" + [10] r6 seat=0 Sudden Death player miss dmg=0 php=62 ehp=1 roll=1/0 desc="fumble" + [11] r6 seat=0 Sudden Death enemy hit dmg=9 php=53 ehp=1 roll=9/0 desc="" + [12] r7 seat=0 Sudden Death player miss dmg=0 php=53 ehp=1 roll=1/0 desc="fumble" + [13] r7 seat=0 Sudden Death enemy hit dmg=10 php=43 ehp=1 roll=17/0 desc="" + [14] r8 seat=0 Sudden Death player miss dmg=0 php=43 ehp=1 roll=1/0 desc="fumble" + [15] r8 seat=0 Sudden Death enemy crit dmg=20 php=23 ehp=1 roll=20/0 desc="" + [16] r8 seat=0 exhaust system timeout dmg=0 php=23 ehp=1 roll=0/0 desc="" + +=== solo/one-seat roster seed=2 === +result: won=true timedOut=false rounds=6 survivors=true + enemy: start=60 entry=60 end=0 + seat[0]: start=100 entry=100 end=54 + [00] r1 seat=0 Opening player hit dmg=10 php=100 ehp=50 roll=16/0 desc="" + [01] r1 seat=0 Opening enemy hit dmg=7 php=93 ehp=50 roll=16/0 desc="" + [02] r2 seat=0 Clash player hit dmg=12 php=93 ehp=38 roll=16/0 desc="" + [03] r2 seat=0 Clash enemy hit dmg=8 php=85 ehp=38 roll=12/0 desc="" + [04] r3 seat=0 Clash enemy crit dmg=16 php=69 ehp=38 roll=20/0 desc="" + [05] r3 seat=0 Clash player hit dmg=13 php=69 ehp=25 roll=4/0 desc="" + [06] r4 seat=0 Clash player hit dmg=15 php=69 ehp=10 roll=15/0 desc="" + [07] r4 seat=0 Clash enemy hit dmg=9 php=60 ehp=10 roll=6/0 desc="" + [08] r4 seat=0 Clash environment environmental dmg=2 php=58 ehp=10 roll=0/0 desc="" + [09] r5 seat=0 Decisive enemy block dmg=4 php=54 ehp=10 roll=14/0 desc="" + [10] r5 seat=0 Decisive player miss dmg=0 php=54 ehp=10 roll=1/0 desc="fumble" + [11] r6 seat=0 Sudden Death player hit dmg=14 php=54 ehp=0 roll=12/0 desc="" + +=== solo/one-seat roster seed=3 === +result: won=true timedOut=false rounds=5 survivors=true + enemy: start=60 entry=60 end=0 + seat[0]: start=100 entry=100 end=70 + [00] r1 seat=0 Opening player block dmg=6 php=100 ehp=54 roll=5/0 desc="" + [01] r1 seat=0 Opening enemy hit dmg=7 php=93 ehp=54 roll=8/0 desc="" + [02] r2 seat=0 Clash player hit dmg=14 php=93 ehp=40 roll=10/0 desc="" + [03] r2 seat=0 Clash enemy hit dmg=7 php=86 ehp=40 roll=14/0 desc="" + [04] r3 seat=0 Clash player hit dmg=13 php=86 ehp=27 roll=9/0 desc="" + [05] r3 seat=0 Clash enemy hit dmg=9 php=77 ehp=27 roll=18/0 desc="" + [06] r4 seat=0 Decisive player hit dmg=14 php=77 ehp=13 roll=13/0 desc="" + [07] r4 seat=0 Decisive enemy hit dmg=7 php=70 ehp=13 roll=18/0 desc="" + [08] r5 seat=0 Sudden Death player hit dmg=13 php=70 ehp=0 roll=6/0 desc="" + +=== solo/one-seat roster seed=7 === +result: won=true timedOut=false rounds=5 survivors=true + enemy: start=60 entry=60 end=0 + seat[0]: start=100 entry=100 end=69 + [00] r1 seat=0 Opening enemy miss dmg=0 php=100 ehp=60 roll=1/0 desc="fumble" + [01] r1 seat=0 Opening player hit dmg=11 php=100 ehp=49 roll=11/0 desc="" + [02] r2 seat=0 Clash player hit dmg=13 php=100 ehp=36 roll=15/0 desc="" + [03] r2 seat=0 Clash enemy hit dmg=9 php=91 ehp=36 roll=7/0 desc="" + [04] r2 seat=0 Clash environment environmental dmg=5 php=86 ehp=36 roll=0/0 desc="" + [05] r3 seat=0 Clash player hit dmg=13 php=86 ehp=23 roll=4/0 desc="" + [06] r3 seat=0 Clash enemy hit dmg=9 php=77 ehp=23 roll=6/0 desc="" + [07] r4 seat=0 Clash player hit dmg=12 php=77 ehp=11 roll=6/0 desc="" + [08] r4 seat=0 Clash enemy hit dmg=8 php=69 ehp=11 roll=18/0 desc="" + [09] r5 seat=0 Decisive player hit dmg=13 php=69 ehp=0 roll=8/0 desc="" + +=== solo/one-seat roster seed=42 === +result: won=true timedOut=false rounds=7 survivors=true + enemy: start=60 entry=60 end=0 + seat[0]: start=100 entry=100 end=45 + [00] r1 seat=0 Opening enemy hit dmg=6 php=94 ehp=60 roll=11/0 desc="" + [01] r1 seat=0 Opening player miss dmg=0 php=94 ehp=60 roll=1/0 desc="fumble" + [02] r1 seat=0 Opening environment environmental dmg=3 php=91 ehp=60 roll=0/0 desc="" + [03] r2 seat=0 Clash player hit dmg=12 php=91 ehp=48 roll=15/0 desc="" + [04] r2 seat=0 Clash enemy hit dmg=8 php=83 ehp=48 roll=9/0 desc="" + [05] r3 seat=0 Clash player hit dmg=14 php=83 ehp=34 roll=2/0 desc="" + [06] r3 seat=0 Clash enemy hit dmg=8 php=75 ehp=34 roll=9/0 desc="" + [07] r4 seat=0 Clash player hit dmg=15 php=75 ehp=19 roll=6/0 desc="" + [08] r4 seat=0 Clash enemy hit dmg=7 php=68 ehp=19 roll=12/0 desc="" + [09] r5 seat=0 Decisive player miss dmg=0 php=68 ehp=19 roll=1/0 desc="fumble" + [10] r5 seat=0 Decisive enemy hit dmg=8 php=60 ehp=19 roll=16/0 desc="" + [11] r6 seat=0 Sudden Death player hit dmg=14 php=60 ehp=5 roll=15/0 desc="" + [12] r6 seat=0 Sudden Death enemy hit dmg=10 php=50 ehp=5 roll=14/0 desc="" + [13] r6 seat=0 Sudden Death environment environmental dmg=5 php=45 ehp=5 roll=0/0 desc="" + [14] r7 seat=0 Sudden Death player hit dmg=17 php=45 ehp=0 roll=12/0 desc="" + diff --git a/internal/plugin/zone_combat_autocast.go b/internal/plugin/zone_combat_autocast.go new file mode 100644 index 0000000..c2c9506 --- /dev/null +++ b/internal/plugin/zone_combat_autocast.go @@ -0,0 +1,151 @@ +package plugin + +import ( + "maunium.net/go/mautrix/id" +) + +// §6 — a caster casts in an auto-resolved fight. +// +// An ordinary room fight is runZoneCombatRoster → SimulateCombat on an 8-round +// clock: one breath, no turn engine, no action picker. The only spell that has +// ever landed there is a PendingCast the player queued BY HAND with `!cast` +// before walking in. Under autopilot nobody queues one — so for every room of +// every expedition, a caster fought with a weapon and nothing else. +// +// A cleric is the extreme case: no Extra Attack, a mace, and its whole kit +// unusable. Measured on the room path at L10, a wounded cleric loses 8% of +// ORDINARY room fights (a fighter loses 0.0%), and a room loss ends the entire +// expedition — "the fight drags on, X outlasts you, you retreat". That is why +// cleric fled 167 of 500 runs in the corpus while fighter, ranger and paladin +// fled none, and it is the whole of the "cleric is a weak class" gap. +// +// The tell: dnd_class_balance.go — the harness the class corpus was tuned on — +// ALWAYS hands a caster their best damage spell (pickBestDamageSpell + +// applyHarnessSpellCast) before it simulates. So the numbers we balanced against +// modelled a caster who casts, and the live room modelled one who does not. This +// closes that gap: the character fights with the kit it actually owns. +// +// It is NOT a new mechanic. A folded-in spell is additive pre-damage, exactly as +// a hand-queued PendingCast has always been, which is what keeps this comparable +// to the corpus rather than a fresh invention. + +// autoCastForAutoResolve picks the caster's best damage spell, spends the slot, +// and folds the damage into the Combatant pair. Reports whether it cast. +// +// It reads the seat's ACTUAL remaining slots, not the class slot table. +// pickBestDamageSpell (the harness one) reads slotsForClassLevel — the +// theoretical pool — which is right for a one-fight harness and would be an +// infinite spell here: the same "no row to persist onto, so it arrives fresh" +// bug that gave the companion an unlimited body and an unlimited slot pool. +// A leveled spell cast here costs a real slot and stays spent until a rest. +func (p *AdventurePlugin) autoCastForAutoResolve( + uid id.UserID, + c *DnDCharacter, + playerStats *CombatStats, + playerMods *CombatModifiers, + enemyStats *CombatStats, +) bool { + // A hand-queued spell wins: the player already chose, applyPendingCast owns + // it, and casting a second one would be two spells in one action. + if c == nil || c.PendingCast != "" || !isSpellcaster(c) { + return false + } + spell, slot, ok := pickAutoResolveSpell(uid, c) + if !ok { + return false + } + // Leveled spells cost a slot. Spend it BEFORE the damage lands, and bail if + // the debit fails — a spell that could not be paid for must not be cast. + if slot > 0 { + spent, err := consumeSpellSlot(uid, slot) + if err != nil || !spent { + return false + } + } + applyHarnessSpellCast(c, spell, slot, playerStats, playerMods, enemyStats) + return true +} + +// roomSlotCap is the highest slot level an ordinary room may spend. Cantrips are +// free and always available; anything above this is reserved for the elite and +// the boss. +// +// The first cut of §6 had no cap — "never upcast" was supposed to be enough. It +// was not, and the sweep said so: room deaths fell for every caster (mage 131 → +// 105) while elite and boss deaths exploded (mage 7 → 38 and 19 → 61, sorcerer +// 14 → 51 and 15 → 65). Casters were winning the trash rooms and arriving at the +// thing that matters with an empty pool. Native-level is not a reserve, because +// a mage's native-level spells ARE its boss kit; only a level cap is. +// +// 2 is where the two demands stop overlapping. Every caster owns a damage spell +// at or below it (a cleric's guiding_bolt and inflict_wounds are both 1st), so +// the rooms still get a real spell — but a fireball, a spirit_guardians, a +// flame_strike, and every slot the turn engine would upcast into stays in the +// caster's pocket until there is something worth spending it on. +const roomSlotCap = 2 + +// pickAutoResolveSpell is the best damage spell this caster can actually cast +// right now: known, prepared, on their list, at or under the room slot cap, and +// backed by a slot they still hold. +// +// It casts at the spell's NATIVE level and never upcasts. simPickSpell upcasts +// aggressively — correct there, because it runs in the turn engine at an elite +// or a boss, which is what the big slots are for. A trash room is not, and a +// picker that nukes a goblin with a 5th-level slot would leave the caster +// swinging a stick at the thing that actually matters. +func pickAutoResolveSpell(uid id.UserID, c *DnDCharacter) (SpellDefinition, int, bool) { + known, err := listKnownSpells(uid) + if err != nil || len(known) == 0 { + return SpellDefinition{}, 0, false + } + slots, err := getSpellSlots(uid) + if err != nil { + return SpellDefinition{}, 0, false + } + + var best SpellDefinition + bestSlot := 0 + bestScore := -1.0 + for _, k := range known { + if !k.Prepared { + continue + } + sp, ok := lookupSpell(k.SpellID) + if !ok { + continue + } + switch sp.Effect { + case EffectDamageAttack, EffectDamageSave, EffectDamageAuto: + default: + continue + } + // The auto-resolve engine has no reaction window, same as everywhere else. + if sp.CastTime == CastReaction { + continue + } + onList := false + for _, cl := range sp.Classes { + if cl == c.Class { + onList = true + break + } + } + if !onList { + continue + } + // A cantrip is free; a leveled spell needs a slot still in hand, and the + // room only gets to reach for the small ones. + if sp.Level > 0 { + if sp.Level > roomSlotCap { + continue + } + if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 { + continue + } + } + if score := spellExpectedDamage(sp, sp.Level, c.Level); score > bestScore { + best, bestSlot, bestScore = sp, sp.Level, score + } + } + return best, bestSlot, bestScore >= 0 +} diff --git a/internal/plugin/zone_combat_autocast_test.go b/internal/plugin/zone_combat_autocast_test.go new file mode 100644 index 0000000..a8a4213 --- /dev/null +++ b/internal/plugin/zone_combat_autocast_test.go @@ -0,0 +1,254 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +// §6 — the caster casts in an auto-resolved room fight. +// +// Before this, the ONLY spell that could land in an ordinary room was one the +// player hand-queued with `!cast` before walking in. On autopilot nobody queues +// one, so a cleric fought every room of every expedition with a mace, lost room +// fights a fighter never loses, and a room loss ends the whole expedition. + +func autocastCleric(t *testing.T, tag string) (id.UserID, *DnDCharacter) { + t.Helper() + p := &AdventurePlugin{} + s := &SimRunner{P: p} + uid := id.UserID("@autocast-" + tag + ":example.org") + c, err := s.BuildCharacter(uid, ClassCleric, 10) + if err != nil { + t.Fatal(err) + } + return uid, c +} + +func usedSlots(t *testing.T, uid id.UserID) int { + t.Helper() + slots, err := getSpellSlots(uid) + if err != nil { + t.Fatal(err) + } + total := 0 + for _, pair := range slots { + total += pair[1] + } + return total +} + +func TestAutoCast_CasterCastsAndPaysForIt(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + uid, c := autocastCleric(t, "pays") + + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 100, AC: 14} + + if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) { + t.Fatal("a level-10 cleric with a full slot pool cast nothing") + } + // It has to be PAID for. pickBestDamageSpell reads the theoretical class slot + // table; if we had reused it here the spell would be infinite — the same + // free-lunch bug that gave the companion an unlimited body. The slot is spent + // whether or not the spell connects, exactly as it is at a real table. + if used := usedSlots(t, uid); used != 1 { + t.Fatalf("slots used = %d, want exactly 1 — the spell must cost a real slot", used) + } +} + +// The slot has to buy damage. Not on any GIVEN cast — under the room slot cap a +// cleric reaches for inflict_wounds, which is an attack-roll spell and is +// allowed to miss — but a caster who spends its pool and never scratches +// anything would be strictly worse than swinging the mace. +func TestAutoCast_SpellActuallyDealsDamage(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + uid, c := autocastCleric(t, "damage") + + landed := 0 + for i := 0; i < 40; i++ { + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 1000, AC: 10} + if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) { + continue + } + if mods.SpellPreDamage > 0 || enemy.MaxHP < 1000 { + landed++ + } + } + if landed == 0 { + t.Fatal("40 casts against AC 10 and not one dealt a point of damage — " + + "the caster is spending slots on air") + } +} + +// The pool is finite: cast far more times than the caster has slots, and the +// ledger must converge rather than run forever. This is the free-lunch guard — +// reusing the harness's pickBestDamageSpell (which reads the theoretical class +// slot table, not the row) would cast an unlimited leveled spell every room. +// +// It converges BELOW the full pool, and that is correct: a cleric owns no damage +// spell at slot 2 or 4 (guiding_bolt and inflict_wounds are L1, spirit_guardians +// L3, flame_strike L5), and a trash room never upcasts. Those slots are not +// wasted — the turn engine upcasts them at the elite and the boss, which is what +// they are for. +func TestAutoCast_SlotPoolIsFiniteAndConverges(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + uid, c := autocastCleric(t, "finite") + + before, err := getSpellSlots(uid) + if err != nil { + t.Fatal(err) + } + pool := 0 + for _, pair := range before { + pool += pair[0] + } + if pool == 0 { + t.Fatal("test cleric has no slots at all") + } + + cast := func(n int) { + for i := 0; i < n; i++ { + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 1000, AC: 14} + p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) + } + } + + cast(pool + 10) + drained := usedSlots(t, uid) + if drained == 0 { + t.Fatal("the cleric never spent a slot — it is not really casting") + } + if drained > pool { + t.Fatalf("slots used = %d > pool %d — slots are being conjured", drained, pool) + } + + // Dry means dry: another twenty rooms must not find another leveled slot. + cast(20) + if after := usedSlots(t, uid); after != drained { + t.Fatalf("slots used went %d → %d after the pool was dry — a leveled spell is being cast for free", + drained, after) + } + + // And a dry caster still throws a cantrip rather than reverting to a stick. + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 1000, AC: 14} + if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) { + t.Fatal("an out-of-slots caster cast nothing at all; a cantrip is free") + } +} + +// A hand-queued spell wins. applyPendingCast already owns that cast; auto-casting +// on top of it would fire two spells in one action. +func TestAutoCast_HandQueuedSpellWins(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + uid, c := autocastCleric(t, "queued") + + c.PendingCast = encodePendingCast(PendingCast{SpellID: "guiding_bolt", SlotLevel: 1}) + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 100, AC: 14} + if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) { + t.Fatal("auto-cast fired on top of a hand-queued spell — that is two spells in one action") + } + if used := usedSlots(t, uid); used != 0 { + t.Fatalf("slots used = %d; the auto-cast must not spend anything when the player already queued", used) + } +} + +// A martial has no spellbook and must be left exactly as it was — the balance +// corpus rests on the martials not moving. +func TestAutoCast_MartialIsUntouched(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + s := &SimRunner{P: p} + uid := id.UserID("@autocast-fighter:example.org") + c, err := s.BuildCharacter(uid, ClassFighter, 10) + if err != nil { + t.Fatal(err) + } + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 100, AC: 14} + if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) { + t.Fatal("a fighter cast a spell") + } + if mods.SpellPreDamage != 0 { + t.Fatalf("fighter picked up %d spell pre-damage", mods.SpellPreDamage) + } +} + +// Never upcast in a trash room: the big slots are what the elite and the boss +// are for, and the turn engine spends them there. +func TestAutoCast_DoesNotBurnTheBigSlots(t *testing.T) { + setupEmptyTestDB(t) + uid, c := autocastCleric(t, "native") + + spell, slot, ok := pickAutoResolveSpell(uid, c) + if !ok { + t.Fatal("cleric picked nothing") + } + if slot != spell.Level { + t.Fatalf("%s cast at slot %d, native level %d — an ordinary room must not upcast", + spell.ID, slot, spell.Level) + } +} + +// The boss kit survives the walk in. This is the regression the first cut of §6 +// shipped and the sweep caught: with no cap, a caster spent its whole pool on +// goblins and reached the boss empty — room deaths fell but boss deaths tripled +// (mage 19 → 61, sorcerer 15 → 65). "Never upcast" is not a reserve, because a +// mage's native-level spells ARE its boss kit. +// +// So: grind a full expedition's worth of rooms, and every slot above the cap +// must still be sitting there untouched when the dragon opens its eyes. +func TestAutoCast_ReservesTheBossSlots(t *testing.T) { + for _, class := range []DnDClass{ClassMage, ClassSorcerer, ClassCleric, ClassWarlock} { + t.Run(string(class), func(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + s := &SimRunner{P: p} + uid := id.UserID("@autocast-reserve-" + string(class) + ":example.org") + c, err := s.BuildCharacter(uid, class, 12) + if err != nil { + t.Fatal(err) + } + + // A long expedition: far more rooms than the caster has slots. + for i := 0; i < 60; i++ { + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 1000, AC: 14} + p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) + } + + slots, err := getSpellSlots(uid) + if err != nil { + t.Fatal(err) + } + for lvl, pair := range slots { + if lvl <= roomSlotCap { + continue + } + if pair[1] != 0 { + t.Errorf("level-%d slots: %d of %d spent in ordinary rooms — "+ + "that is the boss's kit, and the caster will arrive empty", + lvl, pair[1], pair[0]) + } + } + }) + } +} diff --git a/internal/plugin/zone_combat_party.go b/internal/plugin/zone_combat_party.go index 4875019..f787da1 100644 --- a/internal/plugin/zone_combat_party.go +++ b/internal/plugin/zone_combat_party.go @@ -51,6 +51,11 @@ func (p *AdventurePlugin) runZoneCombatRoster( advChar *AdventureCharacter equip map[EquipmentSlot]*AdvEquipment mods CombatModifiers + // companion marks the hired NPC seat: it fights, but it owns none of the + // character-scoped effects the close-out loop applies, and its dndChar / + // advChar / equip are nil precisely so a missed guard panics loudly here + // rather than silently writing rows for a bot. + companion bool } var ( @@ -58,6 +63,10 @@ func (p *AdventurePlugin) runZoneCombatRoster( builds []seatBuild seated []id.UserID enemy Combatant + // Parallel to players: what each seat is worth, priced once the roster is + // final. A member who sat the encounter out is in none of these. + levels []int + companions []bool ) for i, uid := range roster { @@ -69,6 +78,29 @@ func (p *AdventurePlugin) runZoneCombatRoster( return PartyCombatResult{}, nil, err } + // The hired companion fights here too — the auto-resolve path is where + // most expedition rooms are actually decided. He is synthesized rather + // than loaded, and his seatBuild is flagged so the close-out loop below + // gives him no XP, no loot, and no post-combat persistence. + if isCompanionSeat(uid) { + expID := companionExpeditionFor(roster[0]) + class, level := companionLoadout(expID) + player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood) + // The wounds he carries into this fight. His synthetic sheet is always + // written at full HP, so applyDnDHPScaling left StartHP at the + // "enter at MaxHP" sentinel and he healed himself for free between every + // room of the run. See companionSeatHP. + player.Stats.StartHP = companionSeatHP(expID, player.Stats.MaxHP) + if leader { + enemy = e + } + players = append(players, player) + builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true}) + seated = append(seated, uid) + levels, companions = append(levels, level), append(companions, true) + continue + } + advChar, err := loadAdvCharacter(uid) if err != nil || advChar == nil { if leader { @@ -120,6 +152,11 @@ func (p *AdventurePlugin) runZoneCombatRoster( // Combatant once, before the fight runs. The queued spell can also debuff // the shared enemy, so every seat's cast lands on the one stat block. applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats) + // §6 — and if they queued nothing, they still cast. This engine has no + // action picker, so before this a caster on autopilot swung a weapon for + // the whole fight and their spellbook may as well not have existed. The + // slot is really spent; see autoCastForAutoResolve. + p.autoCastForAutoResolve(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats) setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods) players = append(players, player) @@ -127,8 +164,21 @@ func (p *AdventurePlugin) runZoneCombatRoster( uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods, }) seated = append(seated, uid) + lvl := 0 + if dndChar != nil { + lvl = dndChar.Level + } + levels, companions = append(levels, lvl), append(companions, false) } + // What each seat costs the enemy, priced against the leader — over the seats + // that were actually seated, so a member left behind is not charged. + ptrs := make([]*Combatant, len(players)) + for i := range players { + ptrs[i] = &players[i] + } + applySeatWeights(ptrs, levels, companions) + res := simulateParty(players, enemy, phases) dumpCombatEventsIfDebug( fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)), @@ -137,6 +187,19 @@ func (p *AdventurePlugin) runZoneCombatRoster( for i, b := range builds { seatRes := res.Seats[i] + // The companion swings and then goes back to filing copy: no inventory to + // deduct from, no sheet to persist to, no XP to earn. Every call below + // would either write rows for a bot or log an error about the rows it + // hasn't got. + // + // His HP is the exception, and it is not bookkeeping — it is the fight's + // result. Without it he re-enters the next room at full while the humans + // beside him carry every wound to camp. + if b.companion { + _ = setCompanionHP(companionExpeditionFor(roster[0]), seatRes.PlayerEndHP) + continue + } + // Remove the actual heal items consumed during combat (one inventory item // per heal_item event this seat fired). Cheapest-tier first. consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes)) @@ -193,6 +256,12 @@ func (p *AdventurePlugin) closeOutZoneWin( ) (leaderDrop string, downed []id.UserID) { party := len(seated) > 1 for i, uid := range seated { + // The companion takes no cut and cannot die. He is not in the downed list + // either: that list is what the room narration mourns, and the party did + // not lose a friend when the hireling took a nap. + if isCompanionSeat(uid) { + continue + } if res.Seats[i].PlayerEndHP > 0 { drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite) if i == 0 { @@ -217,6 +286,12 @@ func (p *AdventurePlugin) closeOutZoneWin( // fight. func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) { for i, uid := range seated { + // The companion is not killed and is not counted among the dead — he is + // not in the graveyard, and a wipe that lists him would have the news bot + // reporting its own funeral. + if isCompanionSeat(uid) { + continue + } if res.Seats[i].PlayerEndHP <= 0 { markAdventureDead(uid, deathSource, zone.Display) killed = append(killed, uid)