Files
gogobee/internal/plugin/combat_party_start_test.go
prosolis fc9e055083 adventure: T6 postgame Layer-2 seam + Aurvandryx's Greed Tax (P8)
Add the pre-combat boss-hook seam for Tier-6 postgame bosses and the first
bespoke mechanic on it.

Seam: applyBossRunModifiers(bossID, enemy, run) in postgame_boss_hooks.go —
a pure, idempotent, bestiary-ID-dispatched hook that folds run-state-derived
adjustments into the freshly-built boss Combatant. No-op for every non-hooked
enemy or nil run. The turn engine (which resolves both prod bosses and the
sim) rebuilds the enemy every round via partyCombatantsForSession, so the hook
must be a pure function of run state — fine at a terminal boss room, where the
route is frozen. Wired at both enemy-finalization points: the per-round rebuild
and buildFightSeats' initial HP persist (threaded a run param; the caller
already had it).

Greed Tax (boss_aurvandryx / first_hoard): her Attack rises with the richness
of the route walked to reach her — the summed excess LootBias (>1.0) over the
run's visited nodes. Note run.LootCollected is the wrong signal (BossOnly
signature manifest, empty at the boss); the gilded veins on the graph are.
Attack += min(2.0*richness, 12). Same-binary A/B sweep (n=150, L20 party +
Pete + pets): taxless 66.7% -> taxed 48.0%, landing first_hoard mid-band.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 07:44:10 -07:00

309 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// N3/P6c — seating the roster, and paying for it.
//
// The invariant every test here defends is the same one: a solo player must be
// unable to tell that parties exist. The seat-0 owner, the burn rate, and the
// opening block are all shapes a solo fight has already written thousands of
// times, and the balance corpus is the receipt.
// ── who owns the fight ───────────────────────────────────────────────────────
// roster[0] is the fight's owner: its lock key and its session row.
// A bare zone run — no expedition row anywhere — must not fall over looking for
// a leader. This is the `!zone enter` path, which predates expeditions entirely.
func TestFightRoster_BareZoneRunOwnsItsOwnFight(t *testing.T) {
setupEmptyTestDB(t)
wanderer := id.UserID("@wanderer:example.org")
if got := fightRoster(wanderer); len(got) != 1 || got[0] != wanderer {
t.Errorf("fightRoster(wanderer) = %v, want just the player themselves", got)
}
}
// The reason the lookup exists: a member's `!fight` opens the *leader's* fight,
// under the leader's lock and on the leader's session row.
func TestFightRoster_MembersFightBelongsToTheLeader(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
for _, who := range []id.UserID{member, leader} {
if got := fightRoster(who)[0]; got != leader {
t.Errorf("fightRoster(%s)[0] = %q, want the leader %q", who, got, leader)
}
}
}
// ── who sits down ────────────────────────────────────────────────────────────
func TestFightRoster_SoloSeatsExactlyOne(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", solo, "active")
roster := fightRoster(solo)
if len(roster) != 1 || roster[0] != solo {
t.Fatalf("solo roster = %v, want [%s]", roster, solo)
}
}
// Seat 0 is the leader whoever typed `!fight`. Every seat-0 invariant in the
// combat layer — the lock key, the once-only close-out effects, the leader-only
// `!flee` — reads the roster's head, so a member-first ordering would flee the
// leader's run on a member's say-so.
func TestFightRoster_LeaderIsAlwaysSeatZero(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
for _, who := range []id.UserID{leader, member} {
roster := fightRoster(who)
if len(roster) != 2 {
t.Fatalf("fightRoster(%s) = %v, want 2 seats", who, roster)
}
if roster[0] != leader {
t.Errorf("fightRoster(%s) seats %q at 0, want the leader", who, roster[0])
}
}
}
// ── who actually gets a seat ─────────────────────────────────────────────────
// fightTestChar creates the full character stack buildZoneCombatants reads:
// the adventure character, then the D&D sheet its HP snapshot comes from.
func fightTestChar(t *testing.T, uid id.UserID, hp int) {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
HPMax: 30, HPCurrent: hp, ArmorClass: 14,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
}
func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
fightTestChar(t, solo, 30)
seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" || skip != "" {
t.Fatalf("solo fight refused: %s / %s", refusal, skip)
}
if len(seats) != 1 || seats[0].UserID != solo {
t.Fatalf("seats = %+v, want exactly the solo player", seats)
}
if seats[0].HP != 30 || seats[0].HPMax != 30 {
t.Errorf("seat HP = %d/%d, want 30/30", seats[0].HP, seats[0].HPMax)
}
if seats[0].C == nil || seats[0].C.Name == "" {
t.Errorf("seat carries no built combatant: %+v", seats[0])
}
if enemy == nil || enemy.Name == "" || enemy.Stats.MaxHP <= 0 {
t.Errorf("enemy not built: %+v", enemy)
}
}
// A downed member is not a blocked party: they sit the fight out, and the seats
// that remain still line up leader-first.
func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
standing := id.UserID("@standing:example.org")
downed := id.UserID("@downed:example.org")
fightTestChar(t, leader, 30)
fightTestChar(t, standing, 25)
fightTestChar(t, downed, 0)
roster := []id.UserID{leader, downed, standing}
seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, roster, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" {
t.Fatalf("party refused over a downed member: %s", refusal)
}
if skip != "" {
t.Errorf("the leader was seated, so nothing was skipped on their behalf: %q", skip)
}
if len(seats) != 2 {
t.Fatalf("seated %d, want the leader and the standing member", len(seats))
}
if seats[0].UserID != leader || seats[1].UserID != standing {
t.Errorf("seats = [%s %s], want [leader standing]", seats[0].UserID, seats[1].UserID)
}
// The one who was left behind typed `!fight` too, and silence is not an answer.
_, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats(
downed, roster, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" {
t.Fatalf("a downed member must not refuse the party's fight: %s", refusal)
}
if !strings.Contains(skip, "`!rest`") {
t.Errorf("the downed member should be told to rest, got %q", skip)
}
}
// Seat 0 is not optional, and the copy depends on who is reading it.
func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
fightTestChar(t, leader, 0)
fightTestChar(t, member, 25)
roster := []id.UserID{leader, member}
p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0, nil)
if len(seats) != 0 || refusal == "" {
t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal)
}
if !strings.Contains(refusal, "`!rest`") {
t.Errorf("the leader should be told to rest, got %q", refusal)
}
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0, nil)
if !strings.Contains(refusal, "leader") {
t.Errorf("the member should be told it is the leader holding things up, got %q", refusal)
}
if strings.Contains(refusal, "`!rest`") {
t.Errorf("the member cannot rest on the leader's behalf, got %q", refusal)
}
}
// ── the opening block ────────────────────────────────────────────────────────
// initiativeNames walks the turn order, which carries the enemy as sentinel seat
// -1. Indexing players with it would panic on every party's first `!fight`.
func TestInitiativeNames_RendersTheEnemySentinelNotAPanic(t *testing.T) {
players, enemy := biasedParty()
sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
names := initiativeNames(players, sess, enemy)
if len(names) != 4 {
t.Fatalf("order = %v, want 3 players + the enemy", names)
}
// biasedParty stacks initiative 300/200/100 against a speed-1 enemy.
if want := []string{"Ada", "Bram", "Cass", enemy.Name}; strings.Join(names, ",") != strings.Join(want, ",") {
t.Errorf("order = %v, want %v", names, want)
}
}
func TestInitiativeNames_SoloIsPlayerThenEnemy(t *testing.T) {
p := basePlayer()
e := baseEnemy()
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
if got := initiativeNames([]*Combatant{&p}, sess, &e); len(got) != 2 || got[1] != e.Name {
t.Errorf("solo order = %v, want [player, enemy]", got)
}
}
// ── what the party eats ──────────────────────────────────────────────────────
// The rate a solo expedition burns at is a tuned constant that the whole
// difficulty corpus (Phase 3-B / 5-B) was measured against. It must come out of
// the party-aware path untouched.
func TestExpeditionBurnRatePct_SoloIsTheShippedRate(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", solo, "active")
if got := expeditionBurnRatePct("exp-solo"); got != phase5BDailyBurnRatePct {
t.Errorf("solo burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct)
}
// An expedition with no roster row at all is the pre-N3 world: same answer.
if got := expeditionBurnRatePct("exp-never-seen"); got != phase5BDailyBurnRatePct {
t.Errorf("rosterless burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct)
}
}
// N × 0.8: a party eats more than one player and less than N of them.
func TestExpeditionBurnRatePct_PartyEatsMoreButNotProRata(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
want := map[int]int{1: 50, 2: 80, 3: 120}
for seat := 1; seat <= 2; seat++ {
if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil {
t.Fatal(err)
}
size := seat + 1
if got := expeditionBurnRatePct("exp-1"); got != want[size] {
t.Errorf("party of %d burns at %d%%, want %d%%", size, got, want[size])
}
}
}
// Bit-identity, not approximate agreement: a solo expedition's supplies snapshot
// after the party-aware burn must equal the one applyDailyBurn produced.
func TestApplyExpeditionDailyBurn_SoloMatchesApplyDailyBurnExactly(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", solo, "active")
supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1.5}
e := &Expedition{ID: "exp-solo", UserID: string(solo), Supplies: supplies}
for _, tc := range []struct{ harsh, siege bool }{{false, false}, {true, false}, {false, true}} {
wantS, wantBurn := applyDailyBurn(supplies, tc.harsh, tc.siege)
gotS, gotBurn := applyExpeditionDailyBurn(e, tc.harsh, tc.siege)
if gotBurn != wantBurn || gotS != wantS {
t.Errorf("harsh=%v siege=%v: got (%v, %g), want (%v, %g)",
tc.harsh, tc.siege, gotS, gotBurn, wantS, wantBurn)
}
}
}
func TestApplyExpeditionDailyBurn_PartyOfThreeBurnsTwoPointFourShares(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
for seat := 1; seat <= 2; seat++ {
if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil {
t.Fatal(err)
}
}
supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1}
e := &Expedition{ID: "exp-1", UserID: string(leader), Supplies: supplies}
_, solo := applyDailyBurn(supplies, false, false)
_, party := applyExpeditionDailyBurn(e, false, false)
// DailyBurn 3 at the party-of-3 rate of 120% — exactly 2.4 solo shares, and
// exactly the value the int rate yields. A float32 0.8 would land at 3.6000001.
if want := float32(3.6); party != want {
t.Errorf("party of 3 burned %v SU, want %v", party, want)
}
if party >= solo*3 {
t.Errorf("party of 3 burned %g, which is no better than three solo runs (%g)", party, solo*3)
}
if party <= solo {
t.Errorf("party of 3 burned %g, no more than one player alone (%g)", party, solo)
}
}