diff --git a/internal/db/db.go b/internal/db/db.go index e8d6f49..44566d1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -462,6 +462,9 @@ func runMigrations(d *sql.DB) error { // 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. `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 { 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, source TEXT NOT NULL DEFAULT 'matrix', 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, window_ends_at DATETIME NOT NULL, resolved_at DATETIME diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 7d64ccb..5159ad6 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -247,12 +247,41 @@ type RosterEntry struct { 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 // 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. +// +// 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 { - SnapshotAt int64 `json:"snapshot_at"` - Adventurers []RosterEntry `json:"adventurers"` + SnapshotAt int64 `json:"snapshot_at"` + 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. @@ -388,6 +417,59 @@ func EmitEscrowVerdict(v EscrowVerdict) { 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. func (c *Client) getJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 7d1733e..b43a561 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -290,6 +290,7 @@ func (p *AdventurePlugin) Init() error { go p.expeditionExtractionSweepTicker() go p.expeditionBoredomTicker() go p.mischiefTicker() + go p.peteMischiefTicker() // Auto-cashout any arena runs left in 'awaiting' from a prior restart p.arenaCleanupStaleRuns() diff --git a/internal/plugin/adventure_mischief.go b/internal/plugin/adventure_mischief.go index 519afd2..1a77403 100644 --- a/internal/plugin/adventure_mischief.go +++ b/internal/plugin/adventure_mischief.go @@ -350,19 +350,20 @@ type mischiefContract struct { BlessingCount int Source string Outcome string + OrderGUID string // the web order this was opened for; "" for a Matrix buy CreatedAt time.Time WindowEndsAt time.Time } const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed, 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) { c := &mischiefContract{} 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.CreatedAt, &c.WindowEndsAt) + &c.OrderGUID, &c.CreatedAt, &c.WindowEndsAt) if err != nil { return nil, err } @@ -382,14 +383,32 @@ func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error func insertMischiefContract(c *mischiefContract) error { _, err := db.Get().Exec( `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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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 } +// 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 // 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 @@ -799,6 +818,175 @@ func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) e 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 @:. 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 // 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 diff --git a/internal/plugin/euro.go b/internal/plugin/euro.go index e7a4772..567cd18 100644 --- a/internal/plugin/euro.go +++ b/internal/plugin/euro.go @@ -416,6 +416,21 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 { 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 // diff --git a/internal/plugin/pete_mischief.go b/internal/plugin/pete_mischief.go new file mode 100644 index 0000000..1478bc2 --- /dev/null +++ b/internal/plugin/pete_mischief.go @@ -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) +} diff --git a/internal/plugin/pete_mischief_test.go b/internal/plugin/pete_mischief_test.go new file mode 100644 index 0000000..bb6a73e --- /dev/null +++ b/internal/plugin/pete_mischief_test.go @@ -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) + } +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index c8900fe..07f9313 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -60,7 +60,7 @@ func (p *AdventurePlugin) peteRosterTicker() { var rosterPushOK bool func (p *AdventurePlugin) pushRoster() { - snap, err := buildRosterSnapshot(time.Now().UTC()) + snap, err := buildRosterSnapshot(time.Now().UTC(), p.euro) if err != nil { slog.Error("roster: build snapshot failed", "err", err) return @@ -96,8 +96,8 @@ func (p *AdventurePlugin) pushRoster() { // anonymized. A standing row showing class + level + zone is trivially // re-identifiable (there is one level-14 cleric), so "an adventurer" would have // been a fig leaf; absence is the only honest option. -func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) { - snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix()} +func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnapshot, error) { + snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix(), Tiers: mischiefTierCatalog()} // 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* @@ -133,6 +133,22 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) { } 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) { continue } @@ -175,3 +191,47 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) { } 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 +} diff --git a/internal/plugin/pete_roster_test.go b/internal/plugin/pete_roster_test.go index 957375b..2980c70 100644 --- a/internal/plugin/pete_roster_test.go +++ b/internal/plugin/pete_roster_test.go @@ -49,7 +49,7 @@ func TestRosterSnapshotReadsTheClock(t *testing.T) { // Never acted at all: the fold falls through to created_at. seedRosterPlayer(t, "@never:test", "Camcast", &old, nil) - snap, err := buildRosterSnapshot(now) + snap, err := buildRosterSnapshot(now, nil) if err != nil { t.Fatalf("buildRosterSnapshot: %v", err) } @@ -94,7 +94,7 @@ func TestRosterOmitsOptedOut(t *testing.T) { seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old) setNewsOptout("@hidden:test", true) - snap, err := buildRosterSnapshot(now) + snap, err := buildRosterSnapshot(now, nil) if err != nil { t.Fatalf("buildRosterSnapshot: %v", err) }