mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
`!fight` seats the expedition's roster instead of the one player who typed it. Seat 0 is the leader, always: the session row is theirs, the lock is theirs, and `!flee`, the fork, and `!extract` stay their call. A monster that wins initiative now swings before anyone speaks. The session layer used to park every new fight on a player_turn, which is true of the hardcoded solo order and a lie about a party's -- the enemy would forfeit round 1 and nobody would notice. `startPartyCombatSession` rolls the order and sets the phase from it; `handleFightCmd` settles the round before it announces, so the opening block narrates the hit rather than quietly showing its damage. Members were invisible to two commands that had no business ignoring them: `!cast` queued a spell for "next combat" while its caster was standing in one, and `!rest` healed a seated member to full mid boss fight. Both now resolve through the party. Nobody leaves without an answer. A downed member's `!fight` opens the party's fight and tells them why they are not in it. The leader's `!extract` reaches everyone it drags out of the dungeon, and everyone rolls for what moved into their house while they were gone. Supplies burn at 50% x N x 4/5 -- a party eats more than one and less than N. The ratio is exact: 0.8 as a float truncates a party of three to 119%, a permanent tax nobody would have found. Solo is untouched, byte for byte. One seat means one build, one INSERT, no participant rows, the same RNG draws in the same order -- the combat characterization golden does not move, and neither does the balance corpus.
538 lines
20 KiB
Go
538 lines
20 KiB
Go
package plugin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// N3/P5 — the session layer.
|
|
//
|
|
// Everything here exercises a seated party, which production cannot yet build:
|
|
// P6 supplies the invite. The point of testing it now is that P5's mistakes are
|
|
// silent ones — a buff landing on the wrong sheet, a latch that resets on every
|
|
// step, a solo row that grew a key — and none of them announce themselves.
|
|
|
|
// ── Seat addressing ──────────────────────────────────────────────────────────
|
|
|
|
// partySession builds a 3-seat roster in memory, with each seat's HP and
|
|
// statuses distinguishable so a mix-up cannot pass.
|
|
func partySession(phase string, seatHP ...int) *CombatSession {
|
|
s := turnSession(phase, seatHP[0], 100)
|
|
s.UserID = "@leader:x"
|
|
for i, hp := range seatHP[1:] {
|
|
s.Participants = append(s.Participants, CombatParticipant{
|
|
Seat: i + 1, UserID: memberID(i + 1), HP: hp, HPMax: hp + 10,
|
|
})
|
|
}
|
|
s.rosterSize = len(seatHP)
|
|
return s
|
|
}
|
|
|
|
func memberID(seat int) string {
|
|
return string(rune('a'+seat-1)) + "member:x"
|
|
}
|
|
|
|
func TestSeatAccessors_AddressTheRightRow(t *testing.T) {
|
|
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
|
s.PlayerHPMax = 90
|
|
|
|
for seat, want := range []int{40, 55, 70} {
|
|
if got := s.seatHP(seat); got != want {
|
|
t.Errorf("seatHP(%d) = %d, want %d", seat, got, want)
|
|
}
|
|
}
|
|
if got := s.seatHPMax(0); got != 90 {
|
|
t.Errorf("seatHPMax(0) = %d, want the session row's 90", got)
|
|
}
|
|
if got := s.seatHPMax(2); got != 80 {
|
|
t.Errorf("seatHPMax(2) = %d, want the participant row's 80", got)
|
|
}
|
|
if got := s.seatUserID(0); got != "@leader:x" {
|
|
t.Errorf("seatUserID(0) = %q, want the session owner", got)
|
|
}
|
|
if got := s.seatUserID(1); got != memberID(1) {
|
|
t.Errorf("seatUserID(1) = %q, want %q", got, memberID(1))
|
|
}
|
|
}
|
|
|
|
func TestSeatOf_FindsMembersAndRejectsStrangers(t *testing.T) {
|
|
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
|
|
|
if seat, ok := s.seatOf("@leader:x"); !ok || seat != 0 {
|
|
t.Errorf("seatOf(leader) = (%d,%v), want (0,true)", seat, ok)
|
|
}
|
|
if seat, ok := s.seatOf(id.UserID(memberID(2))); !ok || seat != 2 {
|
|
t.Errorf("seatOf(member 2) = (%d,%v), want (2,true)", seat, ok)
|
|
}
|
|
if _, ok := s.seatOf("@nobody:x"); ok {
|
|
t.Error("seatOf(stranger) reported a seat — an outsider could act in the fight")
|
|
}
|
|
}
|
|
|
|
// A member's mid-fight buff must land on their own sheet. Before P5 the cast
|
|
// path folded every delta into the session's embedded ActorStatuses — seat 0 —
|
|
// so a party member casting Shield on themselves armoured the leader instead.
|
|
func TestActorStatusesPtr_WritesToTheCastingSeat(t *testing.T) {
|
|
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
|
|
|
s.actorStatusesPtr(2).applyBuffDelta(turnBuffDelta{dAC: 5})
|
|
|
|
if got := s.Statuses.BuffACBonus; got != 0 {
|
|
t.Errorf("seat 0 picked up seat 2's buff: BuffACBonus = %d, want 0", got)
|
|
}
|
|
if got := s.Participants[1].Statuses.BuffACBonus; got != 5 {
|
|
t.Errorf("seat 2's BuffACBonus = %d, want 5", got)
|
|
}
|
|
}
|
|
|
|
// ── The autopilot latch ──────────────────────────────────────────────────────
|
|
|
|
// The latch is per-seat session state with no combatState counterpart, so it
|
|
// rides through the engine on snapshotActor's carry-over of the prior snapshot —
|
|
// the same road the Buff* deltas take. If it did not, an away member would be
|
|
// handed the wheel back on every single step and the party would stall anew each
|
|
// round.
|
|
func TestSnapshotActor_CarriesTheAutopilotLatchAcrossAStep(t *testing.T) {
|
|
c := basePlayer()
|
|
prior := ActorStatuses{Autopilot: true, BuffACBonus: 3}
|
|
|
|
got := snapshotActor(newActor(&c), prior)
|
|
|
|
if !got.Autopilot {
|
|
t.Error("snapshotActor dropped the autopilot latch — an away member unlatches every step")
|
|
}
|
|
if got.BuffACBonus != 3 {
|
|
t.Errorf("snapshotActor dropped a carried-over buff: BuffACBonus = %d, want 3", got.BuffACBonus)
|
|
}
|
|
}
|
|
|
|
func TestSeatNeedsNoHuman(t *testing.T) {
|
|
s := partySession(CombatPhasePlayerTurn, 40, 0, 70)
|
|
s.Participants[1].Statuses.Autopilot = true
|
|
|
|
if s.seatNeedsNoHuman(0) {
|
|
t.Error("a standing, unlatched seat needs its human")
|
|
}
|
|
if !s.seatNeedsNoHuman(1) {
|
|
t.Error("a downed seat must not block the round")
|
|
}
|
|
if !s.seatNeedsNoHuman(2) {
|
|
t.Error("a latched seat must resolve without waiting")
|
|
}
|
|
}
|
|
|
|
// ── Wire compatibility ───────────────────────────────────────────────────────
|
|
|
|
// Two fields landed on persisted structs in P5. Both must vanish from a solo
|
|
// row, which is every row prod holds today: statuses_json and turn_log_json are
|
|
// decoded by readers that predate them, and a fight in flight across the deploy
|
|
// must resume byte-identically.
|
|
func TestP5Fields_StayOffSoloRows(t *testing.T) {
|
|
raw, err := json.Marshal(CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), "autopilot") {
|
|
t.Errorf("a solo statuses_json carries the autopilot key: %s", raw)
|
|
}
|
|
|
|
raw, err = json.Marshal(CombatEvent{Actor: "player", Action: "hit", Damage: 4})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), "Seat") {
|
|
t.Errorf("a solo turn_log event carries the Seat key: %s", raw)
|
|
}
|
|
|
|
// …and both must survive the round trip when they are set.
|
|
raw, _ = json.Marshal(ActorStatuses{Autopilot: true})
|
|
var back ActorStatuses
|
|
if err := json.Unmarshal(raw, &back); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !back.Autopilot {
|
|
t.Errorf("autopilot did not round-trip: %s", raw)
|
|
}
|
|
}
|
|
|
|
// ── Event attribution ────────────────────────────────────────────────────────
|
|
|
|
// biasedParty builds a roster whose initiative order is fixed at [0,1,2,enemy],
|
|
// so a test can park the cursor on a known seat.
|
|
func biasedParty() ([]*Combatant, *Combatant) {
|
|
a, b, c := basePlayer(), basePlayer(), basePlayer()
|
|
a.Name, b.Name, c.Name = "Ada", "Bram", "Cass"
|
|
a.Mods.InitiativeBias, b.Mods.InitiativeBias, c.Mods.InitiativeBias = 300, 200, 100
|
|
e := baseEnemy()
|
|
e.Stats.Speed = 1
|
|
return []*Combatant{&a, &b, &c}, &e
|
|
}
|
|
|
|
func TestTurnEngine_StampsEventsWithTheSeatThatActed(t *testing.T) {
|
|
players, enemy := biasedParty()
|
|
sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
|
|
sess.Statuses.TurnIdx = 1 // Bram's turn
|
|
|
|
events, err := partyStep(sess, players, enemy, PlayerAction{Kind: ActionAttack})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) == 0 {
|
|
t.Fatal("Bram's turn produced no events")
|
|
}
|
|
for _, e := range events {
|
|
if e.Seat != 1 {
|
|
t.Errorf("event %+v stamped seat %d, want 1 (Bram swung)", e, e.Seat)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Solo events must all be seat 0 — that is what keeps the field omitted from
|
|
// every solo turn_log_json.
|
|
func TestTurnEngine_SoloEventsAreAllSeatZero(t *testing.T) {
|
|
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
|
p, e := basePlayer(), baseEnemy()
|
|
|
|
events, err := partyStep(sess, []*Combatant{&p}, &e, PlayerAction{Kind: ActionAttack})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, ev := range events {
|
|
if ev.Seat != 0 {
|
|
t.Errorf("solo event %+v stamped seat %d, want 0", ev, ev.Seat)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The flavor pool speaks in the second person. A round must therefore read
|
|
// differently to each member: your own swing is "You score 9 damage", your
|
|
// ally's is "**Cass** hits Rat for 9". Getting this backwards tells three people
|
|
// they each personally landed the same blow.
|
|
func TestRenderPartyTurnRound_SecondPersonForTheReaderThirdForAllies(t *testing.T) {
|
|
events := []CombatEvent{
|
|
{Actor: "player", Action: "hit", Damage: 7, Seat: 0},
|
|
{Actor: "player", Action: "hit", Damage: 9, Seat: 2},
|
|
}
|
|
names := []string{"Ada", "Bram", "Cass"}
|
|
|
|
// The reader's own line comes from the flavor pool, whose phrasings vary
|
|
// ("You score 9 damage", "A measured strike. 9 damage."). What is invariant
|
|
// is that it never names them in the third person, and that the ally's line
|
|
// always does.
|
|
ada := RenderPartyTurnRound(events, names, "Rat", 0)
|
|
if strings.Contains(ada, "**Ada**") {
|
|
t.Errorf("Ada's own swing was rendered in the third person:\n%s", ada)
|
|
}
|
|
if !strings.Contains(ada, "**Cass** hits Rat for 9.") {
|
|
t.Errorf("Ada should read Cass's swing in the third person:\n%s", ada)
|
|
}
|
|
if strings.Contains(ada, "Bram") {
|
|
t.Errorf("Bram did nothing this round but was named:\n%s", ada)
|
|
}
|
|
|
|
cass := RenderPartyTurnRound(events, names, "Rat", 2)
|
|
if strings.Contains(cass, "**Cass**") {
|
|
t.Errorf("Cass's own swing was rendered in the third person:\n%s", cass)
|
|
}
|
|
if !strings.Contains(cass, "**Ada** hits Rat for 7.") {
|
|
t.Errorf("Cass should read Ada's swing in the third person:\n%s", cass)
|
|
}
|
|
}
|
|
|
|
// A downed ally is the one thing a member must not miss in their own DM.
|
|
func TestRenderAllySeatEvent_FlagsAnAllyGoingDown(t *testing.T) {
|
|
got := renderAllySeatEvent(
|
|
CombatEvent{Actor: "enemy", Action: "hit", Damage: 12, PlayerHP: 0, Seat: 1}, "Bram", "Rat")
|
|
if !strings.Contains(got, "is down") {
|
|
t.Errorf("an ally dropping to 0 HP should be called out, got %q", got)
|
|
}
|
|
|
|
standing := renderAllySeatEvent(
|
|
CombatEvent{Actor: "enemy", Action: "hit", Damage: 3, PlayerHP: 20, Seat: 1}, "Bram", "Rat")
|
|
if strings.Contains(standing, "is down") {
|
|
t.Errorf("a standing ally was reported down: %q", standing)
|
|
}
|
|
}
|
|
|
|
// An event whose seat has fallen off the roster falls back to seat 0 rather
|
|
// than panicking on a replayed turn_log.
|
|
func TestRenderPartyTurnRound_OutOfRangeSeatFallsBack(t *testing.T) {
|
|
got := RenderPartyTurnRound(
|
|
[]CombatEvent{{Actor: "player", Action: "hit", Damage: 3, Seat: 9}}, []string{"Ada"}, "Rat", 0)
|
|
if got == "" || strings.Contains(got, "without a clean blow") {
|
|
t.Errorf("out-of-range seat should still render a line, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// ── The turn deadline ────────────────────────────────────────────────────────
|
|
|
|
func TestTurnDeadlineLapsed(t *testing.T) {
|
|
fresh := time.Now().UTC()
|
|
stale := fresh.Add(-partyTurnDeadline - time.Second)
|
|
|
|
cases := []struct {
|
|
name string
|
|
phase string
|
|
at time.Time
|
|
want bool
|
|
}{
|
|
{"fresh player turn", CombatPhasePlayerTurn, fresh, false},
|
|
{"stale player turn", CombatPhasePlayerTurn, stale, true},
|
|
// The enemy's turn belongs to the engine, not to a player who might be
|
|
// away — a fight parked there is mid-resolution, not stalled on anyone.
|
|
{"stale enemy turn", CombatPhaseEnemyTurn, stale, false},
|
|
{"stale round end", CombatPhaseRoundEnd, stale, false},
|
|
}
|
|
for _, tc := range cases {
|
|
s := partySession(tc.phase, 40, 40)
|
|
s.LastActionAt = tc.at
|
|
if got := turnDeadlineLapsed(s); got != tc.want {
|
|
t.Errorf("%s: turnDeadlineLapsed = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Locking ──────────────────────────────────────────────────────────────────
|
|
|
|
// A solo fight's owner is the sender. sync.Mutex is not reentrant, so taking the
|
|
// fight lock and then the user lock would deadlock the very command every solo
|
|
// player types. This test hangs rather than fails if that regresses.
|
|
func TestLockCombatFight_SoloTakesExactlyOneLock(t *testing.T) {
|
|
p := &AdventurePlugin{}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
release := p.lockCombatFight("@solo:x", "@solo:x")
|
|
release()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("lockCombatFight self-deadlocked on a solo fight")
|
|
}
|
|
}
|
|
|
|
// A party member's command holds both the fight's lock and their own, so no
|
|
// other command of theirs races the round they are resolving.
|
|
func TestLockCombatFight_PartyHoldsBothLocks(t *testing.T) {
|
|
p := &AdventurePlugin{}
|
|
release := p.lockCombatFight("@leader:x", "@member:x")
|
|
|
|
if p.advUserLock("@leader:x").TryLock() {
|
|
t.Error("the fight's lock (seat 0) was not held")
|
|
}
|
|
if p.advUserLock("@member:x").TryLock() {
|
|
t.Error("the acting member's own lock was not held")
|
|
}
|
|
release()
|
|
|
|
if !p.advUserLock("@leader:x").TryLock() {
|
|
t.Error("release() left the fight's lock held")
|
|
}
|
|
if !p.advUserLock("@member:x").TryLock() {
|
|
t.Error("release() left the member's lock held")
|
|
}
|
|
}
|
|
|
|
func TestNotYourTurnMsg_NamesWhoWeAreWaitingOn(t *testing.T) {
|
|
players, _ := biasedParty()
|
|
if got := notYourTurnMsg(players, 1, true); !strings.Contains(got, "Bram") {
|
|
t.Errorf("message should name the acting player, got %q", got)
|
|
}
|
|
if got := notYourTurnMsg(players, 0, false); strings.Contains(got, "Ada") {
|
|
t.Errorf("nobody is on the clock mid-resolution, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ── Starting a fight ─────────────────────────────────────────────────────────
|
|
|
|
// enemyWithHP is the monster the seats below face. Only its HP pool and its
|
|
// Speed matter here: the pool sizes the fight, the speed feeds initiative.
|
|
func enemyWithHP(hp int) *Combatant {
|
|
e := baseEnemy()
|
|
e.Stats.MaxHP = hp
|
|
return &e
|
|
}
|
|
|
|
// partySeats pairs each combatant with a seat setup, as buildFightSeats does.
|
|
func partySeats(players []*Combatant, hp int) []CombatSeatSetup {
|
|
seats := make([]CombatSeatSetup, len(players))
|
|
for i, c := range players {
|
|
seats[i] = CombatSeatSetup{UserID: id.UserID(memberID(i)), HP: hp, HPMax: hp, C: c}
|
|
}
|
|
seats[0].UserID = "@leader:x"
|
|
return seats
|
|
}
|
|
|
|
func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
|
|
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
|
|
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sess.IsParty() || sess.RosterSize() != 1 {
|
|
t.Fatalf("solo session reports a roster of %d", sess.RosterSize())
|
|
}
|
|
ps, err := loadCombatParticipants(sess.SessionID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ps) != 0 {
|
|
t.Errorf("solo fight wrote %d participant rows, want 0", len(ps))
|
|
}
|
|
}
|
|
|
|
// Each seat's fight-start one-shots are read off their own combatant. A party
|
|
// Abjurer brings their own Arcane Ward; the leader does not inherit it.
|
|
func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
|
|
leader, abjurer := basePlayer(), basePlayer()
|
|
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60), []CombatSeatSetup{
|
|
{UserID: "@leader:x", HP: 40, HPMax: 40, C: &leader},
|
|
{UserID: "@abjurer:x", HP: 30, HPMax: 30, C: &abjurer,
|
|
Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sess.IsParty() || sess.RosterSize() != 2 {
|
|
t.Fatalf("roster size = %d, want 2", sess.RosterSize())
|
|
}
|
|
if sess.Statuses.ArcaneWardHP != 0 {
|
|
t.Errorf("the leader inherited the abjurer's ward: %d", sess.Statuses.ArcaneWardHP)
|
|
}
|
|
|
|
// …and it must be there after a round trip through the DB.
|
|
back, err := getCombatSession(sess.SessionID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := back.actorStatusesForSeat(1); got.ArcaneWardHP != 12 || got.WardCharges != 2 {
|
|
t.Errorf("seat 1 statuses after reload = %+v, want ArcaneWardHP 12 / WardCharges 2", got)
|
|
}
|
|
if back.seatHP(1) != 30 || back.seatUserID(1) != "@abjurer:x" {
|
|
t.Errorf("seat 1 = %d HP / %s", back.seatHP(1), back.seatUserID(1))
|
|
}
|
|
}
|
|
|
|
// A monster that wins initiative swings first. startCombatSession parks every
|
|
// fight on a player_turn, which is right for the hardcoded solo order and wrong
|
|
// for a party: the resume path would snap the cursor to the first player slot
|
|
// and the enemy would silently forfeit round 1.
|
|
func TestStartPartyCombatSession_EnemyThatWinsInitiativeOpensTheRound(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
players, enemy := biasedParty()
|
|
// Invert the bias biasedParty stacks: the monster now outruns everyone.
|
|
for _, c := range players {
|
|
c.Mods.InitiativeBias = -1000
|
|
}
|
|
enemy.Stats.Speed = 500
|
|
|
|
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
|
|
partySeats(players, 100))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sess.Phase != CombatPhaseEnemyTurn {
|
|
t.Fatalf("round 1 opened on phase %q; the enemy won initiative and must act first", sess.Phase)
|
|
}
|
|
if order := turnOrder(sess, 1, players, enemy); order[0] != enemySeat {
|
|
t.Fatalf("turn order = %v, want the enemy at the head", order)
|
|
}
|
|
|
|
// And the phase survives the round trip, since a resume reads it back.
|
|
back, err := getCombatSession(sess.SessionID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.Phase != CombatPhaseEnemyTurn {
|
|
t.Errorf("reloaded phase = %q, want the enemy still on the clock", back.Phase)
|
|
}
|
|
}
|
|
|
|
// The other half of the same rule: when the party wins, nothing moved.
|
|
func TestStartPartyCombatSession_PartyThatWinsInitiativeStillOpensOnAPlayer(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
players, enemy := biasedParty()
|
|
|
|
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
|
|
partySeats(players, 100))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sess.Phase != CombatPhasePlayerTurn || sess.Statuses.TurnIdx != 0 {
|
|
t.Fatalf("round 1 opened on %q/%d, want player_turn at the head of the order",
|
|
sess.Phase, sess.Statuses.TurnIdx)
|
|
}
|
|
}
|
|
|
|
// ── The round driver ─────────────────────────────────────────────────────────
|
|
|
|
// The old loop rested on any player_turn. A party's downed seat still holds a
|
|
// player_turn, so the round would come to rest on a corpse and the fight would
|
|
// wait forever for a dead member to type `!attack`.
|
|
func TestSettleCombatSession_StepsPastADownedSeat(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
players, enemy := biasedParty()
|
|
|
|
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
|
|
partySeats(players, 100))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Bram is down, and it is his turn. biasedParty stacks initiative so the
|
|
// party leads the round; force the phase back to it in case the roll disagrees.
|
|
sess.Participants[0].HP = 0
|
|
sess.Phase = CombatPhasePlayerTurn
|
|
sess.Statuses.TurnIdx = 1
|
|
|
|
if _, err := settleCombatSession(sess, players, enemy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sess.IsActive() {
|
|
t.Fatalf("fight ended; two members were still standing (status %q)", sess.Status)
|
|
}
|
|
seat, waiting := actingSeat(sess, players, enemy)
|
|
if !waiting {
|
|
t.Fatal("settle came to rest on no player's turn")
|
|
}
|
|
if seat != 2 {
|
|
t.Errorf("settled on seat %d, want seat 2 — seat 1 is down and forfeits", seat)
|
|
}
|
|
}
|
|
|
|
// Settling a session already parked on a standing player's turn must do nothing
|
|
// at all. Every solo fight sits exactly there between commands, and a stray step
|
|
// would resolve a round the player never asked for.
|
|
func TestSettleCombatSession_IsANoOpOnASoloPlayerTurn(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
p, e := basePlayer(), baseEnemy()
|
|
|
|
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
|
|
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := *sess
|
|
|
|
events, err := settleCombatSession(sess, []*Combatant{&p}, &e)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) != 0 {
|
|
t.Errorf("settle emitted %d events on an idle solo turn", len(events))
|
|
}
|
|
if sess.Round != before.Round || sess.Phase != before.Phase || sess.PlayerHP != before.PlayerHP {
|
|
t.Errorf("settle advanced an idle solo fight: %d/%s/%d -> %d/%s/%d",
|
|
before.Round, before.Phase, before.PlayerHP, sess.Round, sess.Phase, sess.PlayerHP)
|
|
}
|
|
}
|