The extract pre-check is gone. It read a snapshot up to two minutes behind and still got the last word, so somebody who set out over Matrix during a lagging roster push was told they weren't on an expedition for a run gogobee would happily have ended. Same call abandon and leave already made: let it through and let rejected_not_running be the answer. The siege_join check stays, because whether a boss is camped outside town is town-wide and runs on a day-or-longer clock, but it now reads one column through SiegeIsCamped instead of loading every defender row and the whole history to look at one flag. The war-room history insert is OR REPLACE. boss_id is the primary key and it was never settled whether gogobee means the siege instance or the boss type by it, so a duplicate pair used to fail the transaction carrying the live boss and the muster too and freeze the war room on the last good snapshot. A dropped history row is the smaller failure; the open question is noted in the schema. offersToUndo's guard didn't cover the case its comment claimed. A gogobee too old to push seats sends a valid blob with no party key, which decodes to the same empty slice as a solo run, and a party member got shown the button that throws away everyone's day. That needs a new field, so whoDetail gains party_known and the flag gates the empty-list branch alone; the branch that reads the viewer's own seat is self-evidencing and keeps working against any sender. gogobee's half is written up in adventure_party_known_flag.md. And an empty offer list no longer claims "you're already out there", which Pete can't actually know from a game box too old to push offers at all.
270 lines
11 KiB
Go
270 lines
11 KiB
Go
package web
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// W9: the three verbs that undo something. Two halves are worth pinning.
|
|
//
|
|
// The first is offersToUndo, which is the only place on this site where Pete
|
|
// decides what a player MAY do from facts rather than from a list gogobee handed
|
|
// it. Getting it wrong in the generous direction puts "Call the whole thing off"
|
|
// — a button that throws away four people's day — in front of somebody who is not
|
|
// the leader, so the interesting cases are the ones where it must stay quiet.
|
|
//
|
|
// The second is that the two new verdict names round-trip. gogobee 400s on an
|
|
// unknown verdict and parks the order, so a name that exists on one side and not
|
|
// the other is a player watching "asked for…" forever.
|
|
|
|
func seat(kind, name, token string, level int) partySeat {
|
|
return partySeat{Kind: kind, Name: name, Token: token, Level: level}
|
|
}
|
|
|
|
// TestOffersToUndoReadsTheViewersOwnSeat is the core of the phase. A shared
|
|
// expedition publishes a seat per body, so which button this page offers is
|
|
// decided by finding the viewer among them — never by "there is a party, so
|
|
// somebody can abandon it".
|
|
func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
|
|
party := []partySeat{
|
|
seat("leader", "Josie", "tok-josie", 14),
|
|
seat("member", "Camcast", "tok-cam", 11),
|
|
seat("companion", "Pete", "", 9),
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
token, status string
|
|
partyKnown bool
|
|
party []partySeat
|
|
self storage.PlayerDetail
|
|
abandon, leave, cancel bool
|
|
}{
|
|
{
|
|
name: "leader of a party is offered the abandon",
|
|
token: "tok-josie", status: "expedition", partyKnown: true, party: party,
|
|
abandon: true,
|
|
},
|
|
{
|
|
name: "member of a party is offered the exit, never the abandon",
|
|
token: "tok-cam", status: "expedition", partyKnown: true, party: party,
|
|
leave: true,
|
|
},
|
|
{
|
|
// A solo run publishes no party at all (partySeatViews returns nil below
|
|
// two seats), so an empty list on a live run means "nobody else", not
|
|
// "we don't know" — and the one body down there is the leader.
|
|
name: "solo run is offered the abandon",
|
|
token: "tok-josie", status: "expedition", partyKnown: true,
|
|
abandon: true,
|
|
},
|
|
{
|
|
// The fail-closed case. A party we cannot find ourselves in is a
|
|
// snapshot we do not understand, and the safe answer is to offer
|
|
// nothing rather than guess which of the two buttons applies.
|
|
name: "a party with no seat for the viewer offers nothing",
|
|
token: "tok-nobody", status: "expedition", partyKnown: true, party: party,
|
|
},
|
|
{
|
|
// The one that came out of running it. An undecodable public sheet
|
|
// gives the same empty slice as a solo run, and treating the two alike
|
|
// offered a party MEMBER the abandon — convincingly, with the rest of
|
|
// the page looking fine.
|
|
name: "a run whose sheet did not decode offers nothing",
|
|
token: "tok-cam", status: "expedition", partyKnown: false,
|
|
},
|
|
{
|
|
// The same empty slice again, this time from a gogobee too old to push
|
|
// seats at all. It decodes fine, so only the sender's own flag tells it
|
|
// apart from the solo case two rows up.
|
|
name: "an empty party from a sender that never pushes seats offers nothing",
|
|
token: "tok-cam", status: "expedition", partyKnown: false, party: nil,
|
|
},
|
|
{
|
|
// The asymmetry: the seat list is self-evidencing, so it keeps working
|
|
// against a sender whose capability we cannot confirm. A seat saying
|
|
// "member" is not a guess, and refusing the exit here would strand
|
|
// somebody in a party for the length of the rollout.
|
|
name: "a seated member is offered the exit even without the flag",
|
|
token: "tok-cam", status: "expedition", partyKnown: false, party: party,
|
|
leave: true,
|
|
},
|
|
{
|
|
name: "standing in town with nothing open offers nothing",
|
|
token: "tok-josie", status: "idle", partyKnown: true, party: nil,
|
|
},
|
|
{
|
|
// The case the roster status cannot see: an extracted expedition is
|
|
// still its owner's to close, and its owner reads as idle in town with
|
|
// no party. The resume offer is the only sign the run is still open.
|
|
name: "an extracted run is abandonable from town",
|
|
token: "tok-josie", status: "idle",
|
|
self: storage.PlayerDetail{Resume: &storage.ResumeOffer{ZoneID: "holymachina", Day: 3}},
|
|
abandon: true,
|
|
},
|
|
{
|
|
name: "an engaged sitter can be sent home",
|
|
token: "tok-josie", status: "idle",
|
|
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: true}},
|
|
cancel: true,
|
|
},
|
|
{
|
|
name: "an unengaged sitter cannot",
|
|
token: "tok-josie", status: "idle",
|
|
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700}},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
abandon, leave, cancel := offersToUndo(tc.token, tc.status, tc.partyKnown, tc.party, tc.self)
|
|
if abandon != tc.abandon || leave != tc.leave || cancel != tc.cancel {
|
|
t.Fatalf("offers = abandon:%v leave:%v cancel:%v, want abandon:%v leave:%v cancel:%v",
|
|
abandon, leave, cancel, tc.abandon, tc.leave, tc.cancel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAbandonAndLeaveAreNeverBothOffered. They are opposite claims about the
|
|
// same person, and a page showing both would be asking the reader to work out
|
|
// which one they are. No input may produce the pair.
|
|
//
|
|
// This test found a real one: a member seated in somebody else's live run who
|
|
// ALSO has their own extracted run waiting has both facts true at once, about two
|
|
// different expeditions. offersToUndo suppresses the abandon in that case; see
|
|
// the comment on the Resume clause.
|
|
func TestAbandonAndLeaveAreNeverBothOffered(t *testing.T) {
|
|
for _, kind := range []string{"leader", "member", "companion", "", "nonsense"} {
|
|
party := []partySeat{seat("leader", "Josie", "tok-josie", 14), seat(kind, "Me", "tok-me", 8)}
|
|
for _, status := range []string{"expedition", "idle"} {
|
|
for _, resume := range []*storage.ResumeOffer{nil, {ZoneID: "z", Day: 2}} {
|
|
abandon, leave, _ := offersToUndo("tok-me", status, true, party, storage.PlayerDetail{Resume: resume})
|
|
if abandon && leave {
|
|
t.Fatalf("kind=%q status=%q resume=%v offered both ways out at once", kind, status, resume != nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUndoOrdersAreAccepted: the three verbs must survive the action allow-list
|
|
// and land as pending orders. A verb Pete does not know is a 400 at the door,
|
|
// which is a dead button rather than a refusal anybody can read.
|
|
func TestUndoOrdersAreAccepted(t *testing.T) {
|
|
s := seedActions(t, "holymachina", "expedition")
|
|
for _, action := range []string{storage.AdvActionAbandon, storage.AdvActionLeave} {
|
|
if w := placeAction(t, s, "holymachina", action); w.Code != 200 {
|
|
t.Fatalf("%s = %d (%s)", action, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
// Per verb, so the two do not block each other or anything already queued.
|
|
pending, err := storage.PendingAdvOrders(0)
|
|
if err != nil {
|
|
t.Fatalf("pending: %v", err)
|
|
}
|
|
if len(pending) != 2 {
|
|
t.Fatalf("pending = %d orders, want 2", len(pending))
|
|
}
|
|
}
|
|
|
|
// TestAbandonIsOfferedToAMarkStandingInTown is W5a's asymmetry restated for the
|
|
// two expedition verbs, and it is the reason neither has a snapshot pre-check.
|
|
// The board is up to two minutes stale, and an EXTRACTED run is abandonable
|
|
// while its owner reads as idle — so refusing on "the mark is in town" would
|
|
// refuse the case the verb exists for. gogobee answers rejected_not_running if
|
|
// the run really has gone.
|
|
func TestAbandonIsOfferedToAMarkStandingInTown(t *testing.T) {
|
|
s := seedActions(t, "holymachina", "idle")
|
|
if w := placeAction(t, s, "holymachina", storage.AdvActionAbandon); w.Code != 200 {
|
|
t.Fatalf("abandon from town = %d (%s), want it queued and answered by the game box",
|
|
w.Code, w.Body.String())
|
|
}
|
|
if w := placeAction(t, s, "holymachina", storage.AdvActionLeave); w.Code != 200 {
|
|
t.Fatalf("leave from town = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBabysitCancelRefusesWhenThereIsNoSitter is the one W9 pre-check that IS
|
|
// allowed to be the last word locally, and the comment in resolveAdvOrderParams
|
|
// says why: an engagement is a fact about the character rather than about where
|
|
// they are standing, so it does not go stale the way "on an expedition" does.
|
|
//
|
|
// A MISSING offer is still not a refusal — that is a gogobee too old to push one,
|
|
// not a player without a sitter — and the second half here pins that.
|
|
func TestBabysitCancelRefusesWhenThereIsNoSitter(t *testing.T) {
|
|
s := seedActions(t, "holymachina", "idle")
|
|
now := time.Now().Unix()
|
|
|
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
|
Localpart: "holymachina", Token: "tok-josie",
|
|
Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700, MonthCost: 2400},
|
|
}}}); w.Code != 200 {
|
|
t.Fatalf("detail push = %d", w.Code)
|
|
}
|
|
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 409 {
|
|
t.Fatalf("cancel with no sitter = %d, want 409", w.Code)
|
|
}
|
|
|
|
// Sitter engaged: allowed.
|
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
|
Localpart: "holymachina", Token: "tok-josie",
|
|
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 2400},
|
|
}}}); w.Code != 200 {
|
|
t.Fatalf("detail push = %d", w.Code)
|
|
}
|
|
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
|
|
t.Fatalf("cancel with a sitter = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
// No babysit offer at all: a gogobee that predates the offer push. Queue it
|
|
// and let the game box answer, rather than making the button dead.
|
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
|
Localpart: "holymachina", Token: "tok-josie",
|
|
}}}); w.Code != 200 {
|
|
t.Fatalf("detail push = %d", w.Code)
|
|
}
|
|
storage.Get().Exec(`DELETE FROM adventure_orders`)
|
|
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
|
|
t.Fatalf("cancel with no offer pushed = %d (%s), want it deferred to gogobee",
|
|
w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestNewVerdictsRoundTrip: gogobee 400s on a verdict Pete will not take, and
|
|
// that parks the order — the player watches "asked for…" and nothing ever
|
|
// answers. So every status the game box can file has to be accepted here.
|
|
func TestNewVerdictsRoundTrip(t *testing.T) {
|
|
s := seedActions(t, "holymachina", "expedition")
|
|
|
|
for _, tc := range []struct{ action, verdict string }{
|
|
{storage.AdvActionLeave, storage.AdvRejectedIsLeader},
|
|
{storage.AdvActionBabysitCancel, storage.AdvRejectedNothingToCancel},
|
|
{storage.AdvActionAbandon, storage.AdvRejectedNotLeader},
|
|
} {
|
|
storage.Get().Exec(`DELETE FROM adventure_orders`)
|
|
w := placeAction(t, s, "holymachina", tc.action)
|
|
if w.Code != 200 {
|
|
t.Fatalf("place %s = %d (%s)", tc.action, w.Code, w.Body.String())
|
|
}
|
|
pending, err := storage.PendingAdvOrders(0)
|
|
if err != nil || len(pending) != 1 {
|
|
t.Fatalf("pending = %v (%v)", pending, err)
|
|
}
|
|
rec := postVerdict(t, s, "tok", advOrderVerdict{
|
|
GUID: pending[0].GUID, Status: tc.verdict, Detail: "because.",
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("verdict %s = %d (%s) — an unknown status parks the order forever",
|
|
tc.verdict, rec.Code, rec.Body.String())
|
|
}
|
|
got, err := storage.AdvOrderByGUID(pending[0].GUID)
|
|
if err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if got.Status != tc.verdict {
|
|
t.Fatalf("stored status = %q, want %q", got.Status, tc.verdict)
|
|
}
|
|
}
|
|
}
|