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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
//
|
//
|
||||||
|
|||||||
86
internal/plugin/pete_mischief.go
Normal file
86
internal/plugin/pete_mischief.go
Normal 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)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user