From d538f91cf72c42cf0a7add9b108edd3f69d95052 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:39:01 -0700 Subject: [PATCH] Combat engine: pay off the N-body debt, and hire Pete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N3 widened the combat *roster* from 1 to N but never widened the action model, the scaling model, or the test net to match. Building the hireable companion walked into all three. Only one of the four defects found was the companion's; the rest have been live in prod for every human party since N3. The party golden did not exist (§5) Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had nothing. That is why a healer class that cannot heal shipped without a test going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl. weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the N-body path can never quietly stop being a superset of the balance corpus. Regenerate only on purpose: -update-party. No action could target another seat (§1) Every heal in the engine was self-scoped. A party cleric could not put one hit point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast @user` and `--target @user` -- the latter has been advertised in !help and silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto picker uses it too (simPickAllyHeal), so away-players and engine-driven healers behave like competent ones. It will not raise the dead. Corpses kept buffing the boss (§2b) enemyActionsThisRound counted len(st.actors), dead included -- so a party that lost a member kept paying for them, and the survivor faced a boss still swinging at two-player cadence, alone. A death spiral with the arrow pointing the wrong way. Now counts livingActors(). Party golden moved deliberately for this. An engine-driven seat was a bool any command could clear (§3) autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so a companion's own auto-played move arrived at beginCombatTurn looking like a player returning to the keyboard and cleared the latch that was moving him. He then stood in the fight doing nothing while the boss he had inflated killed everyone. ActorStatuses.EngineDriven is now a persisted seat property that no command clears, and the driver calls driveEngineSeat instead of impersonating. "The party" could be empty (§4) A solo expedition has no expedition_party rows, so asking the roster who was in the party answered "nobody" -- and every caller fell back to something plausible. That is how the companion got hired at level 1 for exactly the player the feature exists for. expeditionParty()/partyHumans() always include the owner. The companion himself (!expedition hire [class] / !expedition dismiss) Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an NPC seat and must never become a player: no player_meta, no dnd_character, no inventory, no DM room -- mint him a player_meta row and ensureDnDCharacterForCombat will auto-build the news bot a real character, and he starts appearing in the graveyard and filing death notices about himself. Mail and seats are different sets: he fights, he does not get written to. Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for trailing players and +2.0pp for leaders. Help, never a carry. The solo golden is byte-identical throughout: solo combat provably did not move, and the balance corpus is intact. Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB and he has none by design), so a hired Cleric is still just a bad fighter. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy --- cmd/expedition-sim/main.go | 23 +- internal/db/db.go | 9 + internal/plugin/adventure_companion.go | 554 +++++ internal/plugin/adventure_companion_test.go | 309 +++ internal/plugin/combat_ally_heal_test.go | 174 ++ internal/plugin/combat_cmd.go | 95 + internal/plugin/combat_engine.go | 9 +- internal/plugin/combat_engine_party.go | 25 +- .../combat_party_characterization_test.go | 159 ++ internal/plugin/combat_party_finish.go | 30 +- internal/plugin/combat_party_start.go | 29 +- internal/plugin/combat_party_turn.go | 42 + internal/plugin/combat_session.go | 55 +- internal/plugin/combat_session_build.go | 23 + internal/plugin/combat_turn_engine.go | 32 + internal/plugin/dnd_expedition_cmd.go | 8 +- internal/plugin/expedition_companion_cmd.go | 115 + internal/plugin/expedition_party.go | 122 +- internal/plugin/expedition_party_cmd.go | 7 + internal/plugin/expedition_party_resolve.go | 34 +- internal/plugin/expedition_sim.go | 159 +- .../testdata/party_characterization.golden | 1908 +++++++++++++++++ internal/plugin/zone_combat_party.go | 41 + 23 files changed, 3924 insertions(+), 38 deletions(-) create mode 100644 internal/plugin/adventure_companion.go create mode 100644 internal/plugin/adventure_companion_test.go create mode 100644 internal/plugin/combat_ally_heal_test.go create mode 100644 internal/plugin/combat_party_characterization_test.go create mode 100644 internal/plugin/expedition_companion_cmd.go create mode 100644 internal/plugin/testdata/party_characterization.golden 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..c59ce5d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -401,6 +401,15 @@ 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`, } 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..0b83afa --- /dev/null +++ b/internal/plugin/adventure_companion.go @@ -0,0 +1,554 @@ +package plugin + +import ( + "database/sql" + "errors" + "fmt" + "os" + "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 +} + +// 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..fd5afdf 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -526,10 +526,91 @@ 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)) + // §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 + } + advChar, _ := loadAdvCharacter(uid) c, err := p.ensureCharForDnDCmd(uid, advChar) if err != nil || c == nil { @@ -612,6 +693,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 diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 340ee07..ad6b285 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -220,7 +220,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..19f4e7d 100644 --- a/internal/plugin/combat_engine_party.go +++ b/internal/plugin/combat_engine_party.go @@ -307,8 +307,20 @@ 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) + n := livingActors(st) if n < 2 { return 1 } @@ -320,6 +332,17 @@ 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 +} + // 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 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..2e6ed97 100644 --- a/internal/plugin/combat_party_finish.go +++ b/internal/plugin/combat_party_finish.go @@ -55,6 +55,14 @@ 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 to persist HP onto and no bookkeeping that + // outlives the fight — he arrives fresh next time. Skipping him here is + // not just tidiness: postCombatBookkeepingForSeat logs at ERROR when a + // seat has no sheet, so leaving him in would file an error for every + // party fight he is ever hired for. + if isCompanionUser(sess.seatUserID(seat)) { + continue + } persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat)) p.postCombatBookkeepingForSeat(sess, seat) } @@ -100,6 +108,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 +166,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") diff --git a/internal/plugin/combat_party_start.go b/internal/plugin/combat_party_start.go index c2f715a..750ced8 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 @@ -59,6 +61,25 @@ func (p *AdventurePlugin) buildFightSeats( 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) { + class, level := companionLoadout(companionExpeditionFor(roster[0])) + player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood) + seats = append(seats, CombatSeatSetup{ + UserID: uid, HP: player.Stats.MaxHP, HPMax: player.Stats.MaxHP, + Mods: player.Mods, C: &player, EngineDriven: 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. @@ -159,6 +180,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_session.go b/internal/plugin/combat_session.go index 827ab29..49b13b3 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) } @@ -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..38f8a88 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -160,6 +160,29 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com // 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) + + // 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 + 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 + if sess.IsParty() { + enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize()) + } + } + continue + } + player, e, _, 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) 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_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/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..6e1d4a3 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 { @@ -1157,6 +1225,14 @@ 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. + if id, target := simPickAllyHeal(c, uid, sess, seat); 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 — @@ -1261,6 +1337,85 @@ 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. +// +// 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 + +// simPickAllyHeal returns the heal spell and target name a competent healer would +// use on the worst-hurt *other* seat, or ("", "") if nobody needs it, the caster +// cannot heal, or the fight is solo. +// +// This is the picker half of §1. Before the engine could target another seat there +// was nothing for it to choose, so an autopiloted or engine-driven healer simply +// swung a weapon while their friends died beside them. +func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) { + if sess == nil || !sess.IsParty() || 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() { + if i == seat { + continue + } + 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 "", "" + } + + // The cheapest heal that is actually castable — burning a 5th-level slot to + // top somebody up is not what a competent player does. + known, err := listKnownSpells(uid) + if err != nil { + return "", "" + } + slots, _ := getSpellSlots(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 + } + } + 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() +} + func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string { known, err := listKnownSpells(uid) if err != nil || len(known) == 0 { 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_party.go b/internal/plugin/zone_combat_party.go index 4875019..ffd5a2d 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 ( @@ -69,6 +74,22 @@ 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) { + class, level := companionLoadout(companionExpeditionFor(roster[0])) + player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood) + if leader { + enemy = e + } + players = append(players, player) + builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true}) + seated = append(seated, uid) + continue + } + advChar, err := loadAdvCharacter(uid) if err != nil || advChar == nil { if leader { @@ -137,6 +158,14 @@ 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. + if b.companion { + 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 +222,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 +252,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)