mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
mischief: gogobee learns to fill orders placed on Pete's web board
The game-side half of Mischief Makers M3. gogobee polls Pete for storefront orders and opens the contract itself — the money, the eligibility, the fight are all its own, exactly as a Matrix !mischief buy. - roster push now carries each buyer's advisory euro balance (keyed by localpart, a separate keyspace from the anonymous board token) and the live tier catalog, so the storefront renders gogobee's current prices and never hardcodes one - placeWebMischief: the fulfilment path, debit-first-then-refund so the money state is a pure function of the order guid. DebitIdem + an order_guid stamp on the contract make a retried claim neither double-charge nor double-open - resolveRosterToken recomputes the one-way board token per live player to name a mark; the buyer is @<username>:<homeserver> from Authentik's preferred_username - a 30s poll loop drives it; a lost verdict just leaves the order pending to re-run, so the loop is its own retry and needs no durable queue - euro.HasExternalTx lets the affordability gate tell a first attempt (no debt for a mischief buy, like Matrix) from a retry of one already paid order_guid column added to mischief_contracts (schema + idempotent ALTER).
This commit is contained in:
194
internal/plugin/pete_mischief_test.go
Normal file
194
internal/plugin/pete_mischief_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// webMischiefPlugin builds a plugin the web placement path can run against: a
|
||||
// euro ledger, a capture sink so DMs go nowhere, and a bare client carrying only
|
||||
// a user id — enough for mischiefBuyerMXID to read the homeserver, never used for
|
||||
// a real call (DisplayName fast-fails to the localpart, sends hit the sink).
|
||||
func webMischiefPlugin(t *testing.T) *AdventurePlugin {
|
||||
t.Helper()
|
||||
p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
|
||||
p.Sink = &captureSink{}
|
||||
// A well-formed client pointed at an unroutable host: mischiefBuyerMXID reads
|
||||
// only its user id (for the homeserver), and the DM courtesy path's lone real
|
||||
// call — GetDisplayName — fails fast against 127.0.0.1:1 and falls back to the
|
||||
// localpart, so nothing here touches a network or hangs.
|
||||
cli, err := mautrix.NewClient("http://127.0.0.1:1", id.UserID("@gogobee:example.org"), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Client = cli
|
||||
return p
|
||||
}
|
||||
|
||||
func webOrder(guid, buyer, targetToken, tier string, signed bool) peteclient.MischiefOrder {
|
||||
return peteclient.MischiefOrder{
|
||||
GUID: guid, BuyerUsername: buyer, TargetToken: targetToken,
|
||||
TargetName: "Josie", Tier: tier, Signed: signed,
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRosterToken round-trips the one-way board token: gogobee can't
|
||||
// invert it, but recomputing every live player's token finds the match, which is
|
||||
// how a web order names its mark.
|
||||
func TestResolveRosterToken(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, uid, 5, 60)
|
||||
|
||||
tok := eventToken(uid, "roster")
|
||||
got, ok := resolveRosterToken(tok)
|
||||
if !ok || got != uid {
|
||||
t.Fatalf("resolveRosterToken(%s) = %q, %v; want %s", tok, got, ok, uid)
|
||||
}
|
||||
if _, ok := resolveRosterToken("not-a-real-token"); ok {
|
||||
t.Error("resolveRosterToken matched a token no player owns")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefHappyPath: a funded buyer, a mark on a live expedition, a
|
||||
// tier gogobee offers — a web-sourced contract opens, stamped with the order.
|
||||
func TestPlaceWebMischiefHappyPath(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
target := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, target, 5, 60)
|
||||
buyer := id.UserID("@reala:example.org")
|
||||
p.euro.Credit(buyer, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-1", "reala", eventToken(target, "roster"), "grunt", false)
|
||||
res := p.placeWebMischief(order)
|
||||
|
||||
if res.Status != mischiefWebPlaced {
|
||||
t.Fatalf("status = %q (%s), want placed", res.Status, res.Detail)
|
||||
}
|
||||
c := mischiefContractByOrderGUID("ord-1")
|
||||
if c == nil {
|
||||
t.Fatal("no contract stamped with the order guid")
|
||||
}
|
||||
if c.Source != "web" || c.TargetID != target || c.BuyerID != buyer {
|
||||
t.Fatalf("contract = %+v", c)
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != 960 {
|
||||
t.Errorf("buyer balance = %v, want 960 (1000 - 40 grunt)", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefIdempotent is the whole point of keying on the order guid:
|
||||
// the poll loop retries, so a second placement of the same order must not open a
|
||||
// second contract or debit the buyer twice.
|
||||
func TestPlaceWebMischiefIdempotent(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
target := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, target, 5, 60)
|
||||
buyer := id.UserID("@reala:example.org")
|
||||
p.euro.Credit(buyer, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-dup", "reala", eventToken(target, "roster"), "mob", false)
|
||||
if res := p.placeWebMischief(order); res.Status != mischiefWebPlaced {
|
||||
t.Fatalf("first placement = %q", res.Status)
|
||||
}
|
||||
balAfterFirst := p.euro.GetBalance(buyer)
|
||||
|
||||
// Same order again — the retry.
|
||||
if res := p.placeWebMischief(order); res.Status != mischiefWebPlaced {
|
||||
t.Fatalf("replay = %q, want placed", res.Status)
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != balAfterFirst {
|
||||
t.Errorf("replay moved money: balance %v -> %v", balAfterFirst, bal)
|
||||
}
|
||||
|
||||
// Exactly one contract exists for this target.
|
||||
var n int
|
||||
_ = db.Get().QueryRow(`SELECT COUNT(*) FROM mischief_contracts WHERE order_guid = ?`, "ord-dup").Scan(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("order opened %d contracts, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefBouncedFunds: a broke buyer is turned away before any money
|
||||
// moves — a mischief buy takes no credit, same as the Matrix path.
|
||||
func TestPlaceWebMischiefBouncedFunds(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
target := id.UserID("@josie:example.org")
|
||||
seedMischiefTarget(t, target, 5, 60)
|
||||
buyer := id.UserID("@broke:example.org")
|
||||
p.euro.Credit(buyer, 10, "test seed") // a grunt is 40
|
||||
|
||||
order := webOrder("ord-broke", "broke", eventToken(target, "roster"), "grunt", false)
|
||||
res := p.placeWebMischief(order)
|
||||
|
||||
if res.Status != mischiefWebBouncedFunds {
|
||||
t.Fatalf("status = %q, want bounced_funds", res.Status)
|
||||
}
|
||||
if mischiefContractByOrderGUID("ord-broke") != nil {
|
||||
t.Error("a bounced order still opened a contract")
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != 10 {
|
||||
t.Errorf("balance = %v, want 10 untouched (no debt for a mischief buy)", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefBouncedIneligibleRefunds: a mark who isn't on an expedition
|
||||
// can't be hit; the buyer is refunded whole, net zero.
|
||||
func TestPlaceWebMischiefBouncedIneligibleRefunds(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
// A real, alive character but NOT on an expedition — ineligible.
|
||||
idle := id.UserID("@idle:example.org")
|
||||
if err := createAdvCharacter(idle, "idle"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveDnDCharacter(&DnDCharacter{
|
||||
UserID: idle, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
HPMax: 40, HPCurrent: 40, ArmorClass: 16,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buyer := id.UserID("@reala:example.org")
|
||||
p.euro.Credit(buyer, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-inelig", "reala", eventToken(idle, "roster"), "elite", false)
|
||||
res := p.placeWebMischief(order)
|
||||
|
||||
if res.Status != mischiefWebBouncedIneligible {
|
||||
t.Fatalf("status = %q (%s), want bounced_ineligible", res.Status, res.Detail)
|
||||
}
|
||||
if bal := p.euro.GetBalance(buyer); bal != 1000 {
|
||||
t.Errorf("buyer balance = %v, want 1000 (debited then refunded whole)", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceWebMischiefSelfTarget: the reconstructed buyer and the resolved target
|
||||
// are the same person — refused, like the Matrix path.
|
||||
func TestPlaceWebMischiefSelfTarget(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
p := webMischiefPlugin(t)
|
||||
|
||||
me := id.UserID("@reala:example.org")
|
||||
seedMischiefTarget(t, me, 5, 60)
|
||||
p.euro.Credit(me, 1000, "test seed")
|
||||
|
||||
order := webOrder("ord-self", "reala", eventToken(me, "roster"), "grunt", false)
|
||||
if res := p.placeWebMischief(order); res.Status != mischiefWebBouncedIneligible {
|
||||
t.Fatalf("self-target status = %q, want bounced_ineligible", res.Status)
|
||||
}
|
||||
if bal := p.euro.GetBalance(me); bal != 1000 {
|
||||
t.Errorf("self-target moved money: balance %v", bal)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user