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:
prosolis
2026-07-14 21:12:37 -07:00
parent 6d402343e6
commit ba306f0b59
9 changed files with 646 additions and 12 deletions

View File

@@ -462,6 +462,9 @@ func runMigrations(d *sql.DB) error {
// same durability, backoff and parking, so it rides the same queue and the // same durability, backoff and parking, so it rides the same queue and the
// row says where it's going. Existing rows are all facts, hence the default. // row says where it's going. Existing rows are all facts, hence the default.
`ALTER TABLE pete_emit_queue ADD COLUMN path TEXT NOT NULL DEFAULT '/api/ingest/adventure'`, `ALTER TABLE pete_emit_queue ADD COLUMN path TEXT NOT NULL DEFAULT '/api/ingest/adventure'`,
// Mischief M3 — the web order a contract was opened for, the storefront's
// end-to-end idempotency key. "" for a Matrix buy. See placeWebMischief.
`ALTER TABLE mischief_contracts ADD COLUMN order_guid TEXT`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {
@@ -1784,6 +1787,11 @@ CREATE TABLE IF NOT EXISTS mischief_contracts (
blessing_count INTEGER NOT NULL DEFAULT 0, blessing_count INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'matrix', source TEXT NOT NULL DEFAULT 'matrix',
outcome TEXT, outcome TEXT,
-- The web order this contract was opened for, "" for a Matrix buy. It is the
-- storefront's idempotency key: a poll loop whose claim-ack was lost retries
-- the whole placement, and finding the contract already stamped with the order
-- is what stops a second one being opened (and a second euro debit chased).
order_guid TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
window_ends_at DATETIME NOT NULL, window_ends_at DATETIME NOT NULL,
resolved_at DATETIME resolved_at DATETIME

View File

@@ -247,12 +247,41 @@ type RosterEntry struct {
IdleHours int `json:"idle_hours,omitempty"` IdleHours int `json:"idle_hours,omitempty"`
} }
// MischiefBalance is one buyer's advisory euro balance, ridden along with the
// board. It is keyed by localpart — a buyer's own sign-in name — not by the
// anonymous roster token, so it lives in a separate keyspace on Pete and only
// ever surfaces for the one authenticated user asking about themselves. That is
// what lets the storefront grey out tiers a buyer can't afford without ever
// putting a number next to a name on the public board.
type MischiefBalance struct {
Username string `json:"username"`
Euro float64 `json:"euro"`
}
// MischiefTier is one rung of the storefront price list. gogobee is the sole
// authority on prices, so it pushes the whole catalog on every tick: a fee
// retune reaches the storefront within a snapshot and Pete never hardcodes a
// number that can silently drift out of step with the game.
type MischiefTier struct {
Key string `json:"key"`
Display string `json:"display"`
Fee int `json:"fee"`
SignedFee int `json:"signed_fee"`
Blurb string `json:"blurb,omitempty"`
}
// RosterSnapshot is the complete board. Complete is load-bearing: Pete replaces // RosterSnapshot is the complete board. Complete is load-bearing: Pete replaces
// its whole board with this, so anyone omitted (opted out, no character) drops // its whole board with this, so anyone omitted (opted out, no character) drops
// off the public page. A partial snapshot would silently strand people on it. // off the public page. A partial snapshot would silently strand people on it.
//
// Balances and Tiers ride the same tick — advisory affordability and the live
// price list for the mischief storefront. Both are best-effort on Pete's side; a
// board that lands without them is still a good board.
type RosterSnapshot struct { type RosterSnapshot struct {
SnapshotAt int64 `json:"snapshot_at"` SnapshotAt int64 `json:"snapshot_at"`
Adventurers []RosterEntry `json:"adventurers"` Adventurers []RosterEntry `json:"adventurers"`
Balances []MischiefBalance `json:"balances,omitempty"`
Tiers []MischiefTier `json:"tiers,omitempty"`
} }
// PushRoster sends the board to Pete, synchronously, and drops it on failure. // PushRoster sends the board to Pete, synchronously, and drops it on failure.
@@ -388,6 +417,59 @@ func EmitEscrowVerdict(v EscrowVerdict) {
enqueue("escrow:"+v.GUID, escrowVerdictPath, payload) enqueue("escrow:"+v.GUID, escrowVerdictPath, payload)
} }
// ---------------------------------------------------------------------------
// The mischief storefront's reverse pipe
//
// A buyer places a hit on Pete's web board; we poll for it, do the real work
// against our own ledger, and hand back a verdict. Same shape as the escrow
// border above — Pete has no route in, so we poll — but simpler: the order guid
// is the end-to-end idempotency key (external_id on the euro debit, stamped on
// the contract), and the *verdict rides the claim itself* rather than a durable
// queue. If a claim fails, the order stays pending and the next poll re-offers
// it; re-running is a no-op, so the poll loop is its own retry.
// ---------------------------------------------------------------------------
// MischiefOrder is one storefront order as Pete describes it. buyer_sub stays on
// Pete (it is the OIDC subject, only for "my orders"); we get the username, which
// we turn into a Matrix id, and the anonymous target token.
type MischiefOrder struct {
GUID string `json:"guid"`
BuyerUsername string `json:"buyer_username"`
TargetToken string `json:"target_token"`
TargetName string `json:"target_name"`
Tier string `json:"tier"`
Signed bool `json:"signed"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
}
// PendingMischief asks Pete for orders waiting on us. A Pete that predates the
// storefront answers 404, which surfaces here as an error; the poll loop treats
// any error as "nothing to do this tick" and logs it quietly, so gogobee can ship
// ahead of Pete without noise.
func PendingMischief(ctx context.Context) ([]MischiefOrder, error) {
if !Enabled() {
return nil, nil
}
var out []MischiefOrder
if err := std.getJSON(ctx, "/api/mischief/pending", &out); err != nil {
return nil, err
}
return out, nil
}
// ClaimMischief files our verdict on an order: the terminal status Pete should
// show and a human note. Idempotent on Pete, so a retried claim is safe. The
// verdict rides this call directly — there is no separate durable emit, because
// a lost claim just leaves the order pending for the next poll to re-run.
func ClaimMischief(ctx context.Context, guid, status, detail string) error {
payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail})
if err != nil {
return err
}
return std.post(ctx, "/api/mischief/claim", payload)
}
// getJSON does a bearer-authed GET and decodes the body. // getJSON does a bearer-authed GET and decodes the body.
func (c *Client) getJSON(ctx context.Context, path string, out any) error { func (c *Client) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)

View File

@@ -290,6 +290,7 @@ func (p *AdventurePlugin) Init() error {
go p.expeditionExtractionSweepTicker() go p.expeditionExtractionSweepTicker()
go p.expeditionBoredomTicker() go p.expeditionBoredomTicker()
go p.mischiefTicker() go p.mischiefTicker()
go p.peteMischiefTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart // Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns() p.arenaCleanupStaleRuns()

View File

@@ -350,19 +350,20 @@ type mischiefContract struct {
BlessingCount int BlessingCount int
Source string Source string
Outcome string Outcome string
OrderGUID string // the web order this was opened for; "" for a Matrix buy
CreatedAt time.Time CreatedAt time.Time
WindowEndsAt time.Time WindowEndsAt time.Time
} }
const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed, const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed,
escalation_count, COALESCE(escalated_by, ''), blessing_count, source, COALESCE(outcome, ''), escalation_count, COALESCE(escalated_by, ''), blessing_count, source, COALESCE(outcome, ''),
created_at, window_ends_at` COALESCE(order_guid, ''), created_at, window_ends_at`
func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) { func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) {
c := &mischiefContract{} c := &mischiefContract{}
err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status, err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status,
&c.Signed, &c.EscalationCount, &c.EscalatedBy, &c.BlessingCount, &c.Source, &c.Outcome, &c.Signed, &c.EscalationCount, &c.EscalatedBy, &c.BlessingCount, &c.Source, &c.Outcome,
&c.CreatedAt, &c.WindowEndsAt) &c.OrderGUID, &c.CreatedAt, &c.WindowEndsAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -382,14 +383,32 @@ func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error
func insertMischiefContract(c *mischiefContract) error { func insertMischiefContract(c *mischiefContract) error {
_, err := db.Get().Exec( _, err := db.Get().Exec(
`INSERT INTO mischief_contracts `INSERT INTO mischief_contracts
(contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source, (contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source, order_guid,
created_at, window_ends_at) created_at, window_ends_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, string(c.BuyerID), string(c.TargetID), c.Tier, c.Fee, c.Paid, c.ID, string(c.BuyerID), string(c.TargetID), c.Tier, c.Fee, c.Paid,
c.Status, c.Signed, c.Source, c.CreatedAt, c.WindowEndsAt) c.Status, c.Signed, c.Source, c.OrderGUID, c.CreatedAt, c.WindowEndsAt)
return err return err
} }
// mischiefContractByOrderGUID finds the contract opened for a web order, if one
// exists. It is the idempotency backstop for the storefront's retrying claim
// loop: a placement that already created a contract must not create a second, so
// the loop checks here before it debits or inserts. Returns nil when no contract
// carries this order (the normal first-attempt case).
func mischiefContractByOrderGUID(orderGUID string) *mischiefContract {
if orderGUID == "" {
return nil
}
row := db.Get().QueryRow(
`SELECT `+mischiefCols+` FROM mischief_contracts WHERE order_guid = ?`, orderGUID)
c, err := scanMischief(row)
if err != nil {
return nil // sql.ErrNoRows (the common case) or a scan miss — treat as absent
}
return c
}
// blessMischiefContract adds one ward to an open contract and reports whether it // blessMischiefContract adds one ward to an open contract and reports whether it
// took. The cap and the still-open check are in the WHERE clause, not in the // took. The cap and the still-open check are in the WHERE clause, not in the
// caller: the window is a scramble, and a room that piles four blessings in the // caller: the window is a scramble, and a room that piles four blessings in the
@@ -799,6 +818,175 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e
mischiefBuyerNote(signed))) mischiefBuyerNote(signed)))
} }
// Web-order verdicts. These strings are the storefront order statuses Pete files,
// so they are part of the wire contract — see internal/storage/mischief.go.
const (
mischiefWebPlaced = "placed"
mischiefWebBouncedFunds = "bounced_funds"
mischiefWebBouncedIneligible = "bounced_ineligible"
)
// mischiefWebResult is the outcome of a web placement. Retry means "leave the
// order pending" — a transient failure (DB hiccup, half-done state) the poll loop
// should try again — and is never itself a verdict Pete files.
type mischiefWebResult struct {
Status string
Detail string
Retry bool
}
// placeWebMischief opens a contract for a web storefront order. It is the poll
// loop's fulfilment path, and differs from the Matrix mischiefSendCmd in exactly
// the ways a retrying wire demands:
//
// - The order guid is the idempotency key. The euro debit goes through
// DebitIdem keyed on it, and the contract is stamped with it, so a claim
// whose ack was lost can re-run the whole thing without charging twice or
// opening a second contract.
// - It debits *before* checking eligibility, then refunds if the target turns
// out ineligible. Doing it in that order keeps the money state a pure
// function of the guid: on any retry the debit is a settled fact, not a
// coin-flip on where we crashed. The Matrix path can afford the friendlier
// check-first flow because it never retries.
// - It returns a verdict for Pete to render instead of replying in a room.
//
// The buyer is DM'd the same way a Matrix buyer is told, and the victim and news
// feed see exactly what a Matrix contract produces — a web hit is indistinguishable
// downstream from one placed in chat.
func (p *AdventurePlugin) placeWebMischief(order peteclient.MischiefOrder) mischiefWebResult {
// Already fulfilled on a prior attempt — the surest idempotency check there
// is. Report placed without touching money again.
if existing := mischiefContractByOrderGUID(order.GUID); existing != nil {
return mischiefWebResult{Status: mischiefWebPlaced, Detail: "already placed"}
}
tier, ok := mischiefTierByKey(order.Tier)
if !ok {
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "unknown tier"}
}
buyerID, ok := p.mischiefBuyerMXID(order.BuyerUsername)
if !ok {
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "unknown buyer"}
}
targetID, ok := resolveRosterToken(order.TargetToken)
if !ok {
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "that adventurer is no longer on the board"}
}
if targetID == buyerID {
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "you can't put a price on your own head"}
}
// Serialise this buyer's placements, same discipline as the Matrix path: two
// orders in one poll batch can't both slip past the daily cap.
lock := p.advUserLock(buyerID)
lock.Lock()
defer lock.Unlock()
now := time.Now().UTC()
if n := mischiefBuyerCountSince(buyerID, now.Add(-24*time.Hour)); n >= mischiefBuyerDailyCap {
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: fmt.Sprintf("daily limit reached (%d today)", n)}
}
price := tier.Fee
if order.Signed {
price = mischiefSignedFee(tier.Fee)
}
// Affordability gate, matching the Matrix path: a mischief buy takes no credit,
// so a buyer short of the fee is bounced before any money moves. The gate is
// skipped on a retry of an order already debited — re-reading the now-lower
// balance would wrongly bounce a hit that was already paid for.
if !p.euro.HasExternalTx(order.GUID) && int(p.euro.GetBalance(buyerID)) < price {
return mischiefWebResult{Status: mischiefWebBouncedFunds, Detail: fmt.Sprintf("a %s runs %s", strings.ToLower(tier.Display), fmtEuro(price))}
}
// Debit, keyed on the order guid so a replay is a no-op. ok=false means the
// buyer is already past the debt floor, which the gate above all but rules out.
debited, _, err := p.euro.DebitIdem(buyerID, float64(price), "mischief_contract_web", order.GUID)
if err != nil {
slog.Warn("mischief: web debit errored, will retry", "order", order.GUID, "err", err)
return mischiefWebResult{Retry: true}
}
if !debited {
return mischiefWebResult{Status: mischiefWebBouncedFunds, Detail: fmt.Sprintf("a %s runs %s", strings.ToLower(tier.Display), fmtEuro(price))}
}
// From here the buyer is debited; every failure path must refund. The refund
// keys on a distinct external id so it can't be swallowed as a replay of the
// debit itself.
refund := func() {
if _, _, err := p.euro.CreditIdem(buyerID, float64(price), "mischief_refund_web", order.GUID+":refund"); err != nil {
slog.Error("mischief: web refund failed — buyer is short", "order", order.GUID, "buyer", buyerID, "err", err)
}
}
if reason := p.mischiefTargetable(targetID, tier, now); reason != "" {
refund()
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: reason}
}
c := &mischiefContract{
ID: uuid.NewString(),
BuyerID: buyerID,
TargetID: targetID,
Tier: tier.Key,
Fee: tier.Fee,
Paid: price,
Status: mischiefStatusOpen,
Signed: order.Signed,
Source: "web",
OrderGUID: order.GUID,
CreatedAt: now,
WindowEndsAt: now.Add(mischiefWindow),
}
if err := insertMischiefContract(c); err != nil {
// The one-live-per-target index rejected us — a rival contract is already
// on this target (ours isn't: we checked order_guid up top). Refund and
// bounce; the buyer lost a race, not money.
refund()
slog.Warn("mischief: web contract insert rejected", "order", order.GUID, "target", targetID, "err", err)
return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "someone already sent a monster their way"}
}
p.announceMischiefContract(c, tier)
p.dmMischiefVictim(c, tier)
emitMischiefContractNews(c, tier)
p.dmMischiefWebBuyer(buyerID, c, tier)
return mischiefWebResult{Status: mischiefWebPlaced, Detail: fmt.Sprintf("a %s is on the way", strings.ToLower(tier.Display))}
}
// mischiefBuyerMXID reconstructs the buyer's Matrix id from the username Pete
// sent. Authentik's preferred_username is the Matrix localpart by construction
// (verified on the MAS box), so the buyer is @<username>:<our homeserver>. We
// lowercase because Matrix localparts are lowercase and an Authentik display of
// the name may not be. Fails closed if the client isn't up (tests) or the name
// is empty.
func (p *AdventurePlugin) mischiefBuyerMXID(username string) (id.UserID, bool) {
lp := strings.ToLower(strings.TrimSpace(username))
if lp == "" || p.Client == nil {
return "", false
}
server := p.Client.UserID.Homeserver()
if server == "" {
return "", false
}
return id.NewUserID(lp, server), true
}
// dmMischiefWebBuyer tells a web buyer their hit is out, the same courtesy a
// Matrix buyer gets in-thread. Best-effort: a buyer with no DM room open just
// watches the storefront status instead.
func (p *AdventurePlugin) dmMischiefWebBuyer(buyerID id.UserID, c *mischiefContract, tier mischiefTier) {
msg := fmt.Sprintf("😈 The word's out. A **%s** is on its way to **%s** — it finds them in about %s.\n%s",
strings.ToLower(tier.Display), p.DisplayName(c.TargetID), formatDuration(mischiefWindow),
mischiefBuyerNote(c.Signed))
if err := p.SendDM(buyerID, msg); err != nil {
slog.Debug("mischief: web buyer DM failed", "buyer", buyerID, "err", err)
}
}
// mischiefOpenContractFor resolves "@user" to the contract currently open against // mischiefOpenContractFor resolves "@user" to the contract currently open against
// them, or explains why the room can't act on it. A contract already claimed for // them, or explains why the room can't act on it. A contract already claimed for
// delivery is deliberately out of reach: the monster is in the room with them and // delivery is deliberately out of reach: the monster is in the room with them and

View File

@@ -416,6 +416,21 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
return balance return balance
} }
// HasExternalTx reports whether a money move with this externalID has already
// been applied. It lets a retrying web caller tell a first attempt from a replay:
// on a first attempt the affordability gate must run, but on a retry of an order
// already debited the gate must be skipped — the debit is a settled fact, and
// re-reading the now-lower balance would wrongly bounce a hit the buyer paid for.
func (p *EuroPlugin) HasExternalTx(externalID string) bool {
if externalID == "" {
return false
}
var one int
err := db.Get().QueryRow(
`SELECT 1 FROM euro_transactions WHERE external_id = ? LIMIT 1`, externalID).Scan(&one)
return err == nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Idempotent money moves // Idempotent money moves
// //

View File

@@ -0,0 +1,86 @@
package plugin
// The mischief storefront's game-side loop.
//
// A buyer places a hit on Pete's web board; Pete records the intent and waits.
// We poll for those orders, do the real work against our own ledger — the money,
// the eligibility, the contract — and hand back a verdict Pete files against the
// order. Pete has no route into this box, which is why the traffic runs this way:
// we ask for work, we don't get told about it.
//
// The order guid is the idempotency key end to end (see placeWebMischief), so
// every step here is safe to repeat. That is the whole reason the loop can be
// this simple: a failed claim just leaves the order pending, and the next tick
// re-runs it as a no-op. The loop is its own retry, and needs no durable queue.
import (
"context"
"log/slog"
"time"
"gogobee/internal/peteclient"
)
const (
mischiefPollInterval = 30 * time.Second
mischiefPollTimeout = 20 * time.Second
)
// peteMischiefTicker polls Pete for storefront orders and fulfils them. Started
// alongside the other adventure tickers; exits on stopCh.
func (p *AdventurePlugin) peteMischiefTicker() {
if !peteclient.Enabled() {
return // no Pete wire configured; the storefront half is simply off
}
ticker := time.NewTicker(mischiefPollInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.pollMischiefOrders()
}
}
}
// pollMischiefOrders fetches the pending orders and fulfils each in turn.
func (p *AdventurePlugin) pollMischiefOrders() {
ctx, cancel := context.WithTimeout(context.Background(), mischiefPollTimeout)
defer cancel()
orders, err := peteclient.PendingMischief(ctx)
if err != nil {
// A Pete that predates the storefront answers 404 here; a wire blip is the
// same. Either way there is nothing to do this tick, and the next one tries
// again. Debug, not warn — this must be quiet when Pete simply hasn't
// shipped the endpoint yet.
slog.Debug("mischief: poll failed", "err", err)
return
}
for _, order := range orders {
p.fulfilMischiefOrder(ctx, order)
}
}
// fulfilMischiefOrder places one order's contract and files the verdict. A
// transient failure (Retry) is left pending for the next poll; a real verdict is
// pushed back so the buyer sees why. A failed claim-push is not fatal — the order
// stays pending and the next poll re-runs it, which the guid makes a no-op.
func (p *AdventurePlugin) fulfilMischiefOrder(ctx context.Context, order peteclient.MischiefOrder) {
res := p.placeWebMischief(order)
if res.Retry {
return // leave it pending; try again next tick
}
if err := peteclient.ClaimMischief(ctx, order.GUID, res.Status, res.Detail); err != nil {
// The contract is placed (or the buyer refunded) on our side, but Pete
// hasn't heard the verdict. It stays pending there and we'll re-offer it;
// placeWebMischief will short-circuit on the stamped order guid and we'll
// re-file. So this is a warn, not a lost order.
slog.Warn("mischief: claim verdict push failed, will re-file next poll",
"order", order.GUID, "status", res.Status, "err", err)
return
}
slog.Info("mischief: web order fulfilled", "order", order.GUID, "status", res.Status)
}

View 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)
}
}

View File

@@ -60,7 +60,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
var rosterPushOK bool var rosterPushOK bool
func (p *AdventurePlugin) pushRoster() { func (p *AdventurePlugin) pushRoster() {
snap, err := buildRosterSnapshot(time.Now().UTC()) snap, err := buildRosterSnapshot(time.Now().UTC(), p.euro)
if err != nil { if err != nil {
slog.Error("roster: build snapshot failed", "err", err) slog.Error("roster: build snapshot failed", "err", err)
return return
@@ -96,8 +96,8 @@ func (p *AdventurePlugin) pushRoster() {
// anonymized. A standing row showing class + level + zone is trivially // anonymized. A standing row showing class + level + zone is trivially
// re-identifiable (there is one level-14 cleric), so "an adventurer" would have // re-identifiable (there is one level-14 cleric), so "an adventurer" would have
// been a fig leaf; absence is the only honest option. // been a fig leaf; absence is the only honest option.
func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) { func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnapshot, error) {
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix()} snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix(), Tiers: mischiefTierCatalog()}
// Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in // Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in
// SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared* // SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared*
@@ -133,6 +133,22 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
} }
for _, pl := range players { for _, pl := range players {
// The buyer's own advisory balance rides along in a separate keyspace,
// keyed by localpart (their sign-in name), and is collected *before* the
// opt-out skip: opt-out hides a player from the public board, but their own
// balance is private on Pete — only ever read for the user asking about
// themselves — so there is no reason to deny an opted-out player the
// storefront's affordability hint. localpart is lowercase, matching how the
// buyer signs in and how a web order's username is resolved back to an MXID.
if euro != nil {
if lp := localpartOf(pl.uid); lp != "" {
snap.Balances = append(snap.Balances, peteclient.MischiefBalance{
Username: lp,
Euro: euro.GetBalance(pl.uid),
})
}
}
if isNewsOptedOut(pl.uid) { if isNewsOptedOut(pl.uid) {
continue continue
} }
@@ -175,3 +191,47 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
} }
return snap, nil return snap, nil
} }
// resolveRosterToken maps a board token back to the adventurer it names. The
// token is a one-way HMAC (eventToken), so it can't be inverted — instead we
// recompute every live player's token and match. The salt is DB-persisted, so a
// token minted before a restart still resolves after one. O(players) per call,
// which is nothing at realm scale and only runs when a web order is being placed.
func resolveRosterToken(token string) (id.UserID, bool) {
if token == "" {
return "", false
}
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
if err != nil {
return "", false
}
defer rows.Close()
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
if eventToken(id.UserID(uid), "roster") == token {
return id.UserID(uid), true
}
}
return "", false
}
// mischiefTierCatalog is the storefront price list, pushed on every tick so the
// web shop always renders gogobee's current prices — a fee retune here reaches
// Pete within a snapshot, and Pete never hardcodes a number of its own. The
// signed fee is the same +25% sink a Matrix buyer pays to put their name on it.
func mischiefTierCatalog() []peteclient.MischiefTier {
out := make([]peteclient.MischiefTier, 0, len(mischiefTiers))
for _, t := range mischiefTiers {
out = append(out, peteclient.MischiefTier{
Key: t.Key,
Display: t.Display,
Fee: t.Fee,
SignedFee: mischiefSignedFee(t.Fee),
Blurb: t.Blurb,
})
}
return out
}

View File

@@ -49,7 +49,7 @@ func TestRosterSnapshotReadsTheClock(t *testing.T) {
// Never acted at all: the fold falls through to created_at. // Never acted at all: the fold falls through to created_at.
seedRosterPlayer(t, "@never:test", "Camcast", &old, nil) seedRosterPlayer(t, "@never:test", "Camcast", &old, nil)
snap, err := buildRosterSnapshot(now) snap, err := buildRosterSnapshot(now, nil)
if err != nil { if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err) t.Fatalf("buildRosterSnapshot: %v", err)
} }
@@ -94,7 +94,7 @@ func TestRosterOmitsOptedOut(t *testing.T) {
seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old) seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old)
setNewsOptout("@hidden:test", true) setNewsOptout("@hidden:test", true)
snap, err := buildRosterSnapshot(now) snap, err := buildRosterSnapshot(now, nil)
if err != nil { if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err) t.Fatalf("buildRosterSnapshot: %v", err)
} }