Compare commits
20
Commits
19255d933a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd4368631 | ||
|
|
15e229b6c3 | ||
|
|
5b07199631 | ||
|
|
a14859c5ee | ||
|
|
aac6c3e127 | ||
|
|
c40ac1e673 | ||
|
|
556b9440b8 | ||
|
|
b07abc1d13 | ||
|
|
0d8dba90df | ||
|
|
c2a40dad64 | ||
|
|
868a29e992 | ||
|
|
6b0aae9f4a | ||
|
|
b19ab5eff0 | ||
|
|
1dfd3ac9fb | ||
|
|
b4a276da36 | ||
|
|
8c3f2b0d07 | ||
|
|
7051e8ffff | ||
|
|
23563b6a6a | ||
|
|
91d25e9da1 | ||
|
|
8aa3e762ca |
@@ -38,6 +38,48 @@ type AdventureConfig struct {
|
|||||||
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
||||||
// "unset" and doesn't get silently rewritten to the default.
|
// "unset" and doesn't get silently rewritten to the default.
|
||||||
DigestHour *int `toml:"digest_hour"`
|
DigestHour *int `toml:"digest_hour"`
|
||||||
|
// RoomSilentTypes lists event types gogobee already announces in the games
|
||||||
|
// room in TwinBee's own voice. Pete stores them and publishes them on the
|
||||||
|
// site, but never posts them to Matrix — neither the live priority beat nor
|
||||||
|
// the daily digest — so the room hears each moment once.
|
||||||
|
//
|
||||||
|
// Unset falls back to defaultRoomSilentTypes; an explicit empty list turns
|
||||||
|
// the suppression off and restores the double-post.
|
||||||
|
RoomSilentTypes []string `toml:"room_silent_types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultRoomSilentTypes are the event types TwinBee announces to the games room
|
||||||
|
// itself as of gogobee HEAD. Each has a matching room announce on the gogobee
|
||||||
|
// side (announceTreasureToRoom, the duel broadcast, announceMischief*,
|
||||||
|
// announceWorldBoss), so a Pete post is a second telling of the same beat.
|
||||||
|
//
|
||||||
|
// Types NOT listed here have no room announce behind them and are Pete's alone
|
||||||
|
// to report: zone_clear, zone_first, boss_kill, arrival, departure, death,
|
||||||
|
// retreat, milestone, companion_hire.
|
||||||
|
var defaultRoomSilentTypes = []string{
|
||||||
|
"treasure_found",
|
||||||
|
"rival_result",
|
||||||
|
"mischief_contract",
|
||||||
|
"mischief_survived",
|
||||||
|
"mischief_downed",
|
||||||
|
"mischief_fizzled",
|
||||||
|
"siege_start",
|
||||||
|
"siege_win",
|
||||||
|
"siege_loss",
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomSilentSet returns the room-suppressed event types as a lookup set,
|
||||||
|
// resolving the unset case. Safe on a zero-value AdventureConfig.
|
||||||
|
func (a AdventureConfig) RoomSilentSet() map[string]bool {
|
||||||
|
types := a.RoomSilentTypes
|
||||||
|
if types == nil {
|
||||||
|
types = defaultRoomSilentTypes
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(types))
|
||||||
|
for _, t := range types {
|
||||||
|
set[t] = true
|
||||||
|
}
|
||||||
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
|
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The durable record of what has actually happened in the realm.
|
// The durable record of what has actually happened in the realm.
|
||||||
@@ -23,21 +24,25 @@ import (
|
|||||||
// former transport field kept here: for a treasure_found it carries the item's
|
// former transport field kept here: for a treasure_found it carries the item's
|
||||||
// name, which is the fact, not the delivery.
|
// name, which is the fact, not the delivery.
|
||||||
type AdvEvent struct {
|
type AdvEvent struct {
|
||||||
GUID string `json:"guid"`
|
GUID string `json:"guid"`
|
||||||
EventType string `json:"event_type"`
|
EventType string `json:"event_type"`
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Opponent string `json:"opponent"`
|
Opponent string `json:"opponent"`
|
||||||
Boss string `json:"boss"`
|
Boss string `json:"boss"`
|
||||||
Zone string `json:"zone"`
|
Zone string `json:"zone"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
Tally int `json:"tally"`
|
Tally int `json:"tally"`
|
||||||
Outcome string `json:"outcome"`
|
Outcome string `json:"outcome"`
|
||||||
Milestone string `json:"milestone"`
|
Milestone string `json:"milestone"`
|
||||||
Stakes string `json:"stakes"`
|
Stakes string `json:"stakes"`
|
||||||
Actors []string `json:"actors"`
|
Actors []string `json:"actors"`
|
||||||
OccurredAt int64 `json:"occurred_at"`
|
// RunID is set on the dispatches that are the *ending* of an expedition — a
|
||||||
|
// clear, a retreat, a death. It is the join from "how it went" to "what
|
||||||
|
// happened", and it is empty on every other kind of fact.
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE:
|
// InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE:
|
||||||
@@ -53,13 +58,82 @@ func InsertAdventureEvent(e *AdvEvent) error {
|
|||||||
_, err = Get().Exec(`
|
_, err = Get().Exec(`
|
||||||
INSERT OR IGNORE INTO adventure_events
|
INSERT OR IGNORE INTO adventure_events
|
||||||
(guid, event_type, tier, subject, opponent, boss, zone, region,
|
(guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||||
level, tally, outcome, milestone, stakes, actors, occurred_at)
|
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
|
e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
|
||||||
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
|
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors),
|
||||||
|
e.RunID, e.OccurredAt)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdventureEventByGUID returns the stored fact behind one dispatch, or nil when
|
||||||
|
// there isn't one. Missing is normal, not an error: the story row is inserted
|
||||||
|
// first and the fact record is best-effort (see handleAdventureIngest), and every
|
||||||
|
// dispatch that predates the fact table has a story and no fact at all. Callers
|
||||||
|
// render the thinner event_type-only view in that case.
|
||||||
|
func AdventureEventByGUID(guid string) (*AdvEvent, error) {
|
||||||
|
if guid == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var e AdvEvent
|
||||||
|
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors, runID sql.NullString
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||||
|
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at
|
||||||
|
FROM adventure_events WHERE guid = ?`, guid).Scan(
|
||||||
|
&e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion,
|
||||||
|
&e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &runID, &e.OccurredAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
|
||||||
|
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
|
||||||
|
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
|
||||||
|
e.RunID = runID.String
|
||||||
|
if actors.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdventureEventFacets returns the (event_type, tier, outcome) of many dispatches
|
||||||
|
// in one read, keyed by guid. It exists so a feed page can tint two dozen cards
|
||||||
|
// by what they actually are without paying a query per card — the pool is
|
||||||
|
// MaxOpenConns(1), so N round trips would serialize behind each other.
|
||||||
|
//
|
||||||
|
// Guids absent from the map are dispatches with no fact row; the caller leaves
|
||||||
|
// those untinted rather than guessing.
|
||||||
|
func AdventureEventFacets(guids []string) map[string]AdvEvent {
|
||||||
|
out := make(map[string]AdvEvent, len(guids))
|
||||||
|
if len(guids) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
q := `SELECT guid, event_type, tier, outcome FROM adventure_events WHERE guid IN (?` +
|
||||||
|
strings.Repeat(`,?`, len(guids)-1) + `)`
|
||||||
|
args := make([]any, len(guids))
|
||||||
|
for i, g := range guids {
|
||||||
|
args[i] = g
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var e AdvEvent
|
||||||
|
var tier, outcome sql.NullString
|
||||||
|
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &outcome); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
e.Tier, e.Outcome = tier.String, outcome.String
|
||||||
|
out[e.GUID] = e
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// BossTally is one monster and how many times this adventurer has put it down.
|
// BossTally is one monster and how many times this adventurer has put it down.
|
||||||
type BossTally struct {
|
type BossTally struct {
|
||||||
Boss string `json:"boss"`
|
Boss string `json:"boss"`
|
||||||
|
|||||||
@@ -105,8 +105,35 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// recorded before the treasure_found event existed carry NULL, which is right:
|
// recorded before the treasure_found event existed carry NULL, which is right:
|
||||||
// they had no such noun to keep.
|
// they had no such noun to keep.
|
||||||
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
|
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
|
||||||
|
// The run behind a dispatch that *ended* one. NULL on every fact filed before
|
||||||
|
// the run report existed and on every fact that isn't the end of an
|
||||||
|
// expedition; both simply render without the "read the run" link.
|
||||||
|
addColumnIfMissing(d, "adventure_events", "run_id", "TEXT")
|
||||||
|
// The liveblog's late-arriving prose and the column that carries it. Both are
|
||||||
|
// in their tables' CREATE TABLE — those tables have never shipped — so these
|
||||||
|
// two adds exist only for a database that already ran an earlier build of the
|
||||||
|
// run-liveblog branch.
|
||||||
|
addColumnIfMissing(d, "adventure_run", "summary", "TEXT NOT NULL DEFAULT ''")
|
||||||
|
addColumnIfMissing(d, "adventure_run_beat", "prose", "TEXT NOT NULL DEFAULT ''")
|
||||||
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
|
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
|
||||||
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
|
||||||
|
// W5b: the three verbs that take arguments (which zone, which loadout, how
|
||||||
|
// many days of sitting) carry them as one small JSON object. W5a's two verbs
|
||||||
|
// take none, so an existing row gets '' and reads back as no params — which is
|
||||||
|
// exactly what extract and siege_join mean.
|
||||||
|
addColumnIfMissing(d, "adventure_orders", "params", "TEXT NOT NULL DEFAULT ''")
|
||||||
|
// Adventure alerts. A subscription made before they existed knows only the OIDC
|
||||||
|
// subject, and the adventure ownership join needs the Matrix localpart — so an
|
||||||
|
// existing row gets "" here and is skipped for owner-scoped alerts until the
|
||||||
|
// browser re-subscribes, which it does on every page load that has push on.
|
||||||
|
// Realm-wide alerts (the Siege) need no localpart and work immediately.
|
||||||
|
addColumnIfMissing(d, "push_subscriptions", "user_localpart", "TEXT NOT NULL DEFAULT ''")
|
||||||
|
// The adventure watermark is deliberately separate from last_notified_at: the
|
||||||
|
// digest and the alerts run on different clocks (6 hours vs 2 minutes), and
|
||||||
|
// sharing one column would let whichever ran last decide what the other had
|
||||||
|
// already seen. 0 on a pre-existing row is corrected to "now" on the first
|
||||||
|
// pass rather than replaying every dispatch Pete has ever stored.
|
||||||
|
addColumnIfMissing(d, "push_subscriptions", "last_adv_notified_at", "INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||||
// Check sqlite_master before creating.
|
// Check sqlite_master before creating.
|
||||||
@@ -152,6 +179,15 @@ func RunMaintenance() {
|
|||||||
exec("prune old daily_visitors",
|
exec("prune old daily_visitors",
|
||||||
`DELETE FROM daily_visitors WHERE day < ?`, unixDay()-30)
|
`DELETE FROM daily_visitors WHERE day < ?`, unixDay()-30)
|
||||||
|
|
||||||
|
// Finished expedition logs. Kept for longer than the page shows them (the
|
||||||
|
// adventurer page hides a run six hours after it ends) because the dispatch
|
||||||
|
// that announced the run outlives the run, and a dead link from a story to
|
||||||
|
// its own log is worse than a log nobody reads. A run still walking is never
|
||||||
|
// pruned however old it looks — see PruneRuns for why.
|
||||||
|
if err := PruneRuns(nowUnix() - int64(14*86400)); err != nil {
|
||||||
|
slog.Error("db exec failed", "op", "prune finished runs", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
exec("wal checkpoint", "PRAGMA wal_checkpoint(TRUNCATE)")
|
exec("wal checkpoint", "PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
exec("optimize", "PRAGMA optimize")
|
exec("optimize", "PRAGMA optimize")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,59 @@ type PlayerDetail struct {
|
|||||||
Slots []EquipSlotView `json:"slots,omitempty"`
|
Slots []EquipSlotView `json:"slots,omitempty"`
|
||||||
// Balance is the owner's euro balance, for the upgrade/repair confirm dialogs.
|
// Balance is the owner's euro balance, for the upgrade/repair confirm dialogs.
|
||||||
Balance float64 `json:"balance,omitempty"`
|
Balance float64 `json:"balance,omitempty"`
|
||||||
|
// Zones / Resume / Babysit are the W5b action offers: what this owner may ask
|
||||||
|
// for from the web right now, priced by gogobee. Pete renders these and does no
|
||||||
|
// arithmetic — every price and every gate is the game's, quoted at push time.
|
||||||
|
//
|
||||||
|
// An offer is NOT a permission. It is up to two minutes stale, so gogobee
|
||||||
|
// re-resolves the zone, the price and the fee when the order lands. What the
|
||||||
|
// list buys is a page that does not offer a button certain to be refused.
|
||||||
|
Zones []ZoneOffer `json:"zones,omitempty"`
|
||||||
|
Resume *ResumeOffer `json:"resume,omitempty"`
|
||||||
|
Babysit *BabysitOffer `json:"babysit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneOffer is one place the owner may set out for. Absent entirely while they
|
||||||
|
// are already out, so an empty list means "not right now" rather than "nowhere".
|
||||||
|
type ZoneOffer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Hook string `json:"hook,omitempty"`
|
||||||
|
Postgame bool `json:"postgame,omitempty"`
|
||||||
|
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadoutOffer is one supply preset: what it is called, what it costs, and how
|
||||||
|
// many days of provisions it buys. Key is what the order carries back.
|
||||||
|
type LoadoutOffer struct {
|
||||||
|
Key string `json:"key"` // lean|balanced|heavy
|
||||||
|
Name string `json:"name"`
|
||||||
|
Blurb string `json:"blurb,omitempty"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeOffer is the extracted expedition still waiting to be walked back into.
|
||||||
|
// ExpiresAt is the end of the seven-day window, so the page can say how long is
|
||||||
|
// left rather than only that there is a way back.
|
||||||
|
type ResumeOffer struct {
|
||||||
|
ZoneID string `json:"zone_id"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Day int `json:"day"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BabysitOffer is the pet sitter's standing and the two prices they charge. It
|
||||||
|
// is pushed even when a sitter is engaged: "looked after until Tuesday" is what
|
||||||
|
// the page should say instead of a buy button.
|
||||||
|
type BabysitOffer struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
WeekCost int `json:"week_cost"`
|
||||||
|
MonthCost int `json:"month_cost"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EquipSlotView is one of the 5 standard equipment slots as gogobee pushed it,
|
// EquipSlotView is one of the 5 standard equipment slots as gogobee pushed it,
|
||||||
@@ -113,11 +166,18 @@ type HouseView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PetView is one pet slot.
|
// PetView is one pet slot.
|
||||||
|
//
|
||||||
|
// XP and XPNeeded are both **centi-XP**, the game's own unit: a pet earns 1.5
|
||||||
|
// points per action and the stored ledger is an integer, so everything is kept
|
||||||
|
// times a hundred. Divide by 100 to show a number to a human; do nothing else
|
||||||
|
// with either. XPNeeded is the engine's per-band curve and is 0 at the level cap,
|
||||||
|
// which is the only way to tell "full" from "nothing left to earn".
|
||||||
type PetView struct {
|
type PetView struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
XP int `json:"xp,omitempty"`
|
XP int `json:"xp,omitempty"`
|
||||||
|
XPNeeded int `json:"xp_needed,omitempty"`
|
||||||
ArmorTier int `json:"armor_tier,omitempty"`
|
ArmorTier int `json:"armor_tier,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The action queue: the second channel that carries intent back to the game box,
|
||||||
|
// and the first one that acts on the adventurer rather than on their kit.
|
||||||
|
//
|
||||||
|
// Same shape as the equip queue, deliberately — a signed-in owner asks for
|
||||||
|
// something on a page they own, Pete records only the intent, gogobee polls,
|
||||||
|
// runs the real rule against its own tables, and files a verdict Pete renders.
|
||||||
|
// Pete never ends an expedition and never swings at a boss; it records that
|
||||||
|
// somebody asked to.
|
||||||
|
//
|
||||||
|
// The reason this is its own table rather than more actions on equip_orders is
|
||||||
|
// vocabulary: an equip order is about an item in a slot at a tier, and none of
|
||||||
|
// those columns mean anything to "leave the dungeon". See the schema comment.
|
||||||
|
//
|
||||||
|
// Neither verb is naturally idempotent — an extract ends a run, a bout spends
|
||||||
|
// the day's only swing — so gogobee guards on the order guid before it mutates,
|
||||||
|
// exactly as the equip poller does. On Pete's side the mechanic is the equip
|
||||||
|
// queue's: a verdict only moves a still-pending row, so a retried verdict is a
|
||||||
|
// no-op.
|
||||||
|
|
||||||
|
// AdvOrder is one requested action and its current standing.
|
||||||
|
type AdvOrder struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
OwnerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
|
||||||
|
OwnerLocalpart string `json:"owner_localpart"` // Matrix localpart gogobee turns into an MXID — whose adventurer acts
|
||||||
|
Token string `json:"token,omitempty"` // the roster token ownership was proven against; display/audit only
|
||||||
|
CharacterName string `json:"character_name,omitempty"` // display copy, frozen at order time; gogobee ignores it
|
||||||
|
Action string `json:"action"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||||
|
// Params is the verb's arguments, and only the three verbs that take any carry
|
||||||
|
// it. It never names an adventurer — that still comes from the session — and
|
||||||
|
// nothing in it is trusted: Pete resolves every field against the owner's own
|
||||||
|
// pushed offer list before storing it, and gogobee resolves it again against
|
||||||
|
// the game's tables before it means anything.
|
||||||
|
Params *AdvOrderParams `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvOrderParams is the union of every verb's arguments, flat rather than
|
||||||
|
// per-verb because there are three of them and each reads one or two fields.
|
||||||
|
// A field a verb does not read is ignored rather than rejected.
|
||||||
|
type AdvOrderParams struct {
|
||||||
|
Zone string `json:"zone,omitempty"` // zone id, for expedition_start
|
||||||
|
Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy
|
||||||
|
Days int `json:"days,omitempty"` // 7 or 30, for babysit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions. These cross the wire to gogobee, so they are part of the contract.
|
||||||
|
//
|
||||||
|
// extract pull out of a running expedition, keeping loot/XP, resumable for a
|
||||||
|
// week — the game's `!extract`. Leader-only, which gogobee enforces.
|
||||||
|
// siege_join take today's one bout against the world boss — `!adventure
|
||||||
|
// worldboss fight`. The narration still lands in Matrix; the web gets
|
||||||
|
// the damage line as the verdict.
|
||||||
|
// W5b adds the three that take arguments and spend coins:
|
||||||
|
//
|
||||||
|
// expedition_start leave town for a zone with a supply loadout — `!expedition
|
||||||
|
// start <zone> <loadout>`. The most common action in the game
|
||||||
|
// and, until now, Matrix-only.
|
||||||
|
// expedition_resume walk back into the run you extracted from, re-outfitted —
|
||||||
|
// `!resume`. The other half of W5a's extract: that verb's own
|
||||||
|
// verdict tells people to type !resume, and this is the door.
|
||||||
|
// babysit engage the pet sitter for a week or a month — `!adventure
|
||||||
|
// babysit week|month`.
|
||||||
|
//
|
||||||
|
// W9 adds the three that undo the ones above. Each was already named inside a
|
||||||
|
// refusal or a confirm this page shows — "`!expedition abandon` first",
|
||||||
|
// "`!expedition leave` to walk out alone", "no refund if you cancel early" — so
|
||||||
|
// until now the web told people to go and type a command it could have offered.
|
||||||
|
// None takes an argument and none spends a euro:
|
||||||
|
//
|
||||||
|
// expedition_abandon end the expedition outright, for the whole party. Leader
|
||||||
|
// only, which gogobee enforces. Also the way to close an
|
||||||
|
// extracted run without paying to walk back into it first.
|
||||||
|
// expedition_leave walk out of somebody else's party alone, supplies left in
|
||||||
|
// the pool. Member only — the leader's row IS the expedition.
|
||||||
|
// babysit_cancel dismiss the sitter early. No refund, by the game's design.
|
||||||
|
const (
|
||||||
|
AdvActionExtract = "extract"
|
||||||
|
AdvActionSiegeJoin = "siege_join"
|
||||||
|
AdvActionExpedition = "expedition_start"
|
||||||
|
AdvActionResume = "expedition_resume"
|
||||||
|
AdvActionBabysit = "babysit"
|
||||||
|
|
||||||
|
AdvActionAbandon = "expedition_abandon"
|
||||||
|
AdvActionLeave = "expedition_leave"
|
||||||
|
AdvActionBabysitCancel = "babysit_cancel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Order states. Terminal states are enumerated rather than free-text so the page
|
||||||
|
// can say something specific about each; detail carries gogobee's prose. The
|
||||||
|
// rejection set is honest to what the game paths can actually answer.
|
||||||
|
const (
|
||||||
|
AdvOrderPending = "pending" // placed; gogobee hasn't acted yet
|
||||||
|
AdvOrderApplied = "applied" // it happened; detail says what
|
||||||
|
|
||||||
|
AdvRejectedNotRunning = "rejected_not_running" // extract: no active expedition
|
||||||
|
AdvRejectedNotLeader = "rejected_not_leader" // extract: a party member can't call the extraction
|
||||||
|
AdvRejectedNoSiege = "rejected_no_siege" // siege_join: nothing camped outside town
|
||||||
|
AdvRejectedAlreadyFought = "rejected_already_fought" // siege_join: today's bout is spent
|
||||||
|
AdvRejectedUnavailable = "rejected_unavailable" // no character, dead, or an argument the game does not sell
|
||||||
|
|
||||||
|
// W5b's three verbs.
|
||||||
|
AdvRejectedBusy = "rejected_busy" // already out, already seated, or already has a sitter
|
||||||
|
AdvRejectedInsufficientFunds = "rejected_insufficient_funds" // could not cover the cost
|
||||||
|
AdvRejectedZoneLocked = "rejected_zone_locked" // that zone is not open at this level
|
||||||
|
AdvRejectedNothingToResume = "rejected_nothing_to_resume" // nothing extracted, or its window closed
|
||||||
|
|
||||||
|
// W9's two. rejected_is_leader is deliberately not rejected_not_leader read
|
||||||
|
// backwards: they are opposite facts about the same person, and collapsing
|
||||||
|
// them would answer a leader who tried to walk out by telling them they are
|
||||||
|
// not the leader.
|
||||||
|
AdvRejectedIsLeader = "rejected_is_leader" // expedition_leave: the leader's row is the expedition
|
||||||
|
AdvRejectedNothingToCancel = "rejected_nothing_to_cancel" // babysit_cancel: no sitter is engaged
|
||||||
|
)
|
||||||
|
|
||||||
|
func validAdvAction(action string) bool {
|
||||||
|
switch action {
|
||||||
|
case AdvActionExtract, AdvActionSiegeJoin,
|
||||||
|
AdvActionExpedition, AdvActionResume, AdvActionBabysit,
|
||||||
|
AdvActionAbandon, AdvActionLeave, AdvActionBabysitCancel:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validAdvVerdict is the set of terminal states gogobee may hand back.
|
||||||
|
func validAdvVerdict(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader,
|
||||||
|
AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable,
|
||||||
|
AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked,
|
||||||
|
AdvRejectedNothingToResume, AdvRejectedIsLeader, AdvRejectedNothingToCancel:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNoSuchAdvOrder = errors.New("orders: no such order")
|
||||||
|
|
||||||
|
// ErrBadAdvVerdict is a verdict outside the terminal set. It is kept distinct
|
||||||
|
// from a storage failure so the web seam can answer 400 (gogobee sent something
|
||||||
|
// it will never be able to send successfully) rather than parking a perfectly
|
||||||
|
// resolvable order on a transient database error.
|
||||||
|
var ErrBadAdvVerdict = errors.New("orders: bad verdict")
|
||||||
|
|
||||||
|
// InsertAdvOrder records a fresh, pending order and returns it with a new guid.
|
||||||
|
// The guid is minted here so the owner has a stable reference the instant they
|
||||||
|
// click, before gogobee has heard of it. The caller has already proved the signed-
|
||||||
|
// in viewer owns this adventurer; whether the action is *legal right now* is
|
||||||
|
// gogobee's answer, at verdict time.
|
||||||
|
func InsertAdvOrder(ownerSub, ownerLocalpart, token, characterName, action string, params *AdvOrderParams) (AdvOrder, error) {
|
||||||
|
if !validAdvAction(action) {
|
||||||
|
return AdvOrder{}, fmt.Errorf("orders: bad action %q", action)
|
||||||
|
}
|
||||||
|
// Store the canonical re-serialised form, never the client's bytes: the caller
|
||||||
|
// has already resolved every field against the owner's own offer list, so what
|
||||||
|
// goes in the row is Pete's understanding of the request rather than the
|
||||||
|
// request itself.
|
||||||
|
paramsJSON := ""
|
||||||
|
if params != nil {
|
||||||
|
b, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return AdvOrder{}, fmt.Errorf("orders: marshal params: %w", err)
|
||||||
|
}
|
||||||
|
paramsJSON = string(b)
|
||||||
|
}
|
||||||
|
guid, err := newGUID()
|
||||||
|
if err != nil {
|
||||||
|
return AdvOrder{}, err
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`INSERT INTO adventure_orders
|
||||||
|
(guid, owner_sub, owner_localpart, token, character_name, action, status, params, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
guid, ownerSub, ownerLocalpart, token, characterName, action, AdvOrderPending, paramsJSON, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return AdvOrder{}, fmt.Errorf("orders: insert order: %w", err)
|
||||||
|
}
|
||||||
|
return AdvOrder{
|
||||||
|
GUID: guid, OwnerSub: ownerSub, OwnerLocalpart: ownerLocalpart,
|
||||||
|
Token: token, CharacterName: characterName, Action: action,
|
||||||
|
Status: AdvOrderPending, Params: params, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingAdvOrders is gogobee's poll: every order still waiting. Like the equip
|
||||||
|
// queue there is no claimed-but-stale window — a gogobee that dies mid-apply
|
||||||
|
// leaves the order pending to be offered again, and its own guid ledger makes the
|
||||||
|
// replay a no-op.
|
||||||
|
func PendingAdvOrders(limit int) ([]AdvOrder, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at
|
||||||
|
FROM adventure_orders
|
||||||
|
WHERE status = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT ?`,
|
||||||
|
AdvOrderPending, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("orders: pending orders: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanAdvOrders(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAdvOrder files gogobee's verdict against a pending order. Idempotent by
|
||||||
|
// the equip queue's mechanic: the UPDATE only moves a still-pending row, and the
|
||||||
|
// row is read back unconditionally so a first verdict, a retried verdict, and a
|
||||||
|
// missing row all take one path.
|
||||||
|
func ResolveAdvOrder(guid, status, detail string) (AdvOrder, error) {
|
||||||
|
if !validAdvVerdict(status) {
|
||||||
|
return AdvOrder{}, fmt.Errorf("%w %q", ErrBadAdvVerdict, status)
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`UPDATE adventure_orders SET status = ?, detail = ?, updated_at = ?
|
||||||
|
WHERE guid = ? AND status = ?`,
|
||||||
|
status, detail, now, guid, AdvOrderPending,
|
||||||
|
); err != nil {
|
||||||
|
return AdvOrder{}, fmt.Errorf("orders: resolve order: %w", err)
|
||||||
|
}
|
||||||
|
return AdvOrderByGUID(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvOrderByGUID reads one order.
|
||||||
|
func AdvOrderByGUID(guid string) (AdvOrder, error) {
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at
|
||||||
|
FROM adventure_orders WHERE guid = ?`, guid,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return AdvOrder{}, fmt.Errorf("orders: read order: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out, err := scanAdvOrders(rows)
|
||||||
|
if err != nil {
|
||||||
|
return AdvOrder{}, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return AdvOrder{}, ErrNoSuchAdvOrder
|
||||||
|
}
|
||||||
|
return out[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvOrdersByOwner returns an owner's own recent orders, newest first, for the
|
||||||
|
// status strip. Keyed on the OIDC subject so a rename doesn't strand history.
|
||||||
|
func AdvOrdersByOwner(ownerSub string, limit int) ([]AdvOrder, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at
|
||||||
|
FROM adventure_orders
|
||||||
|
WHERE owner_sub = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
ownerSub, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("orders: orders by owner: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanAdvOrders(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPendingAdvOrder reports whether this owner already has an unanswered order
|
||||||
|
// of this action outstanding. Unlike the equip queue's burst counter this is a
|
||||||
|
// correctness guard, not anti-spam: two queued extracts would apply in sequence
|
||||||
|
// and the second would come back "no expedition to leave", which reads as a
|
||||||
|
// failure for something that in fact worked.
|
||||||
|
func HasPendingAdvOrder(ownerSub, action string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND action = ? AND status = ?`,
|
||||||
|
ownerSub, action, AdvOrderPending,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("orders: pending lookup: %w", err)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAdvOrdersSince backs the web anti-spam guard, same role as the equip
|
||||||
|
// queue's: the real eligibility is gogobee's at verdict time, this only blunts a
|
||||||
|
// stuck mouse button.
|
||||||
|
func CountAdvOrdersSince(ownerSub string, since int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND created_at >= ?`,
|
||||||
|
ownerSub, since,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("orders: count recent orders: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAdvOrders(rows *sql.Rows) ([]AdvOrder, error) {
|
||||||
|
var out []AdvOrder
|
||||||
|
for rows.Next() {
|
||||||
|
var o AdvOrder
|
||||||
|
var params string
|
||||||
|
if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.Token,
|
||||||
|
&o.CharacterName, &o.Action, &o.Status, &o.Detail, ¶ms,
|
||||||
|
&o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("orders: scan order: %w", err)
|
||||||
|
}
|
||||||
|
// Unparseable params are dropped rather than failing the read. The row is
|
||||||
|
// still a real order somebody placed, and a verb whose arguments went
|
||||||
|
// missing is refused honestly by gogobee ("that order didn't say where
|
||||||
|
// to") — which beats the whole poll erroring on one bad row.
|
||||||
|
if params != "" {
|
||||||
|
var pp AdvOrderParams
|
||||||
|
if err := json.Unmarshal([]byte(params), &pp); err == nil {
|
||||||
|
o.Params = &pp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdvOrderRoundTrip(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
o, err := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionExtract, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
if o.GUID == "" || o.Status != AdvOrderPending {
|
||||||
|
t.Fatalf("order = %+v, want a guid and pending", o)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := PendingAdvOrders(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].GUID != o.GUID || pending[0].Action != AdvActionExtract {
|
||||||
|
t.Fatalf("pending = %+v", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, "out on day 3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != AdvOrderApplied || got.Detail != "out on day 3" {
|
||||||
|
t.Fatalf("resolved = %+v", got)
|
||||||
|
}
|
||||||
|
if left, _ := PendingAdvOrders(10); len(left) != 0 {
|
||||||
|
t.Fatalf("a resolved order is still pending: %+v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvOrderVerdictOnlyMovesAPendingRow is the idempotency mechanic: gogobee
|
||||||
|
// retries its verdict push, so the second one must be a read, not a write.
|
||||||
|
func TestAdvOrderVerdictOnlyMovesAPendingRow(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
o, _ := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionSiegeJoin, nil)
|
||||||
|
if _, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, "first"); err != nil {
|
||||||
|
t.Fatalf("first verdict: %v", err)
|
||||||
|
}
|
||||||
|
got, err := ResolveAdvOrder(o.GUID, AdvRejectedNoSiege, "second")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second verdict: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != AdvOrderApplied || got.Detail != "first" {
|
||||||
|
t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvOrderRejectsBadInput(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
if _, err := InsertAdvOrder("sub-1", "josie", "tok", "Josie", "sell_house", nil); err == nil {
|
||||||
|
t.Fatal("an unknown action was accepted")
|
||||||
|
}
|
||||||
|
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract, nil)
|
||||||
|
if _, err := ResolveAdvOrder(o.GUID, "exploded", ""); err == nil {
|
||||||
|
t.Fatal("an unknown verdict was accepted")
|
||||||
|
}
|
||||||
|
if _, err := AdvOrderByGUID("nope"); !errors.Is(err, ErrNoSuchAdvOrder) {
|
||||||
|
t.Fatalf("unknown guid err = %v, want ErrNoSuchAdvOrder", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHasPendingAdvOrderIsPerVerb: the guard stops a double-click on one button,
|
||||||
|
// not the other button.
|
||||||
|
func TestHasPendingAdvOrderIsPerVerb(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract, nil)
|
||||||
|
|
||||||
|
if got, _ := HasPendingAdvOrder("sub-1", AdvActionExtract); !got {
|
||||||
|
t.Fatal("a pending extract wasn't seen")
|
||||||
|
}
|
||||||
|
if got, _ := HasPendingAdvOrder("sub-1", AdvActionSiegeJoin); got {
|
||||||
|
t.Fatal("a pending extract blocked a bout")
|
||||||
|
}
|
||||||
|
if got, _ := HasPendingAdvOrder("sub-2", AdvActionExtract); got {
|
||||||
|
t.Fatal("one owner's pending order was seen for another")
|
||||||
|
}
|
||||||
|
// A resolved order stops holding the verb.
|
||||||
|
if _, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, ""); err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := HasPendingAdvOrder("sub-1", AdvActionExtract); got {
|
||||||
|
t.Fatal("a resolved order still holds its verb")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvOrdersByOwnerScopes(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if _, err := InsertAdvOrder("sub-A", "alice", "tok-a", "Alice", AdvActionExtract, nil); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := InsertAdvOrder("sub-B", "bob", "tok-b", "Bob", AdvActionExtract, nil); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
got, err := AdvOrdersByOwner("sub-A", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("by owner: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].OwnerLocalpart != "alice" {
|
||||||
|
t.Fatalf("orders = %+v, want only alice's", got)
|
||||||
|
}
|
||||||
|
if n, _ := CountAdvOrdersSince("sub-A", 0); n != 1 {
|
||||||
|
t.Fatalf("count = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-15
@@ -5,35 +5,73 @@ import "fmt"
|
|||||||
// PushSubscription is one browser/device endpoint a signed-in user has opted in
|
// PushSubscription is one browser/device endpoint a signed-in user has opted in
|
||||||
// for Web Push digests. See the push_subscriptions schema for the field roles.
|
// for Web Push digests. See the push_subscriptions schema for the field roles.
|
||||||
type PushSubscription struct {
|
type PushSubscription struct {
|
||||||
Endpoint string
|
Endpoint string
|
||||||
UserSub string
|
UserSub string
|
||||||
P256dh string
|
Localpart string
|
||||||
Auth string
|
P256dh string
|
||||||
CreatedAt int64
|
Auth string
|
||||||
LastNotifiedAt int64
|
CreatedAt int64
|
||||||
|
LastNotifiedAt int64
|
||||||
|
LastAdvNotifiedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
|
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
|
||||||
// endpoint is the primary key, so a re-subscribe from the same browser updates
|
// endpoint is the primary key, so a re-subscribe from the same browser updates
|
||||||
// the keys and resets the digest watermark to now — the user shouldn't be
|
// the keys and resets both watermarks to now — the user shouldn't be paged for
|
||||||
// paged for everything published before they opted in.
|
// everything published before they opted in.
|
||||||
func AddPushSubscription(sub, endpoint, p256dh, auth string) error {
|
//
|
||||||
|
// localpart is the session's Matrix handle, refreshed on every re-subscribe so a
|
||||||
|
// row stored by a build that predated adventure alerts heals itself the first
|
||||||
|
// time that browser subscribes again. It may legitimately be empty (a session
|
||||||
|
// minted before the game economy existed carries no username); such a row simply
|
||||||
|
// never matches an owner-scoped alert.
|
||||||
|
func AddPushSubscription(sub, localpart, endpoint, p256dh, auth string) error {
|
||||||
now := nowUnix()
|
now := nowUnix()
|
||||||
_, err := Get().Exec(`
|
_, err := Get().Exec(`
|
||||||
INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at)
|
INSERT INTO push_subscriptions
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
(endpoint, user_sub, user_localpart, p256dh, auth, created_at, last_notified_at, last_adv_notified_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(endpoint) DO UPDATE SET
|
ON CONFLICT(endpoint) DO UPDATE SET
|
||||||
user_sub = excluded.user_sub,
|
user_sub = excluded.user_sub,
|
||||||
|
user_localpart = excluded.user_localpart,
|
||||||
p256dh = excluded.p256dh,
|
p256dh = excluded.p256dh,
|
||||||
auth = excluded.auth,
|
auth = excluded.auth,
|
||||||
last_notified_at = excluded.last_notified_at`,
|
last_notified_at = excluded.last_notified_at,
|
||||||
endpoint, sub, p256dh, auth, now, now)
|
last_adv_notified_at = excluded.last_adv_notified_at`,
|
||||||
|
endpoint, sub, localpart, p256dh, auth, now, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add push subscription: %w", err)
|
return fmt.Errorf("add push subscription: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HealPushSubscriptionLocalpart fills in the Matrix handle on a row that was
|
||||||
|
// stored before push_subscriptions had the column — the rows that can never match
|
||||||
|
// an owner-scoped adventure alert, and whose owners have no way to notice.
|
||||||
|
//
|
||||||
|
// It is deliberately NOT AddPushSubscription with the same arguments. That upsert
|
||||||
|
// resets both watermarks to now, which is right when somebody opts in and
|
||||||
|
// catastrophic on a heal: the browser would call it on every page load, so a
|
||||||
|
// reader who visits daily would silently never receive a digest or an alert
|
||||||
|
// again. This touches one column and no clock.
|
||||||
|
//
|
||||||
|
// Scoped to user_sub so presenting somebody else's endpoint rewrites nothing, and
|
||||||
|
// restricted to rows whose localpart is still empty — so it is a no-op after the
|
||||||
|
// first success, and it can never overwrite a good handle with a stale one.
|
||||||
|
func HealPushSubscriptionLocalpart(sub, endpoint, localpart string) error {
|
||||||
|
if localpart == "" {
|
||||||
|
return nil // nothing to heal with; see AddPushSubscription on empty handles
|
||||||
|
}
|
||||||
|
_, err := Get().Exec(
|
||||||
|
`UPDATE push_subscriptions SET user_localpart = ?
|
||||||
|
WHERE endpoint = ? AND user_sub = ? AND user_localpart = ''`,
|
||||||
|
localpart, endpoint, sub)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("heal push subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for
|
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for
|
||||||
// the digest sender's prune path, where a push service has reported the endpoint
|
// the digest sender's prune path, where a push service has reported the endpoint
|
||||||
// gone (404/410) and there's no caller identity to scope by. User-initiated
|
// gone (404/410) and there's no caller identity to scope by. User-initiated
|
||||||
@@ -61,7 +99,8 @@ func RemovePushSubscriptionForUser(sub, endpoint string) error {
|
|||||||
// ListPushSubscriptions returns every stored subscription, for the digest sender.
|
// ListPushSubscriptions returns every stored subscription, for the digest sender.
|
||||||
func ListPushSubscriptions() ([]PushSubscription, error) {
|
func ListPushSubscriptions() ([]PushSubscription, error) {
|
||||||
rows, err := Get().Query(
|
rows, err := Get().Query(
|
||||||
`SELECT endpoint, user_sub, p256dh, auth, created_at, last_notified_at
|
`SELECT endpoint, user_sub, user_localpart, p256dh, auth,
|
||||||
|
created_at, last_notified_at, last_adv_notified_at
|
||||||
FROM push_subscriptions`)
|
FROM push_subscriptions`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -70,7 +109,8 @@ func ListPushSubscriptions() ([]PushSubscription, error) {
|
|||||||
var out []PushSubscription
|
var out []PushSubscription
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p PushSubscription
|
var p PushSubscription
|
||||||
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil {
|
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.Localpart, &p.P256dh, &p.Auth,
|
||||||
|
&p.CreatedAt, &p.LastNotifiedAt, &p.LastAdvNotifiedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
@@ -78,6 +118,19 @@ func ListPushSubscriptions() ([]PushSubscription, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TouchAdvPushSubscription advances an endpoint's *adventure alert* watermark, so
|
||||||
|
// the next pass only considers dispatches that occurred after ts. Kept separate
|
||||||
|
// from TouchPushSubscription for the reason the schema gives: the digest and the
|
||||||
|
// alerts must not be able to consume each other's backlog.
|
||||||
|
func TouchAdvPushSubscription(endpoint string, ts int64) error {
|
||||||
|
_, err := Get().Exec(
|
||||||
|
`UPDATE push_subscriptions SET last_adv_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("touch adventure push watermark: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// TouchPushSubscription advances an endpoint's digest watermark so its next
|
// TouchPushSubscription advances an endpoint's digest watermark so its next
|
||||||
// digest only considers stories seen after ts.
|
// digest only considers stories seen after ts.
|
||||||
func TouchPushSubscription(endpoint string, ts int64) error {
|
func TouchPushSubscription(endpoint string, ts int64) error {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reads behind adventure push alerts. The alert sender needs two things the
|
||||||
|
// rest of the storage layer does not: dispatches ordered by when they *happened*
|
||||||
|
// rather than by subject, and a way to turn a signed-in identity into the
|
||||||
|
// character name a fact would carry.
|
||||||
|
|
||||||
|
// AdvEventsSince returns dispatches that occurred after sinceUnix, newest first,
|
||||||
|
// capped at limit.
|
||||||
|
//
|
||||||
|
// The clock is occurred_at, not an arrival time, and that choice has a
|
||||||
|
// consequence worth stating: a dispatch that reaches Pete late but describes
|
||||||
|
// something old — a queue row unparked months after the fact, which W0's
|
||||||
|
// inversion made possible — sorts behind the watermark and is never alerted on.
|
||||||
|
// That is the outcome we want. An alert is a claim that something is happening
|
||||||
|
// now, and a phone buzzing about a hire from March would be a lie told urgently.
|
||||||
|
func AdvEventsSince(sinceUnix int64, limit int) ([]AdvEvent, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||||
|
level, tally, outcome, milestone, stakes, run_id, occurred_at
|
||||||
|
FROM adventure_events
|
||||||
|
WHERE occurred_at > ?
|
||||||
|
ORDER BY occurred_at DESC
|
||||||
|
LIMIT ?`, sinceUnix, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []AdvEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e AdvEvent
|
||||||
|
var tier, subject, opponent, boss, zone, region sql.NullString
|
||||||
|
var outcome, milestone, stakes, runID sql.NullString
|
||||||
|
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
||||||
|
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
||||||
|
&stakes, &runID, &e.OccurredAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
|
||||||
|
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
|
||||||
|
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
|
||||||
|
e.RunID = runID.String
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvCharacterForOwner returns the character name currently on the board for a
|
||||||
|
// signed-in user's localpart, which is the join an owner-scoped alert needs: a
|
||||||
|
// fact carries a character *name*, a session carries a localpart, and the only
|
||||||
|
// thing that connects them is the owner-private (localpart -> token) row gogobee
|
||||||
|
// pushes alongside the public (token -> name) board.
|
||||||
|
//
|
||||||
|
// It fails closed, and every way it can fail is a way it should:
|
||||||
|
//
|
||||||
|
// - no self-detail row: gogobee has stopped pushing for this player, so Pete
|
||||||
|
// has no current basis to claim any name is theirs.
|
||||||
|
// - no roster row for the token: the player is off the board — removed, or
|
||||||
|
// opted out of the news entirely. An opted-out player's facts are anonymised
|
||||||
|
// on the wire anyway, so there is no name left to match even in principle.
|
||||||
|
//
|
||||||
|
// Both cases mean the caller sends nothing, which is the correct answer to "is
|
||||||
|
// this dispatch about you" when Pete cannot honestly tell.
|
||||||
|
func AdvCharacterForOwner(localpart string) (string, bool) {
|
||||||
|
if localpart == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT r.name
|
||||||
|
FROM player_self_detail d
|
||||||
|
JOIN adventure_roster r ON r.token = d.token
|
||||||
|
WHERE d.localpart = ?`, localpart).Scan(&name)
|
||||||
|
if err != nil || name == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// seedOwnedCharacter puts a player on the board and gives them an owner, which
|
||||||
|
// is the two-row arrangement AdvCharacterForOwner has to walk: the public
|
||||||
|
// (token -> name) board plus the owner-private (localpart -> token) detail row.
|
||||||
|
func seedOwnedCharacter(t *testing.T, localpart, token, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := ReplaceRoster([]RosterEntry{{
|
||||||
|
Token: token, Name: name, Level: 14, ClassRace: "Cleric", Status: "idle",
|
||||||
|
}}, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ReplacePlayerDetail([]PlayerDetail{{Localpart: localpart, Token: token}}, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvCharacterForOwnerNeedsBothHalves is the privacy contract behind every
|
||||||
|
// owner-scoped alert. The sender asks "which character is this subscriber's" and
|
||||||
|
// then compares that name against a dispatch's subject; if this function ever
|
||||||
|
// answered generously, somebody's phone would buzz about another player's death.
|
||||||
|
//
|
||||||
|
// Each half of the join is removed in turn, because each one goes missing for a
|
||||||
|
// real reason in production: the roster row disappears when a player opts out of
|
||||||
|
// the news or leaves the board, and the self-detail row disappears when gogobee
|
||||||
|
// stops pushing for them. Both must fail closed.
|
||||||
|
func TestAdvCharacterForOwnerNeedsBothHalves(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
seedOwnedCharacter(t, "josie", "tok-josie", "Josie")
|
||||||
|
|
||||||
|
if name, ok := AdvCharacterForOwner("josie"); !ok || name != "Josie" {
|
||||||
|
t.Fatalf("owner lookup = %q/%v, want Josie/true", name, ok)
|
||||||
|
}
|
||||||
|
if _, ok := AdvCharacterForOwner("quack"); ok {
|
||||||
|
t.Error("a localpart with no self-detail row resolved to a character")
|
||||||
|
}
|
||||||
|
if _, ok := AdvCharacterForOwner(""); ok {
|
||||||
|
t.Error("an empty localpart resolved to a character")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off the board — opted out of the news, or removed. The self-detail row is
|
||||||
|
// still there and still points at tok-josie, so only the roster join stops
|
||||||
|
// this. An opted-out player's facts are anonymised on the wire anyway, so
|
||||||
|
// there would be no name left to match even if this leaked.
|
||||||
|
if err := ReplaceRoster(nil, 2000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if name, ok := AdvCharacterForOwner("josie"); ok {
|
||||||
|
t.Errorf("off-the-board player still resolved to %q; alerts must close with the board", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the mirror case: on the board, but gogobee has stopped pushing the
|
||||||
|
// owner-private half, so Pete has no basis to claim the name is theirs.
|
||||||
|
seedOwnedCharacter(t, "josie", "tok-josie", "Josie")
|
||||||
|
if err := ReplacePlayerDetail(nil, 3000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if name, ok := AdvCharacterForOwner("josie"); ok {
|
||||||
|
t.Errorf("resolved %q with no self-detail row; the ownership claim has no source", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvEventsSinceIsOrderedNewestFirst pins the ordering the sender depends on
|
||||||
|
// twice over: it takes the first match as "the newest thing to tell you about",
|
||||||
|
// and it advances every watermark to events[0]. Reverse this and the alert names
|
||||||
|
// the oldest unseen dispatch while the watermark skips the rest.
|
||||||
|
func TestAdvEventsSinceIsOrderedNewestFirst(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
for _, e := range []AdvEvent{
|
||||||
|
{GUID: "g1", EventType: "death", Subject: "Josie", OccurredAt: 100},
|
||||||
|
{GUID: "g2", EventType: "zone_clear", Subject: "Josie", OccurredAt: 300},
|
||||||
|
{GUID: "g3", EventType: "retreat", Subject: "Josie", OccurredAt: 200},
|
||||||
|
} {
|
||||||
|
if err := InsertAdventureEvent(&e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := AdvEventsSince(150, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d events after ts=150, want 2 (the ts=100 one is behind the watermark)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].GUID != "g2" || got[1].GUID != "g3" {
|
||||||
|
t.Fatalf("order = %s,%s; want g2,g3 (newest first)", got[0].GUID, got[1].GUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit <= 0 means "nothing", not "everything": the sender treats the result
|
||||||
|
// as a bounded window and an unbounded read here would be a surprise.
|
||||||
|
if got, _ := AdvEventsSince(0, 0); len(got) != 0 {
|
||||||
|
t.Errorf("limit 0 returned %d events, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvEventsSinceCarriesRunID pins that the run link survives the read. The
|
||||||
|
// alert for an ended expedition points at the run report when there is one, and
|
||||||
|
// that field is the only thing distinguishing it from the plain story permalink.
|
||||||
|
func TestAdvEventsSinceCarriesRunID(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if err := InsertAdventureEvent(&AdvEvent{
|
||||||
|
GUID: "g1", EventType: "zone_clear", Subject: "Josie",
|
||||||
|
RunID: "run-7", OccurredAt: 500,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := AdvEventsSince(0, 10)
|
||||||
|
if err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("read back %d events (err %v), want 1", len(got), err)
|
||||||
|
}
|
||||||
|
if got[0].RunID != "run-7" {
|
||||||
|
t.Errorf("run id = %q, want run-7", got[0].RunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvWatermarkIsIndependentOfTheDigest pins the schema note. The two senders
|
||||||
|
// run on different clocks; if they shared a column, whichever ran last would
|
||||||
|
// decide what the other had already seen, and one of the two channels would go
|
||||||
|
// permanently quiet in a way nobody would think to look for.
|
||||||
|
func TestAdvWatermarkIsIndependentOfTheDigest(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
const ep = "https://push.example/ep"
|
||||||
|
if err := AddPushSubscription("sub-1", "josie", ep, "p", "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := TouchAdvPushSubscription(ep, 4242); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subs, _ := ListPushSubscriptions()
|
||||||
|
if len(subs) != 1 {
|
||||||
|
t.Fatalf("got %d subscriptions, want 1", len(subs))
|
||||||
|
}
|
||||||
|
if subs[0].LastAdvNotifiedAt != 4242 {
|
||||||
|
t.Errorf("adventure watermark = %d, want 4242", subs[0].LastAdvNotifiedAt)
|
||||||
|
}
|
||||||
|
if subs[0].LastNotifiedAt == 4242 {
|
||||||
|
t.Error("touching the adventure watermark moved the digest watermark too")
|
||||||
|
}
|
||||||
|
if subs[0].Localpart != "josie" {
|
||||||
|
t.Errorf("localpart = %q, want josie; owner-scoped alerts have nothing to join on without it", subs[0].Localpart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reverse direction, so neither can quietly consume the other's backlog.
|
||||||
|
if err := TouchPushSubscription(ep, 99); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subs, _ = ListPushSubscriptions()
|
||||||
|
if subs[0].LastAdvNotifiedAt != 4242 {
|
||||||
|
t.Errorf("digest touch moved the adventure watermark to %d", subs[0].LastAdvNotifiedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// W9: healing the Matrix handle onto a subscription stored before the column
|
||||||
|
// existed. Those rows can never match an owner-scoped adventure alert, and their
|
||||||
|
// owners have no way to notice — the browser only re-subscribes on a click.
|
||||||
|
//
|
||||||
|
// The trap this exists to avoid is worth stating plainly, because the obvious
|
||||||
|
// implementation is a one-liner that reuses AddPushSubscription with the same
|
||||||
|
// arguments: that upsert resets BOTH watermarks to now. The heal runs from the
|
||||||
|
// page, so it would fire far more often than a subscribe does, and every run
|
||||||
|
// would push the digest's own "last told them about" stamp forward — a reader who
|
||||||
|
// visits daily would silently stop receiving digests and adventure alerts alike,
|
||||||
|
// from a change made to fix notifications.
|
||||||
|
|
||||||
|
func findSub(t *testing.T, endpoint string) PushSubscription {
|
||||||
|
t.Helper()
|
||||||
|
subs, err := ListPushSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range subs {
|
||||||
|
if s.Endpoint == endpoint {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no subscription for %q", endpoint)
|
||||||
|
return PushSubscription{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealFillsAnEmptyLocalpartAndNothingElse(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
const ep = "https://push.example/ep-old"
|
||||||
|
|
||||||
|
// A row as a pre-W6 build left it: no Matrix handle.
|
||||||
|
if err := AddPushSubscription("sub-1", "", ep, "p256", "auth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := findSub(t, ep)
|
||||||
|
if before.Localpart != "" {
|
||||||
|
t.Fatalf("seed carries a localpart %q; the test isn't testing anything", before.Localpart)
|
||||||
|
}
|
||||||
|
// Move both watermarks off "now" so a reset would be visible rather than
|
||||||
|
// coincidentally equal.
|
||||||
|
if err := TouchPushSubscription(ep, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := TouchAdvPushSubscription(ep, 2000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := HealPushSubscriptionLocalpart("sub-1", ep, "josie"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := findSub(t, ep)
|
||||||
|
if got.Localpart != "josie" {
|
||||||
|
t.Fatalf("localpart = %q, want josie", got.Localpart)
|
||||||
|
}
|
||||||
|
// The whole point: the clocks did not move.
|
||||||
|
if got.LastNotifiedAt != 1000 {
|
||||||
|
t.Fatalf("digest watermark = %d, want 1000 — a heal that resets it silences the digest",
|
||||||
|
got.LastNotifiedAt)
|
||||||
|
}
|
||||||
|
if got.LastAdvNotifiedAt != 2000 {
|
||||||
|
t.Fatalf("adventure watermark = %d, want 2000 — a heal that resets it silences the alerts",
|
||||||
|
got.LastAdvNotifiedAt)
|
||||||
|
}
|
||||||
|
if got.P256dh != "p256" || got.Auth != "auth" {
|
||||||
|
t.Fatal("the heal rewrote the encryption keys; it must touch one column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealNeverOverwritesAKnownHandle(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
const ep = "https://push.example/ep-good"
|
||||||
|
if err := AddPushSubscription("sub-1", "josie", ep, "p256", "auth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A later session whose username resolved differently must not be able to
|
||||||
|
// rewrite a handle that is already good — the heal is for empty rows only, so
|
||||||
|
// it is a no-op the moment one has succeeded.
|
||||||
|
if err := HealPushSubscriptionLocalpart("sub-1", ep, "someone-else"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := findSub(t, ep); got.Localpart != "josie" {
|
||||||
|
t.Fatalf("localpart = %q, want the original josie", got.Localpart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealIsScopedToTheCaller(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
const ep = "https://push.example/ep-theirs"
|
||||||
|
if err := AddPushSubscription("sub-owner", "", ep, "p256", "auth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Somebody else presenting the endpoint string writes nothing. Endpoints are
|
||||||
|
// not secrets and the client hands one straight up, so this is the guard that
|
||||||
|
// stops a stranger attaching their own handle to another account's device.
|
||||||
|
if err := HealPushSubscriptionLocalpart("sub-attacker", ep, "attacker"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := findSub(t, ep); got.Localpart != "" {
|
||||||
|
t.Fatalf("localpart = %q; another account healed a row it does not own", got.Localpart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealWithNoHandleIsANoOp(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
const ep = "https://push.example/ep-nouser"
|
||||||
|
if err := AddPushSubscription("sub-1", "", ep, "p256", "auth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A session minted before the game economy existed carries no username. There
|
||||||
|
// is nothing to heal with, and writing "" over "" is not worth a statement.
|
||||||
|
if err := HealPushSubscriptionLocalpart("sub-1", ep, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := findSub(t, ep); got.Localpart != "" {
|
||||||
|
t.Fatalf("localpart = %q, want empty", got.Localpart)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,15 @@ import "testing"
|
|||||||
func TestPushSubscriptionLifecycle(t *testing.T) {
|
func TestPushSubscriptionLifecycle(t *testing.T) {
|
||||||
setupTestDB(t)
|
setupTestDB(t)
|
||||||
|
|
||||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
|
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A second endpoint for the same user (e.g. a second device).
|
// A second endpoint for the same user (e.g. a second device).
|
||||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
|
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A different user.
|
// A different user.
|
||||||
if err := AddPushSubscription("sub-2", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
|
if err := AddPushSubscription("sub-2", "quack", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-subscribing the same endpoint updates keys in place, not a new row.
|
// Re-subscribing the same endpoint updates keys in place, not a new row.
|
||||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
|
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
subs, _ = ListPushSubscriptions()
|
subs, _ = ListPushSubscriptions()
|
||||||
@@ -54,7 +54,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) {
|
func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) {
|
||||||
setupTestDB(t)
|
setupTestDB(t)
|
||||||
if err := AddPushSubscription("sub-1", "https://push.example/ep", "p", "a"); err != nil {
|
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
subs, _ := ListPushSubscriptions()
|
subs, _ := ListPushSubscriptions()
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The realm, as gogobee pushes it.
|
||||||
|
//
|
||||||
|
// Three pages ride one snapshot: the world map, the board, and the hall of
|
||||||
|
// firsts. They are one push rather than three because they are one *question* —
|
||||||
|
// "what is this place, and what has happened here" — and because every number in
|
||||||
|
// all three comes off the same scan of the same run history. Splitting them would
|
||||||
|
// mean three ways for the same fact to be a different number depending on which
|
||||||
|
// page you were looking at.
|
||||||
|
//
|
||||||
|
// Nothing here is an event. The events (a zone_first dispatch, a death) come down
|
||||||
|
// the dispatch queue like any other fact. This is the standing state of the world,
|
||||||
|
// which is the only kind of thing a map can honestly draw.
|
||||||
|
|
||||||
|
// RealmOccupant is somebody on an expedition in a zone right now. Token is the
|
||||||
|
// public board token and is EMPTY only in the sense that this row never exists
|
||||||
|
// for an opted-out player — unlike a siege contributor, presence is dropped
|
||||||
|
// outright upstream rather than anonymised, so every row here has a name and a
|
||||||
|
// link.
|
||||||
|
type RealmOccupant struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmZone is one place in the world: what it is, who first got through it, how
|
||||||
|
// many have since, and who is inside it.
|
||||||
|
//
|
||||||
|
// FirstBy with an empty FirstToken is the anonymised case — the zone HAS been
|
||||||
|
// cleared and the claim stands, but the clearer opted out and gets no name and no
|
||||||
|
// link. Clears > 0 with no FirstBy at all is the same state seen from the other
|
||||||
|
// side, and both render as "cleared, by somebody" rather than as never-cleared,
|
||||||
|
// which would be a false statement about the realm rather than a withheld one.
|
||||||
|
type RealmZone struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
LevelMin int `json:"level_min"`
|
||||||
|
LevelMax int `json:"level_max"`
|
||||||
|
Faction string `json:"faction,omitempty"`
|
||||||
|
Atmosphere string `json:"atmosphere,omitempty"`
|
||||||
|
Postgame bool `json:"postgame,omitempty"`
|
||||||
|
|
||||||
|
FirstClearBy string `json:"first_clear_by,omitempty"`
|
||||||
|
FirstClearToken string `json:"first_clear_token,omitempty"`
|
||||||
|
FirstClearAt int64 `json:"first_clear_at,omitempty"`
|
||||||
|
|
||||||
|
Clears int `json:"clears"`
|
||||||
|
Clearers int `json:"clearers"`
|
||||||
|
|
||||||
|
Occupants []RealmOccupant `json:"occupants,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmFirst is one entry in the hall of firsts.
|
||||||
|
type RealmFirst struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Tier int `json:"tier,omitempty"`
|
||||||
|
Holder string `json:"holder,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
AtUnix int64 `json:"at_unix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmStanding is one line on the board.
|
||||||
|
type RealmStanding struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
ClassRace string `json:"class_race,omitempty"`
|
||||||
|
DeepestTier int `json:"deepest_tier"`
|
||||||
|
Clears int `json:"clears"`
|
||||||
|
Zones int `json:"zones"`
|
||||||
|
Firsts int `json:"firsts"`
|
||||||
|
SiegeDamage int `json:"siege_damage"`
|
||||||
|
SiegeFights int `json:"siege_fights"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realm is the whole snapshot.
|
||||||
|
type Realm struct {
|
||||||
|
Zones []RealmZone `json:"zones,omitempty"`
|
||||||
|
Firsts []RealmFirst `json:"firsts,omitempty"`
|
||||||
|
Standings []RealmStanding `json:"standings,omitempty"`
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceRealm swaps the whole realm for a new snapshot, in one transaction.
|
||||||
|
//
|
||||||
|
// Replace, never merge, for the reason the siege does it: a zone whose clear
|
||||||
|
// count was corrected upstream, a player who opted out, an occupant who came
|
||||||
|
// home — all of those are *removals*, and a merge has no way to express one. The
|
||||||
|
// transaction means a reader mid-swap sees the old realm or the new one, never a
|
||||||
|
// zone list with the previous board under it.
|
||||||
|
func ReplaceRealm(r Realm, snapshotAt int64) error {
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
for _, t := range []string{
|
||||||
|
"adventure_realm_zone",
|
||||||
|
"adventure_realm_occupant",
|
||||||
|
"adventure_realm_first",
|
||||||
|
"adventure_realm_standing",
|
||||||
|
} {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM ` + t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zstmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO adventure_realm_zone
|
||||||
|
(pos, zone_id, display, tier, level_min, level_max, faction, atmosphere,
|
||||||
|
postgame, first_by, first_token, first_at, clears, clearers)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zstmt.Close()
|
||||||
|
|
||||||
|
ostmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO adventure_realm_occupant (pos, zone_id, token, name, level, day)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer ostmt.Close()
|
||||||
|
|
||||||
|
opos := 0
|
||||||
|
for i, z := range r.Zones {
|
||||||
|
if _, err := zstmt.Exec(i, z.ID, z.Display, z.Tier, z.LevelMin, z.LevelMax,
|
||||||
|
z.Faction, z.Atmosphere, z.Postgame, z.FirstClearBy, z.FirstClearToken,
|
||||||
|
z.FirstClearAt, z.Clears, z.Clearers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, o := range z.Occupants {
|
||||||
|
if _, err := ostmt.Exec(opos, z.ID, o.Token, o.Name, o.Level, o.Day); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fstmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO adventure_realm_first (pos, kind, target, display, tier, holder, token, at_unix)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fstmt.Close()
|
||||||
|
for i, f := range r.Firsts {
|
||||||
|
if _, err := fstmt.Exec(i, f.Kind, f.Target, f.Display, f.Tier, f.Holder, f.Token, f.AtUnix); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sstmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO adventure_realm_standing
|
||||||
|
(pos, token, name, level, class_race, deepest_tier, clears, zones, firsts,
|
||||||
|
siege_damage, siege_fights)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sstmt.Close()
|
||||||
|
for i, s := range r.Standings {
|
||||||
|
if _, err := sstmt.Exec(i, s.Token, s.Name, s.Level, s.ClassRace, s.DeepestTier,
|
||||||
|
s.Clears, s.Zones, s.Firsts, s.SiegeDamage, s.SiegeFights); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO adventure_realm_meta (id, snapshot_at) VALUES (1, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRealm returns the realm as last pushed. ok is false when gogobee has never
|
||||||
|
// pushed one — distinct from a pushed snapshot that happens to be empty, which is
|
||||||
|
// a real answer (a realm with no living adventurers on the board is a thing that
|
||||||
|
// can be true) and which the pages render differently.
|
||||||
|
func LoadRealm() (Realm, bool, error) {
|
||||||
|
var r Realm
|
||||||
|
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_realm_meta WHERE id = 1`).
|
||||||
|
Scan(&r.SnapshotAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Realm{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Realm{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each cursor is drained fully before the next query opens. The pool is one
|
||||||
|
// connection wide, and a nested read is the deadlock the run-beat batch
|
||||||
|
// shipped with and then had to have cut out of it.
|
||||||
|
zones, err := loadRealmZones()
|
||||||
|
if err != nil {
|
||||||
|
return r, true, err
|
||||||
|
}
|
||||||
|
occ, err := loadRealmOccupants()
|
||||||
|
if err != nil {
|
||||||
|
return r, true, err
|
||||||
|
}
|
||||||
|
for i := range zones {
|
||||||
|
zones[i].Occupants = occ[zones[i].ID]
|
||||||
|
}
|
||||||
|
r.Zones = zones
|
||||||
|
|
||||||
|
if r.Firsts, err = loadRealmFirsts(); err != nil {
|
||||||
|
return r, true, err
|
||||||
|
}
|
||||||
|
if r.Standings, err = loadRealmStandings(); err != nil {
|
||||||
|
return r, true, err
|
||||||
|
}
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRealmZones() ([]RealmZone, error) {
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT zone_id, display, tier, level_min, level_max, faction, atmosphere,
|
||||||
|
postgame, first_by, first_token, first_at, clears, clearers
|
||||||
|
FROM adventure_realm_zone ORDER BY pos ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RealmZone
|
||||||
|
for rows.Next() {
|
||||||
|
var z RealmZone
|
||||||
|
if err := rows.Scan(&z.ID, &z.Display, &z.Tier, &z.LevelMin, &z.LevelMax,
|
||||||
|
&z.Faction, &z.Atmosphere, &z.Postgame, &z.FirstClearBy, &z.FirstClearToken,
|
||||||
|
&z.FirstClearAt, &z.Clears, &z.Clearers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, z)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRealmOccupants() (map[string][]RealmOccupant, error) {
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT zone_id, token, name, level, day
|
||||||
|
FROM adventure_realm_occupant ORDER BY pos ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := map[string][]RealmOccupant{}
|
||||||
|
for rows.Next() {
|
||||||
|
var zoneID string
|
||||||
|
var o RealmOccupant
|
||||||
|
if err := rows.Scan(&zoneID, &o.Token, &o.Name, &o.Level, &o.Day); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[zoneID] = append(out[zoneID], o)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRealmFirsts() ([]RealmFirst, error) {
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT kind, target, display, tier, holder, token, at_unix
|
||||||
|
FROM adventure_realm_first ORDER BY pos ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RealmFirst
|
||||||
|
for rows.Next() {
|
||||||
|
var f RealmFirst
|
||||||
|
if err := rows.Scan(&f.Kind, &f.Target, &f.Display, &f.Tier, &f.Holder, &f.Token, &f.AtUnix); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRealmStandings() ([]RealmStanding, error) {
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT token, name, level, class_race, deepest_tier, clears, zones, firsts,
|
||||||
|
siege_damage, siege_fights
|
||||||
|
FROM adventure_realm_standing ORDER BY pos ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RealmStanding
|
||||||
|
for rows.Next() {
|
||||||
|
var s RealmStanding
|
||||||
|
if err := rows.Scan(&s.Token, &s.Name, &s.Level, &s.ClassRace, &s.DeepestTier,
|
||||||
|
&s.Clears, &s.Zones, &s.Firsts, &s.SiegeDamage, &s.SiegeFights); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeathsBySubject counts the deaths Pete has reported for each named adventurer.
|
||||||
|
//
|
||||||
|
// This is the one standings number that does NOT come off the gogobee push, and
|
||||||
|
// the reason is that the game has nowhere to read it from: a character carries
|
||||||
|
// its most recent death (source, place, date) and no running total, so there is
|
||||||
|
// no lifetime count on the game box to send. Pete does have one — his own back
|
||||||
|
// catalogue of death dispatches, seeded at news launch by the backfill and
|
||||||
|
// complete since — so he counts them himself, which is a thing a newspaper is
|
||||||
|
// entitled to do about its own reporting.
|
||||||
|
//
|
||||||
|
// Keyed on the character name because that is the only join the fact table
|
||||||
|
// offers: a dispatch carries a name, never a token. Names are unique per realm in
|
||||||
|
// practice; a collision would merge two adventurers' death counts, which is why
|
||||||
|
// this is the only column derived this way and not, say, clears.
|
||||||
|
func DeathsBySubject() (map[string]int, error) {
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT subject, COUNT(*) FROM adventure_events
|
||||||
|
WHERE event_type = 'death' AND subject IS NOT NULL AND subject <> ''
|
||||||
|
GROUP BY subject`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&name, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[name] = n
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeteDuelRecord is Pete's own won/lost tally, from the dispatches he filed about
|
||||||
|
// himself. He is a companion who can be hired onto an expedition and he has a
|
||||||
|
// record; keeping score on himself is in voice, and it is the one line on the
|
||||||
|
// board that is not about a player.
|
||||||
|
//
|
||||||
|
// Both types have had templates in the renderer since before anything emitted
|
||||||
|
// them, so this reads zero until gogobee starts filing them — and zero-zero is
|
||||||
|
// rendered as "no bouts yet" rather than as a 0% win rate.
|
||||||
|
func PeteDuelRecord() (wins, losses int, err error) {
|
||||||
|
err = Get().QueryRow(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_win' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_loss' THEN 1 ELSE 0 END), 0)
|
||||||
|
FROM adventure_events
|
||||||
|
WHERE event_type IN ('pete_duel_win', 'pete_duel_loss')`).Scan(&wins, &losses)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
return wins, losses, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The expedition liveblog, as gogobee beats it out room by room.
|
||||||
|
//
|
||||||
|
// Everything else the game pushes is a snapshot: the board, the war room, a
|
||||||
|
// player's own sheet. Those get replaced whole, because they describe what is
|
||||||
|
// currently true. Beats are the opposite kind of thing — each one is a moment
|
||||||
|
// that happened, and the only correction a later push can make to a moment is to
|
||||||
|
// add another one after it. So this is append-only, keyed on (run_id, seq), and
|
||||||
|
// a re-sent batch collapses on the primary key instead of duplicating a story.
|
||||||
|
//
|
||||||
|
// The run header is *derived*, not pushed. gogobee sends beats and nothing else;
|
||||||
|
// AppendRunBeats folds the identifying ones into adventure_run as they arrive.
|
||||||
|
// The upside is that a run missing its `start` beat still has a log — anonymous,
|
||||||
|
// but readable — rather than being discarded for want of a name to hang it on.
|
||||||
|
|
||||||
|
// RunBeat is one moment inside a run, exactly as gogobee filed it. Nouns and
|
||||||
|
// numbers only: Pete writes the sentence, the same split every dispatch fact
|
||||||
|
// already respects.
|
||||||
|
type RunBeat struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
|
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Room int `json:"room,omitempty"`
|
||||||
|
TotalRooms int `json:"total_rooms,omitempty"`
|
||||||
|
RoomKind string `json:"room_kind,omitempty"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Amount int `json:"amount,omitempty"`
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
|
HP int `json:"hp,omitempty"`
|
||||||
|
HPMax int `json:"hp_max,omitempty"`
|
||||||
|
Crits int `json:"crits,omitempty"`
|
||||||
|
Fumbles int `json:"fumbles,omitempty"`
|
||||||
|
|
||||||
|
// Prose is the single exception to "nouns and numbers only", and it is
|
||||||
|
// deliberately confined to one beat kind ("summary"). gogobee's LLM reads the
|
||||||
|
// finished run back and says what it was about; Pete guards that text at
|
||||||
|
// ingest exactly as it guards a dispatch lede, then folds it onto the run
|
||||||
|
// header. It is never rendered as a log line — see renderRunBeat.
|
||||||
|
Prose string `json:"prose,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run is the header: who walked, where, and how it ended (if it has).
|
||||||
|
type Run struct {
|
||||||
|
RunID string
|
||||||
|
Token string
|
||||||
|
Name string
|
||||||
|
Level int
|
||||||
|
Zone string
|
||||||
|
TotalRooms int
|
||||||
|
StartedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
EndedAt int64 // 0 = still walking
|
||||||
|
Outcome string
|
||||||
|
Summary string // LLM run summary, empty until the summary beat lands (or forever)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live reports whether this run is still in progress.
|
||||||
|
func (r Run) Live() bool { return r.EndedAt == 0 }
|
||||||
|
|
||||||
|
// AppendRunBeats stores a batch and folds each beat into its run header, in one
|
||||||
|
// transaction so a reader never sees a header that has moved ahead of its beats.
|
||||||
|
//
|
||||||
|
// INSERT OR IGNORE, not upsert: a beat is immutable. If gogobee re-sends
|
||||||
|
// (run_id, seq) the stored copy wins, which makes a duplicated batch free and
|
||||||
|
// makes a *changed* beat impossible — the second is a bug upstream, and quietly
|
||||||
|
// rewriting history to match it would hide that.
|
||||||
|
func AppendRunBeats(beats []RunBeat) error {
|
||||||
|
if len(beats) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
bstmt, err := tx.Prepare(`
|
||||||
|
INSERT OR IGNORE INTO adventure_run_beat
|
||||||
|
(run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||||
|
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer bstmt.Close()
|
||||||
|
|
||||||
|
// The header is created by whichever beat arrives first and enriched by any
|
||||||
|
// later one that knows more. COALESCE(NULLIF(...)) is the whole trick: a beat
|
||||||
|
// that doesn't carry a field leaves the stored value alone, so the `start`
|
||||||
|
// beat's name and zone survive the forty beats after it that have neither.
|
||||||
|
hstmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO adventure_run
|
||||||
|
(run_id, token, name, level, zone, total_rooms, started_at, updated_at, ended_at, outcome, summary)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(run_id) DO UPDATE SET
|
||||||
|
token = COALESCE(NULLIF(excluded.token, ''), adventure_run.token),
|
||||||
|
name = COALESCE(NULLIF(excluded.name, ''), adventure_run.name),
|
||||||
|
level = COALESCE(NULLIF(excluded.level, 0), adventure_run.level),
|
||||||
|
zone = COALESCE(NULLIF(excluded.zone, ''), adventure_run.zone),
|
||||||
|
total_rooms = COALESCE(NULLIF(excluded.total_rooms, 0), adventure_run.total_rooms),
|
||||||
|
started_at = COALESCE(NULLIF(adventure_run.started_at, 0), excluded.started_at),
|
||||||
|
updated_at = MAX(adventure_run.updated_at, excluded.updated_at),
|
||||||
|
ended_at = COALESCE(NULLIF(adventure_run.ended_at, 0), excluded.ended_at),
|
||||||
|
outcome = COALESCE(NULLIF(adventure_run.outcome, ''), excluded.outcome),
|
||||||
|
summary = COALESCE(NULLIF(adventure_run.summary, ''), excluded.summary)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer hstmt.Close()
|
||||||
|
|
||||||
|
for _, b := range beats {
|
||||||
|
if b.RunID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := bstmt.Exec(b.RunID, b.Seq, b.Kind, b.OccurredAt, b.Room, b.TotalRooms,
|
||||||
|
b.RoomKind, b.Target, b.Outcome, b.Amount, b.Count, b.HP, b.HPMax,
|
||||||
|
b.Crits, b.Fumbles, b.Region, b.Prose); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run ends once. First close wins here for the same reason it does on
|
||||||
|
// the game side: the specific outcome ("died") is filed before the generic
|
||||||
|
// one ("abandoned") that follows it down the same drain.
|
||||||
|
var endedAt int64
|
||||||
|
var outcome string
|
||||||
|
if b.Kind == "end" {
|
||||||
|
endedAt = b.OccurredAt
|
||||||
|
outcome = b.Outcome
|
||||||
|
}
|
||||||
|
started := int64(0)
|
||||||
|
if b.Kind == "start" {
|
||||||
|
started = b.OccurredAt
|
||||||
|
}
|
||||||
|
// Only the summary beat may set the summary. Any other kind carrying prose
|
||||||
|
// is upstream noise, and letting it through would put unguarded text on the
|
||||||
|
// header — the guard at ingest only inspects the kind it knows about.
|
||||||
|
summary := ""
|
||||||
|
if b.Kind == "summary" {
|
||||||
|
summary = b.Prose
|
||||||
|
}
|
||||||
|
if _, err := hstmt.Exec(b.RunID, b.Token, b.Name, b.Level, b.Zone,
|
||||||
|
b.TotalRooms, started, b.OccurredAt, endedAt, outcome, summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestRunForToken returns the run worth showing on one adventurer's page.
|
||||||
|
//
|
||||||
|
// A LIVE run always wins, and only then does recency decide. That ordering is
|
||||||
|
// load-bearing at exactly one moment and it is a moment that happens on every
|
||||||
|
// multi-region expedition: crossing a border closes one run and opens the next
|
||||||
|
// in the same breath, so the outgoing run's `end` beat and the incoming run's
|
||||||
|
// `start` beat carry the same second. Ordering on the clock alone would leave
|
||||||
|
// the page showing the log of a region the party has already left, with a
|
||||||
|
// "cleared" chip on it, while they walk on in the next one.
|
||||||
|
//
|
||||||
|
// Recency is updated_at rather than started_at for the same reason: the run
|
||||||
|
// still moving is the one still being written to.
|
||||||
|
func LatestRunForToken(token string) (Run, bool, error) {
|
||||||
|
if token == "" {
|
||||||
|
return Run{}, false, nil
|
||||||
|
}
|
||||||
|
var r Run
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT run_id, token, name, level, zone, total_rooms,
|
||||||
|
started_at, updated_at, ended_at, outcome, summary
|
||||||
|
FROM adventure_run
|
||||||
|
WHERE token = ?
|
||||||
|
ORDER BY (ended_at = 0) DESC, updated_at DESC, started_at DESC
|
||||||
|
LIMIT 1`, token).Scan(
|
||||||
|
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
|
||||||
|
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Run{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Run{}, false, err
|
||||||
|
}
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunByID returns one run header.
|
||||||
|
func RunByID(runID string) (Run, bool, error) {
|
||||||
|
var r Run
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT run_id, token, name, level, zone, total_rooms,
|
||||||
|
started_at, updated_at, ended_at, outcome, summary
|
||||||
|
FROM adventure_run WHERE run_id = ?`, runID).Scan(
|
||||||
|
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
|
||||||
|
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Run{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Run{}, false, err
|
||||||
|
}
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunBeats returns a run's beats in the order they happened.
|
||||||
|
//
|
||||||
|
// limit caps from the END, not the start: a log is read for what just happened,
|
||||||
|
// and a run deep into its third region would otherwise show its first forty
|
||||||
|
// beats forever. The returned slice is still oldest-first.
|
||||||
|
func RunBeats(runID string, limit int) ([]RunBeat, error) {
|
||||||
|
if runID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
q := `SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||||
|
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
|
||||||
|
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq ASC`
|
||||||
|
args := []any{runID}
|
||||||
|
if limit > 0 {
|
||||||
|
// Innermost query takes the tail, the wrapper puts it back in order.
|
||||||
|
q = `SELECT * FROM (
|
||||||
|
SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||||
|
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
|
||||||
|
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq DESC LIMIT ?
|
||||||
|
) ORDER BY seq ASC`
|
||||||
|
args = append(args, limit)
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RunBeat
|
||||||
|
for rows.Next() {
|
||||||
|
var b RunBeat
|
||||||
|
if err := rows.Scan(&b.RunID, &b.Seq, &b.Kind, &b.OccurredAt, &b.Room, &b.TotalRooms,
|
||||||
|
&b.RoomKind, &b.Target, &b.Outcome, &b.Amount, &b.Count, &b.HP, &b.HPMax,
|
||||||
|
&b.Crits, &b.Fumbles, &b.Region, &b.Prose); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneRuns drops runs (and their beats) that ended before cutoff. Live runs are
|
||||||
|
// never touched however old they look — a run that has been walking for a week
|
||||||
|
// is a stuck expedition, and deleting its log is exactly the wrong response to
|
||||||
|
// the one case where somebody wants to read it.
|
||||||
|
func PruneRuns(cutoff int64) error {
|
||||||
|
if _, err := Get().Exec(`
|
||||||
|
DELETE FROM adventure_run_beat
|
||||||
|
WHERE run_id IN (SELECT run_id FROM adventure_run WHERE ended_at > 0 AND ended_at < ?)`,
|
||||||
|
cutoff); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := Get().Exec(`DELETE FROM adventure_run WHERE ended_at > 0 AND ended_at < ?`, cutoff)
|
||||||
|
return err
|
||||||
|
}
|
||||||
+287
-6
@@ -88,12 +88,220 @@ CREATE TABLE IF NOT EXISTS adventure_events (
|
|||||||
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
|
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
|
||||||
|
|
||||||
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
|
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
|
||||||
|
-- The expedition this dispatch is the ending of, when it is the ending of one
|
||||||
|
-- (a clear, a retreat, a death). It is the join from "how it went" to "what
|
||||||
|
-- happened", and it is the reason Pete keeps a finished run's beats for two
|
||||||
|
-- weeks while only *showing* them for six hours: the dispatch outlives the run
|
||||||
|
-- it announced, and a story that can't reach its own log is the whole point
|
||||||
|
-- of the log going missing.
|
||||||
|
run_id TEXT,
|
||||||
occurred_at INTEGER NOT NULL
|
occurred_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
|
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
|
||||||
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);
|
||||||
|
|
||||||
|
-- The Siege. Three tables, all fed by one gogobee push and all replaced whole,
|
||||||
|
-- because the Siege is state, not history — the same contract as the roster.
|
||||||
|
--
|
||||||
|
-- The split is by lifetime, not by convenience. adventure_siege is the single
|
||||||
|
-- live boss (a CHECK-pinned one-row table, like adventure_roster_meta, so "no
|
||||||
|
-- Siege camped" is a row saying active=0 rather than an ambiguous empty table).
|
||||||
|
-- adventure_siege_defenders is the muster for that one boss and dies with it.
|
||||||
|
-- adventure_siege_history outlives both, and is the reason the current Siege
|
||||||
|
-- feels like it counts: a health bar with nothing behind it is a progress bar.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_siege (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
boss_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
boss_name TEXT NOT NULL DEFAULT '',
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hp_current INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hp_max INTEGER NOT NULL DEFAULT 0,
|
||||||
|
starts_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ends_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bouts_today INTEGER NOT NULL DEFAULT 0,
|
||||||
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- pos is the push order, which is gogobee's ranking (damage desc). Kept as the
|
||||||
|
-- key rather than the token because an opted-out defender carries NO token — the
|
||||||
|
-- board shows their rank and their damage as "an adventurer" and offers no link,
|
||||||
|
-- so several rows can legitimately be tokenless and they must not collide.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_siege_defenders (
|
||||||
|
pos INTEGER PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fights INTEGER NOT NULL DEFAULT 0,
|
||||||
|
damage INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fought_today INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Open question, never confirmed with gogobee: whether boss_id identifies the
|
||||||
|
-- siege instance or the boss TYPE. SiegeBarForBoss matches history on boss_name
|
||||||
|
-- plus the nearest ended_at and its comment says "the same boss comes back month
|
||||||
|
-- after month", which reads like a type — in which case this key collides on the
|
||||||
|
-- second visit. ReplaceSiege inserts OR REPLACE so a collision costs one history
|
||||||
|
-- row instead of the whole push; settle the meaning before relying on the key.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_siege_history (
|
||||||
|
boss_id INTEGER PRIMARY KEY,
|
||||||
|
boss_name TEXT NOT NULL,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
outcome TEXT NOT NULL, -- "defeated" | "survived"
|
||||||
|
hp_remaining INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hp_max INTEGER NOT NULL DEFAULT 0,
|
||||||
|
defenders INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mvp TEXT NOT NULL DEFAULT '',
|
||||||
|
mvp_fights INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ended_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The expedition liveblog. Two tables, and — unlike everything else gogobee
|
||||||
|
-- pushes — these are append-only history, not a replaceable snapshot. A beat is
|
||||||
|
-- something that HAPPENED; there is no later truth that corrects it, only more
|
||||||
|
-- of it.
|
||||||
|
--
|
||||||
|
-- adventure_run is the header, assembled from the run's beats rather than pushed
|
||||||
|
-- as its own object: the "start" beat opens it and the "end" beat closes it.
|
||||||
|
-- That means a run whose start beat never arrived still gets a row (created by
|
||||||
|
-- whatever beat did arrive) and is simply unattributed — the log survives
|
||||||
|
-- nameless instead of being dropped for want of a name.
|
||||||
|
--
|
||||||
|
-- summary is the one piece of PROSE anywhere in the liveblog. Every log line is
|
||||||
|
-- assembled by Pete out of a beat's own nouns and numbers; this is gogobee's LLM
|
||||||
|
-- reading the finished run back and saying what it was *about*, which is a
|
||||||
|
-- judgement no template can make. It arrives late — its own beat, a tick or two
|
||||||
|
-- after the run ends — and it is optional forever: with the model off, the report
|
||||||
|
-- is the log plus the numbers, which is still the report.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_run (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL DEFAULT '', -- public board token; '' = unattributed
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
zone TEXT NOT NULL DEFAULT '',
|
||||||
|
total_rooms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ended_at INTEGER NOT NULL DEFAULT 0, -- 0 = still walking
|
||||||
|
outcome TEXT NOT NULL DEFAULT '', -- cleared|died|retreated|abandoned
|
||||||
|
summary TEXT NOT NULL DEFAULT '' -- LLM run summary, post prose-guard
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_run_token ON adventure_run(token, started_at DESC);
|
||||||
|
|
||||||
|
-- (run_id, seq) is the identity, so a re-sent batch collapses on the primary key
|
||||||
|
-- and needs no content comparison. seq is gogobee's monotonic counter, which is
|
||||||
|
-- also the render order — beats can arrive out of order across two batches and
|
||||||
|
-- still read correctly.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_run_beat (
|
||||||
|
run_id TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
occurred_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
room INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_rooms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
room_kind TEXT NOT NULL DEFAULT '',
|
||||||
|
target TEXT NOT NULL DEFAULT '',
|
||||||
|
outcome TEXT NOT NULL DEFAULT '',
|
||||||
|
amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
qty INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hp_max INTEGER NOT NULL DEFAULT 0,
|
||||||
|
crits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fumbles INTEGER NOT NULL DEFAULT 0,
|
||||||
|
region TEXT NOT NULL DEFAULT '',
|
||||||
|
-- prose is carried by exactly one beat kind ("summary") and is never rendered
|
||||||
|
-- as a log line. It lives on the beat rather than on its own endpoint so the
|
||||||
|
-- summary inherits the whole channel: idempotent on (run_id, seq), retried
|
||||||
|
-- until delivered, and impossible to attach to a run that doesn't exist.
|
||||||
|
prose TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (run_id, seq)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The realm: the world map, the hall of firsts, and the board. Four tables from
|
||||||
|
-- one gogobee push, all replaced whole — the same contract as the roster and the
|
||||||
|
-- Siege, and for the same reason. Every row here is a *derived* answer (how many
|
||||||
|
-- clears, who was first, who is inside right now) recomputed on the game box from
|
||||||
|
-- its own run history. Pete keeping a stale one and merging into it would let a
|
||||||
|
-- correction upstream leave a wrong number here permanently.
|
||||||
|
--
|
||||||
|
-- Unlike the Siege there is no live/history lifetime split, because none of this
|
||||||
|
-- has a lifetime: a zone does not end. What varies is only how often it changes,
|
||||||
|
-- and that is handled on the gogobee side by pushing every ten minutes instead of
|
||||||
|
-- every two.
|
||||||
|
--
|
||||||
|
-- pos is the push order throughout, kept as the key for the same reason the siege
|
||||||
|
-- muster does: an opted-out player carries NO token, so several rows can
|
||||||
|
-- legitimately be tokenless and must not collide on one.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_realm_zone (
|
||||||
|
pos INTEGER PRIMARY KEY, -- gogobee's design-doc zone order
|
||||||
|
zone_id TEXT NOT NULL,
|
||||||
|
display TEXT NOT NULL,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
level_min INTEGER NOT NULL DEFAULT 0,
|
||||||
|
level_max INTEGER NOT NULL DEFAULT 0,
|
||||||
|
faction TEXT NOT NULL DEFAULT '',
|
||||||
|
atmosphere TEXT NOT NULL DEFAULT '',
|
||||||
|
postgame INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_by TEXT NOT NULL DEFAULT '',
|
||||||
|
first_token TEXT NOT NULL DEFAULT '',
|
||||||
|
first_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
clears INTEGER NOT NULL DEFAULT 0,
|
||||||
|
clearers INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Who is standing in a zone right now. Its own table rather than a JSON blob on
|
||||||
|
-- the zone row so the map can be drawn with one join and "there are people in
|
||||||
|
-- there" is a count, not a parse.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_realm_occupant (
|
||||||
|
pos INTEGER PRIMARY KEY,
|
||||||
|
zone_id TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
day INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_realm_occ_zone ON adventure_realm_occupant(zone_id);
|
||||||
|
|
||||||
|
-- The hall of firsts: every thing that has happened in the realm exactly once.
|
||||||
|
-- holder is empty when the game can no longer say who did it (a treasure found
|
||||||
|
-- and later discarded leaves no owner anywhere) — an unattributed first is still
|
||||||
|
-- a first and is rendered as one.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_realm_first (
|
||||||
|
pos INTEGER PRIMARY KEY, -- gogobee's order: oldest first
|
||||||
|
kind TEXT NOT NULL, -- "zone" | "treasure" | whatever comes next
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
display TEXT NOT NULL,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
at_unix INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The board. pos IS the rank, and the ranking is gogobee's — the ordering is a
|
||||||
|
-- statement about what the game values, and the game gets to make it.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_realm_standing (
|
||||||
|
pos INTEGER PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
class_race TEXT NOT NULL DEFAULT '',
|
||||||
|
deepest_tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
clears INTEGER NOT NULL DEFAULT 0,
|
||||||
|
zones INTEGER NOT NULL DEFAULT 0,
|
||||||
|
firsts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
siege_damage INTEGER NOT NULL DEFAULT 0,
|
||||||
|
siege_fights INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row, like adventure_siege: when the realm last arrived. Its own table
|
||||||
|
-- because "gogobee has never pushed a realm" and "gogobee pushed a realm that is
|
||||||
|
-- empty" are different states, and the page says different things about them.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_realm_meta (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
||||||
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
||||||
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||||
@@ -191,6 +399,50 @@ CREATE TABLE IF NOT EXISTS equip_orders (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_equip_orders_pending ON equip_orders(status, created_at);
|
CREATE INDEX IF NOT EXISTS idx_equip_orders_pending ON equip_orders(status, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC);
|
||||||
|
|
||||||
|
-- An action an owner asked for from the web — pull out of a run, take today's
|
||||||
|
-- swing at the Siege — on its way to gogobee. Same reverse-pipe shape as
|
||||||
|
-- equip_orders and the same guid-as-idempotency-key contract, but a SEPARATE
|
||||||
|
-- table on purpose: every column of equip_orders is equip vocabulary (item, slot,
|
||||||
|
-- tier), and these verbs act on the character rather than on something it is
|
||||||
|
-- carrying. Sharing the table would have meant rows where most columns are
|
||||||
|
-- meaningless and an action set nobody could read.
|
||||||
|
--
|
||||||
|
-- The status ladder:
|
||||||
|
--
|
||||||
|
-- pending -> applied (it happened; detail says what)
|
||||||
|
-- -> rejected_not_running (extract/abandon/leave: no expedition)
|
||||||
|
-- -> rejected_not_leader (extract/abandon: a member can't call it)
|
||||||
|
-- -> rejected_is_leader (leave: the leader's row IS the expedition)
|
||||||
|
-- -> rejected_no_siege (siege_join: nothing camped outside town)
|
||||||
|
-- -> rejected_already_fought (siege_join: today's bout is already spent)
|
||||||
|
-- -> rejected_busy (already out, seated, or has a sitter)
|
||||||
|
-- -> rejected_insufficient_funds (could not cover the cost)
|
||||||
|
-- -> rejected_zone_locked (expedition_start: not open at this level)
|
||||||
|
-- -> rejected_nothing_to_resume (nothing extracted, or the window closed)
|
||||||
|
-- -> rejected_nothing_to_cancel (babysit_cancel: no sitter is engaged)
|
||||||
|
-- -> rejected_unavailable (no character, dead, or an unsold argument)
|
||||||
|
--
|
||||||
|
-- Like the equip queue, the underlying game action is NOT idempotent — an extract
|
||||||
|
-- ends an expedition and a bout spends a day — so gogobee short-circuits on the
|
||||||
|
-- guid before it mutates anything. token is the roster token the order was placed
|
||||||
|
-- from; gogobee ignores it (the localpart names the character) but it is what
|
||||||
|
-- Pete proved ownership against, and it keeps the row self-describing.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_orders (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
owner_sub TEXT NOT NULL,
|
||||||
|
owner_localpart TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
character_name TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL, -- see the AdvAction* set
|
||||||
|
status TEXT NOT NULL, -- see the ladder above
|
||||||
|
detail TEXT, -- gogobee's human note on the verdict
|
||||||
|
params TEXT NOT NULL DEFAULT '', -- the verb's arguments as JSON; '' for the verbs that take none
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adventure_orders_pending ON adventure_orders(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adventure_orders_owner ON adventure_orders(owner_sub, created_at DESC);
|
||||||
|
|
||||||
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||||
@@ -209,6 +461,22 @@ CREATE TABLE IF NOT EXISTS player_self_detail (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||||
|
|
||||||
|
-- Per-user visit clock for the adventure section's "while you were away" panel,
|
||||||
|
-- keyed by OIDC subject like every other per-user table.
|
||||||
|
--
|
||||||
|
-- TWO stamps, and the second one is the whole trick. window_from is where the
|
||||||
|
-- panel reads from; last_seen_at is a heartbeat written on every page load. One
|
||||||
|
-- column would make the panel a one-shot: it would show what happened, move the
|
||||||
|
-- stamp to now, and a refresh five seconds later would render an empty box over
|
||||||
|
-- the same news. So window_from advances only when a genuinely new visit begins
|
||||||
|
-- (see AdvVisitWindow), which keeps the panel stable for as long as somebody is
|
||||||
|
-- actually reading it.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_visit (
|
||||||
|
user_sub TEXT PRIMARY KEY,
|
||||||
|
window_from INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_log (
|
CREATE TABLE IF NOT EXISTS post_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
guid TEXT NOT NULL,
|
guid TEXT NOT NULL,
|
||||||
@@ -285,13 +553,26 @@ CREATE TABLE IF NOT EXISTS source_health (
|
|||||||
-- server needs them to encrypt each push. last_notified_at is the per-endpoint
|
-- server needs them to encrypt each push. last_notified_at is the per-endpoint
|
||||||
-- digest watermark: the sender only counts stories seen after it. A user can
|
-- digest watermark: the sender only counts stories seen after it. A user can
|
||||||
-- have several endpoints (phone, desktop) — each is notified independently.
|
-- have several endpoints (phone, desktop) — each is notified independently.
|
||||||
|
--
|
||||||
|
-- user_localpart is the same identity one level down: user_sub is the OIDC
|
||||||
|
-- subject, but every adventure ownership join in this schema is keyed on the
|
||||||
|
-- Matrix localpart (see player_self_detail), and nothing else persists that
|
||||||
|
-- mapping outside a live session. It is captured at subscribe time so the
|
||||||
|
-- adventure alert sender — which runs on a ticker with no request to read a
|
||||||
|
-- session from — can answer "whose adventurer is this" at all.
|
||||||
|
--
|
||||||
|
-- last_adv_notified_at is the adventure alerts' own watermark, kept apart from
|
||||||
|
-- the digest's on purpose: the two senders run on different clocks and one
|
||||||
|
-- column would let each silently consume the other's backlog.
|
||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
endpoint TEXT PRIMARY KEY,
|
endpoint TEXT PRIMARY KEY,
|
||||||
user_sub TEXT NOT NULL,
|
user_sub TEXT NOT NULL,
|
||||||
p256dh TEXT NOT NULL,
|
user_localpart TEXT NOT NULL DEFAULT '',
|
||||||
auth TEXT NOT NULL,
|
p256dh TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
auth TEXT NOT NULL,
|
||||||
last_notified_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL,
|
||||||
|
last_notified_at INTEGER NOT NULL,
|
||||||
|
last_adv_notified_at INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Privacy-preserving daily unique estimate. visitor is a salted hash of
|
-- Privacy-preserving daily unique estimate. visitor is a salted hash of
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Siege, as gogobee pushes it.
|
||||||
|
//
|
||||||
|
// Same shape of thing as the roster and stored the same way: a whole snapshot
|
||||||
|
// that replaces whatever we had. Nothing here is an event — the *events*
|
||||||
|
// (siege_start / siege_win / siege_loss) come down the dispatch queue like any
|
||||||
|
// other fact. This is the thing that is currently true, which is the only kind
|
||||||
|
// of thing a health bar can honestly draw.
|
||||||
|
|
||||||
|
// SiegeDefender is one adventurer's standing in the current muster.
|
||||||
|
//
|
||||||
|
// Token is the same public roster token the board uses, so the defender board
|
||||||
|
// can link a name to their page — and it is EMPTY for an opted-out player. That
|
||||||
|
// is the whole opt-out story here: their damage still counts and still holds its
|
||||||
|
// rank (the town's effort is the town's), but there is no name and no link. Name
|
||||||
|
// carries gogobee's anonymised label in that case.
|
||||||
|
type SiegeDefender struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
|
Fights int `json:"fights"`
|
||||||
|
Damage int `json:"damage"`
|
||||||
|
FoughtToday bool `json:"fought_today"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiegePast is one closed-out Siege: what came, whether the town held, and who
|
||||||
|
// turned up most. The history is what makes the live bar mean anything.
|
||||||
|
type SiegePast struct {
|
||||||
|
BossID int64 `json:"boss_id"`
|
||||||
|
BossName string `json:"boss_name"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Outcome string `json:"outcome"` // "defeated" | "survived"
|
||||||
|
HPRemaining int `json:"hp_remaining"`
|
||||||
|
HPMax int `json:"hp_max"`
|
||||||
|
Defenders int `json:"defenders"`
|
||||||
|
MVP string `json:"mvp,omitempty"`
|
||||||
|
MVPFights int `json:"mvp_fights,omitempty"`
|
||||||
|
EndedAt int64 `json:"ended_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Siege is the complete war-room state: the live boss (if any), its muster, and
|
||||||
|
// every Siege that came before.
|
||||||
|
type Siege struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
BossID int64 `json:"boss_id,omitempty"`
|
||||||
|
BossName string `json:"boss_name,omitempty"`
|
||||||
|
Tier int `json:"tier,omitempty"`
|
||||||
|
HPCurrent int `json:"hp_current"`
|
||||||
|
HPMax int `json:"hp_max"`
|
||||||
|
StartsAt int64 `json:"starts_at,omitempty"`
|
||||||
|
EndsAt int64 `json:"ends_at,omitempty"`
|
||||||
|
BoutsToday int `json:"bouts_today"`
|
||||||
|
Defenders []SiegeDefender `json:"defenders,omitempty"`
|
||||||
|
History []SiegePast `json:"history,omitempty"`
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceSiege swaps the whole war room for a new snapshot, in one transaction.
|
||||||
|
//
|
||||||
|
// Replace, never merge — for the same reason the roster does it. A defender who
|
||||||
|
// dropped out of the payload (opted out, deleted character) has to leave the
|
||||||
|
// board, and a Siege that ended has to stop showing a live bar. The transaction
|
||||||
|
// means a reader mid-swap sees the old Siege or the new one, never a boss with
|
||||||
|
// somebody else's muster under it.
|
||||||
|
//
|
||||||
|
// History is replaced too, not appended: gogobee is the authority on what has
|
||||||
|
// happened, and rebuilding from its list each tick means a corrected or purged
|
||||||
|
// row upstream can't leave a ghost siege on Pete forever.
|
||||||
|
func ReplaceSiege(s Siege, snapshotAt int64) error {
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM adventure_siege_defenders`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM adventure_siege_history`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO adventure_siege
|
||||||
|
(id, active, boss_id, boss_name, tier, hp_current, hp_max,
|
||||||
|
starts_at, ends_at, bouts_today, snapshot_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
active = excluded.active, boss_id = excluded.boss_id,
|
||||||
|
boss_name = excluded.boss_name, tier = excluded.tier,
|
||||||
|
hp_current = excluded.hp_current, hp_max = excluded.hp_max,
|
||||||
|
starts_at = excluded.starts_at, ends_at = excluded.ends_at,
|
||||||
|
bouts_today = excluded.bouts_today, snapshot_at = excluded.snapshot_at`,
|
||||||
|
s.Active, s.BossID, s.BossName, s.Tier, s.HPCurrent, s.HPMax,
|
||||||
|
s.StartsAt, s.EndsAt, s.BoutsToday, snapshotAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dstmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO adventure_siege_defenders
|
||||||
|
(pos, token, name, level, fights, damage, fought_today)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstmt.Close()
|
||||||
|
for i, d := range s.Defenders {
|
||||||
|
if _, err := dstmt.Exec(i, d.Token, d.Name, d.Level, d.Fights, d.Damage, d.FoughtToday); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OR REPLACE, because boss_id is the primary key and a duplicate in gogobee's
|
||||||
|
// list would otherwise fail this whole transaction — the live boss and the
|
||||||
|
// muster with it, freezing the war room on the previous snapshot indefinitely.
|
||||||
|
// The table is deleted and rebuilt from the pushed list every time, so a
|
||||||
|
// collision is a wire quirk rather than data loss, and keeping the last of a
|
||||||
|
// colliding pair is a far smaller failure than a war room that stops moving.
|
||||||
|
hstmt, err := tx.Prepare(`
|
||||||
|
INSERT OR REPLACE INTO adventure_siege_history
|
||||||
|
(boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer hstmt.Close()
|
||||||
|
for _, h := range s.History {
|
||||||
|
if _, err := hstmt.Exec(h.BossID, h.BossName, h.Tier, h.Outcome, h.HPRemaining,
|
||||||
|
h.HPMax, h.Defenders, h.MVP, h.MVPFights, h.EndedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiegeBarForBoss finds the HP bar to draw on a siege dispatch's card: current
|
||||||
|
// and max HP for the named boss around the time the dispatch was filed.
|
||||||
|
//
|
||||||
|
// A siege fact carries the boss and the defender count but not the HP, so the
|
||||||
|
// bar has to come from the war-room snapshot. Two places to look, in order:
|
||||||
|
//
|
||||||
|
// - the live row, when that boss is still camped (a siege_start card should
|
||||||
|
// show the bar as it stands right now, and it will keep sliding as the town
|
||||||
|
// chips away);
|
||||||
|
// - the history, for a Siege that has closed — matched on name and then on
|
||||||
|
// the row that ended nearest the dispatch, since the same boss comes back
|
||||||
|
// month after month and only the clock separates the two.
|
||||||
|
//
|
||||||
|
// ok is false when neither has it, which is a real and temporary state: the
|
||||||
|
// win/loss dispatch is filed the moment the Siege resolves, and the history that
|
||||||
|
// explains it doesn't reach Pete until the next 2-minute push. The card renders
|
||||||
|
// without a bar in the meantime rather than drawing a wrong one.
|
||||||
|
func SiegeBarForBoss(boss string, at int64) (current, max int, ok bool) {
|
||||||
|
if boss == "" {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
var active bool
|
||||||
|
var name string
|
||||||
|
var hpCur, hpMax int
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT active, boss_name, hp_current, hp_max FROM adventure_siege WHERE id = 1`).
|
||||||
|
Scan(&active, &name, &hpCur, &hpMax)
|
||||||
|
if err == nil && active && name == boss && hpMax > 0 {
|
||||||
|
return hpCur, hpMax, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER BY the distance from the dispatch, so a boss that has besieged the
|
||||||
|
// town three times resolves to the siege this dispatch is actually about.
|
||||||
|
err = Get().QueryRow(`
|
||||||
|
SELECT hp_remaining, hp_max FROM adventure_siege_history
|
||||||
|
WHERE boss_name = ? AND hp_max > 0
|
||||||
|
ORDER BY ABS(ended_at - ?) ASC LIMIT 1`, boss, at).Scan(&hpCur, &hpMax)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return hpCur, hpMax, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiegeIsCamped answers the one question the siege_join pre-check asks, without
|
||||||
|
// LoadSiege's defender rows and whole history behind it — a one-column read on a
|
||||||
|
// pool that is MaxOpenConns(1).
|
||||||
|
//
|
||||||
|
// known is false when gogobee has never pushed a war room at all, which is NOT
|
||||||
|
// the same as a pushed snapshot saying no Siege is camped. The caller has to keep
|
||||||
|
// the two apart: a fresh deploy that has not been pushed to yet must still queue
|
||||||
|
// the order rather than show a dead button.
|
||||||
|
func SiegeIsCamped() (active, known bool, err error) {
|
||||||
|
err = Get().QueryRow(`SELECT active FROM adventure_siege WHERE id = 1`).Scan(&active)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
return active, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSiege returns the war room as last pushed. ok is false when gogobee has
|
||||||
|
// never pushed one at all — distinct from a pushed snapshot that says no Siege
|
||||||
|
// is camped, which is a real answer the page can render.
|
||||||
|
func LoadSiege() (Siege, bool, error) {
|
||||||
|
var s Siege
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT active, boss_id, boss_name, tier, hp_current, hp_max,
|
||||||
|
starts_at, ends_at, bouts_today, snapshot_at
|
||||||
|
FROM adventure_siege WHERE id = 1`).Scan(
|
||||||
|
&s.Active, &s.BossID, &s.BossName, &s.Tier, &s.HPCurrent, &s.HPMax,
|
||||||
|
&s.StartsAt, &s.EndsAt, &s.BoutsToday, &s.SnapshotAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Siege{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Siege{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
drows, err := Get().Query(`
|
||||||
|
SELECT token, name, level, fights, damage, fought_today
|
||||||
|
FROM adventure_siege_defenders ORDER BY pos ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return s, true, err
|
||||||
|
}
|
||||||
|
defer drows.Close()
|
||||||
|
for drows.Next() {
|
||||||
|
var d SiegeDefender
|
||||||
|
if err := drows.Scan(&d.Token, &d.Name, &d.Level, &d.Fights, &d.Damage, &d.FoughtToday); err != nil {
|
||||||
|
return s, true, err
|
||||||
|
}
|
||||||
|
s.Defenders = append(s.Defenders, d)
|
||||||
|
}
|
||||||
|
if err := drows.Err(); err != nil {
|
||||||
|
return s, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hrows, err := Get().Query(`
|
||||||
|
SELECT boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at
|
||||||
|
FROM adventure_siege_history ORDER BY ended_at DESC, boss_id DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return s, true, err
|
||||||
|
}
|
||||||
|
defer hrows.Close()
|
||||||
|
for hrows.Next() {
|
||||||
|
var h SiegePast
|
||||||
|
if err := hrows.Scan(&h.BossID, &h.BossName, &h.Tier, &h.Outcome, &h.HPRemaining,
|
||||||
|
&h.HPMax, &h.Defenders, &h.MVP, &h.MVPFights, &h.EndedAt); err != nil {
|
||||||
|
return s, true, err
|
||||||
|
}
|
||||||
|
s.History = append(s.History, h)
|
||||||
|
}
|
||||||
|
return s, true, hrows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The visit clock behind the adventure section's "while you were away" panel.
|
||||||
|
//
|
||||||
|
// The panel answers "what happened to my adventurer since I last looked", which
|
||||||
|
// needs a per-user stamp — and the obvious one-column version of that is broken
|
||||||
|
// in a way that only shows up in a browser: show the news, move the stamp to now,
|
||||||
|
// and the reader's first refresh renders an empty box over the same events. So
|
||||||
|
// there are two stamps. See the adventure_visit schema comment.
|
||||||
|
|
||||||
|
// advVisitSessionGap is how long a gap in page loads counts as having gone away.
|
||||||
|
// Thirty minutes: long enough that a reader clicking through a dispatch and back
|
||||||
|
// keeps the same panel, short enough that "since last time" means something after
|
||||||
|
// a lunch break rather than only after a day.
|
||||||
|
const advVisitSessionGap = 30 * 60
|
||||||
|
|
||||||
|
// AdvVisitWindow stamps this visit and reports the instant the panel should read
|
||||||
|
// from — every dispatch after it is news to this user.
|
||||||
|
//
|
||||||
|
// firstVisit is true the first time a user is ever seen, and the caller must show
|
||||||
|
// nothing for it. The row is created stamped to now, so their history is not
|
||||||
|
// news: somebody signing in for the first time has not been "away", and greeting
|
||||||
|
// them with every death their character ever suffered would be a worse
|
||||||
|
// introduction than silence.
|
||||||
|
func AdvVisitWindow(userSub string, now int64) (from int64, firstVisit bool, err error) {
|
||||||
|
if userSub == "" {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
var windowFrom, lastSeen int64
|
||||||
|
err = Get().QueryRow(
|
||||||
|
`SELECT window_from, last_seen_at FROM adventure_visit WHERE user_sub = ?`,
|
||||||
|
userSub).Scan(&windowFrom, &lastSeen)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
_, ierr := Get().Exec(
|
||||||
|
`INSERT INTO adventure_visit (user_sub, window_from, last_seen_at) VALUES (?, ?, ?)`,
|
||||||
|
userSub, now, now)
|
||||||
|
return now, true, ierr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new visit starts when the heartbeat has gone quiet for longer than the
|
||||||
|
// session gap. Only then does the window move — and it moves to where the
|
||||||
|
// reader actually left off (lastSeen), never to now, or the events between
|
||||||
|
// their last page load and this one would fall down the crack between the two.
|
||||||
|
if now-lastSeen > advVisitSessionGap {
|
||||||
|
windowFrom = lastSeen
|
||||||
|
}
|
||||||
|
_, err = Get().Exec(
|
||||||
|
`UPDATE adventure_visit SET window_from = ?, last_seen_at = ? WHERE user_sub = ?`,
|
||||||
|
windowFrom, now, userSub)
|
||||||
|
return windowFrom, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsBySubjectSince is EventsBySubject narrowed to what is new. The limit is
|
||||||
|
// applied to the *window*, not to the subject's whole history, so a player back
|
||||||
|
// from a long absence gets the most recent N of what they missed rather than N
|
||||||
|
// rows scanned from a history that might all predate the window.
|
||||||
|
func EventsBySubjectSince(name string, sinceUnix int64, limit int) ([]AdvEvent, error) {
|
||||||
|
if name == "" || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||||
|
level, tally, outcome, milestone, stakes, run_id, occurred_at
|
||||||
|
FROM adventure_events
|
||||||
|
WHERE (subject = ? OR opponent = ?) AND occurred_at > ?
|
||||||
|
ORDER BY occurred_at DESC
|
||||||
|
LIMIT ?`, name, name, sinceUnix, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []AdvEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e AdvEvent
|
||||||
|
var tier, subject, opponent, boss, zone, region sql.NullString
|
||||||
|
var outcome, milestone, stakes, runID sql.NullString
|
||||||
|
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
||||||
|
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
||||||
|
&stakes, &runID, &e.OccurredAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
|
||||||
|
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
|
||||||
|
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
|
||||||
|
e.RunID = runID.String
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAwayWindowOnlyMovesOnANewVisit pins the session gap directly. Within the
|
||||||
|
// gap the window is held; past it, it advances to where the reader actually left
|
||||||
|
// off — never to now, or everything between their last load and this one would
|
||||||
|
// fall down the crack.
|
||||||
|
func TestAwayWindowOnlyMovesOnANewVisit(t *testing.T) {
|
||||||
|
if err := Init(t.TempDir() + "/visit.db"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { Close() })
|
||||||
|
|
||||||
|
t0 := int64(1_000_000)
|
||||||
|
if _, first, err := AdvVisitWindow("sub-1", t0); err != nil || !first {
|
||||||
|
t.Fatalf("first call: first=%v err=%v", first, err)
|
||||||
|
}
|
||||||
|
// A minute later: same visit, window pinned to where it started.
|
||||||
|
from, _, err := AdvVisitWindow("sub-1", t0+60)
|
||||||
|
if err != nil || from != t0 {
|
||||||
|
t.Fatalf("window = %d (err %v), want it held at %d inside the session", from, err, t0)
|
||||||
|
}
|
||||||
|
// Well past the gap: a new visit, reading from the last heartbeat (t0+60),
|
||||||
|
// not from now.
|
||||||
|
from, _, err = AdvVisitWindow("sub-1", t0+60+advVisitSessionGap+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if from != t0+60 {
|
||||||
|
t.Errorf("window = %d, want the previous heartbeat %d — anything else drops or replays events",
|
||||||
|
from, t0+60)
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
-56
@@ -4,12 +4,12 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -38,6 +38,12 @@ type AdvFact struct {
|
|||||||
Milestone string `json:"milestone"`
|
Milestone string `json:"milestone"`
|
||||||
OccurredAt int64 `json:"occurred_at"`
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
NoPush bool `json:"no_push"`
|
NoPush bool `json:"no_push"`
|
||||||
|
// RunID names the expedition this dispatch is the ending of, on the three
|
||||||
|
// event types that are one (a clear, a retreat, a death). It is what lets the
|
||||||
|
// permalink offer the run's own report — the log, the numbers, the moment it
|
||||||
|
// turned — instead of leaving a paragraph about an outcome with no way back to
|
||||||
|
// what produced it. Empty on every other fact.
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
|
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
|
||||||
// and past the prose-guard they replace the template render; otherwise Pete
|
// and past the prose-guard they replace the template render; otherwise Pete
|
||||||
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
|
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
|
||||||
@@ -70,6 +76,13 @@ const advSource = "Pete"
|
|||||||
// Matrix; the row exists only so the digest skips it.
|
// Matrix; the row exists only so the digest skips it.
|
||||||
const advBackfillEvent = "adv-backfill"
|
const advBackfillEvent = "adv-backfill"
|
||||||
|
|
||||||
|
// advRoomSilentEvent is the synthetic post_log event id used to retire a
|
||||||
|
// dispatch whose event type gogobee announces in the games room itself. Like
|
||||||
|
// advBackfillEvent it never went to Matrix; the row exists so the digest skips
|
||||||
|
// it, and the distinct id keeps "TwinBee said it" separable from "backfilled"
|
||||||
|
// when reading post_log later.
|
||||||
|
const advRoomSilentEvent = "adv-room-silent"
|
||||||
|
|
||||||
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
||||||
@@ -101,12 +114,27 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the template first. It validates the event type, and it is the
|
// Render the template first. It is the fallback for every fact whose LLM
|
||||||
// fallback for every fact whose LLM prose is absent or fails the guard.
|
// prose is absent or fails the guard.
|
||||||
headline, lede, ok := renderAdventure(f)
|
//
|
||||||
if !ok {
|
// An event type Pete has no template for is NOT an error. It used to be a 400,
|
||||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
// and that was the wrong call: gogobee retries a 400 to its cap and then parks
|
||||||
return
|
// the dispatch forever, so the only thing the rejection accomplished was
|
||||||
|
// deleting a real game event that Pete simply hadn't learned to phrase yet.
|
||||||
|
// `companion_hire` was dropped that way from the day it shipped, and the
|
||||||
|
// mitigation on the books ("always deploy Pete first") is a rule a human has
|
||||||
|
// to remember rather than a property of the system.
|
||||||
|
//
|
||||||
|
// So: an unknown type publishes on the neutral fallback and is counted for the
|
||||||
|
// operator. gogobee can ship a new event type any day; the worst case is a
|
||||||
|
// thin card until Pete learns the words. 400 stays for facts that are actually
|
||||||
|
// invalid — no guid, or a failed name guard.
|
||||||
|
headline, lede, known := renderAdventure(f)
|
||||||
|
if !known {
|
||||||
|
advNoteUnknownType(f.EventType)
|
||||||
|
slog.Warn("adventure ingest: no template for event_type, publishing on fallback",
|
||||||
|
"guid", f.GUID, "event_type", f.EventType)
|
||||||
|
headline, lede = advFallbackRender(f)
|
||||||
}
|
}
|
||||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
||||||
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
||||||
@@ -140,7 +168,11 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
articleURL := s.advPermalink(f.GUID)
|
articleURL := s.advPermalink(f.GUID)
|
||||||
imageURL := advArtURL(f.EventType)
|
// Keyed on the guid, not the event type: the card renderer reads the fact
|
||||||
|
// behind the dispatch so it can put the boss's name on it. The fact insert
|
||||||
|
// below is best-effort, and the card degrades to the type-only emblem when
|
||||||
|
// it isn't there — so this URL is safe to bake in before that runs.
|
||||||
|
imageURL := advArtURL(f.GUID)
|
||||||
if err := storage.InsertStory(&storage.Story{
|
if err := storage.InsertStory(&storage.Story{
|
||||||
GUID: f.GUID,
|
GUID: f.GUID,
|
||||||
Headline: headline,
|
Headline: headline,
|
||||||
@@ -177,6 +209,7 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
Milestone: f.Milestone,
|
Milestone: f.Milestone,
|
||||||
Stakes: f.Stakes,
|
Stakes: f.Stakes,
|
||||||
Actors: f.Actors,
|
Actors: f.Actors,
|
||||||
|
RunID: f.RunID,
|
||||||
OccurredAt: occurredAt,
|
OccurredAt: occurredAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
|
slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
|
||||||
@@ -184,13 +217,25 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||||||
|
|
||||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
// Two reasons a dispatch never reaches Matrix, both retired the same way:
|
||||||
// the live post isn't enough: the digest collects adventure rows that carry no
|
//
|
||||||
// post_log entry, so a backfilled bulletin would still be swept into the next
|
// - NoPush: a cold-start backfill, the back-catalogue dump it exists to
|
||||||
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
|
// prevent.
|
||||||
// against the digest up front instead.
|
// - A room-silent type: gogobee already announced this exact moment to the
|
||||||
if f.NoPush {
|
// games room in TwinBee's voice, and relaying it is the room hearing one
|
||||||
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
|
// beat twice in two voices.
|
||||||
|
//
|
||||||
|
// Suppressing only the live post isn't enough: the digest collects adventure
|
||||||
|
// rows that carry no post_log entry, so a held-back bulletin would still be
|
||||||
|
// swept into the next roundup. Retire the guid against the digest up front
|
||||||
|
// instead. The row was stored above either way, so the site, the permalink
|
||||||
|
// and the push alerts keep the full record.
|
||||||
|
if f.NoPush || s.roomSilent[f.EventType] {
|
||||||
|
retiredAs := advBackfillEvent
|
||||||
|
if !f.NoPush {
|
||||||
|
retiredAs = advRoomSilentEvent
|
||||||
|
}
|
||||||
|
storage.MarkAdventureDigested([]string{f.GUID}, retiredAs)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
return
|
return
|
||||||
@@ -198,7 +243,13 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||||
// digest. Website section always gets the row above regardless of tier.
|
// digest. Website section always gets the row above regardless of tier.
|
||||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
//
|
||||||
|
// An untemplated type never interrupts the room, whatever tier it claims.
|
||||||
|
// Publishing one to the site is cheap and reversible — a thin card among
|
||||||
|
// cards. Pinging everyone in Matrix with a dispatch Pete couldn't phrase is
|
||||||
|
// neither. It still reaches Matrix through the daily digest, one line among
|
||||||
|
// many, which is the right volume for something we don't understand yet.
|
||||||
|
if f.Tier == "priority" && known && s.advPost != nil && s.adv.Channel != "" {
|
||||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||||
// media), and the link's og:image carries the preview instead.
|
// media), and the link's og:image carries the preview instead.
|
||||||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||||
@@ -254,46 +305,82 @@ func advEventMeta(eventType string) (label, emoji string) {
|
|||||||
return "The contract landed", "💀"
|
return "The contract landed", "💀"
|
||||||
case "mischief_fizzled":
|
case "mischief_fizzled":
|
||||||
return "Nobody home", "🚪"
|
return "Nobody home", "🚪"
|
||||||
|
case "companion_hire":
|
||||||
|
return "Pete tags along", "🎒"
|
||||||
}
|
}
|
||||||
return "Dispatch", "📣"
|
return "Dispatch", "📣"
|
||||||
}
|
}
|
||||||
|
|
||||||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
// advUnknownTypes counts event types that arrived without a template, so an
|
||||||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
// operator can see what Pete needs to learn to write. Before the unknown-type
|
||||||
// the external-image thumbnailer.
|
// inversion these were 400s: gogobee retried to its cap and then parked the
|
||||||
func advArtURL(eventType string) string {
|
// dispatch forever, which is how `companion_hire` was silently dropped for
|
||||||
return "/adventure/art/" + eventType + ".svg"
|
// months. Now they publish on the neutral fallback and land here instead, where
|
||||||
|
// the admin status page can say "companion_hire ×12, still no template".
|
||||||
|
var advUnknownTypes = struct {
|
||||||
|
sync.Mutex
|
||||||
|
counts map[string]int
|
||||||
|
}{counts: map[string]int{}}
|
||||||
|
|
||||||
|
func advNoteUnknownType(eventType string) {
|
||||||
|
advUnknownTypes.Lock()
|
||||||
|
defer advUnknownTypes.Unlock()
|
||||||
|
advUnknownTypes.counts[eventType]++
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdventureArt renders the themed emblem for an event type — an adventure
|
// AdvUnknownTypeCounts returns a copy of the untemplated-type tally.
|
||||||
// gradient with the event's emoji and label. Deterministic and dependency-free
|
func AdvUnknownTypeCounts() map[string]int {
|
||||||
// (no external asset), so every dispatch card has visual identity instead of the
|
advUnknownTypes.Lock()
|
||||||
// blank placeholder that made the section look broken next to RSS cards.
|
defer advUnknownTypes.Unlock()
|
||||||
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
out := make(map[string]int, len(advUnknownTypes.counts))
|
||||||
if !s.adv.Enabled {
|
for k, v := range advUnknownTypes.counts {
|
||||||
http.NotFound(w, r)
|
out[k] = v
|
||||||
return
|
|
||||||
}
|
}
|
||||||
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
return out
|
||||||
label, emoji := advEventMeta(eventType)
|
|
||||||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
||||||
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
|
// withArticle prefixes a noun with "a"/"an". Class names are a small closed set
|
||||||
// card ratio) so the same image works as a link-preview image.
|
// from the game ("cleric", "artificer"), so first-letter vowel is enough — this
|
||||||
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
// is not trying to be a general English article engine.
|
||||||
<defs>
|
func withArticle(noun string) string {
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
if noun == "" {
|
||||||
<stop offset="0" stop-color="#7c5ce8"/>
|
return ""
|
||||||
<stop offset="1" stop-color="#5836b8"/>
|
}
|
||||||
</linearGradient>
|
switch noun[0] {
|
||||||
</defs>
|
case 'a', 'e', 'i', 'o', 'u':
|
||||||
<rect width="1200" height="630" fill="url(#g)"/>
|
return "an " + noun
|
||||||
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
|
}
|
||||||
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
|
return "a " + noun
|
||||||
</svg>`
|
}
|
||||||
|
|
||||||
|
// advFallbackRender is the dispatch for an event type Pete has no template for.
|
||||||
|
//
|
||||||
|
// It is deliberately thin and deliberately honest: it says something happened
|
||||||
|
// and admits the details aren't in yet, rather than guessing at semantics Pete
|
||||||
|
// doesn't have. Only guarded or game-authored fields reach it — Subject has
|
||||||
|
// already passed factGuard, and Zone is game-authored — so it carries no more
|
||||||
|
// exposure than any templated branch.
|
||||||
|
//
|
||||||
|
// In practice it is rarely what publishes. gogobee authors LLM prose for every
|
||||||
|
// fact from the fact's fields with no per-type switch (authorDispatch in
|
||||||
|
// pete_dispatch_voice.go), so an unknown type still arrives with a real headline
|
||||||
|
// and lede, and this only shows through when the model is off or the prose-guard
|
||||||
|
// rejected it.
|
||||||
|
func advFallbackRender(f AdvFact) (headline, lede string) {
|
||||||
|
const stillGetting = " I'm still getting the details on this one — I'll fill it in properly when I have them."
|
||||||
|
switch {
|
||||||
|
case f.Subject != "" && f.Zone != "":
|
||||||
|
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||||
|
fmt.Sprintf("Something happened out in %s involving %s.%s", f.Zone, f.Subject, stillGetting)
|
||||||
|
case f.Subject != "":
|
||||||
|
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||||
|
fmt.Sprintf("%s has been up to something.%s", f.Subject, stillGetting)
|
||||||
|
case f.Zone != "":
|
||||||
|
return fmt.Sprintf("Something's happened in %s.", f.Zone),
|
||||||
|
"Word just came in from the field." + stillGetting
|
||||||
|
}
|
||||||
|
return "Word in from the realm.", "Something happened out there." + stillGetting
|
||||||
|
}
|
||||||
|
|
||||||
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
||||||
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
||||||
@@ -306,6 +393,10 @@ type advStoryPage struct {
|
|||||||
Region string
|
Region string
|
||||||
When string
|
When string
|
||||||
Permalink string
|
Permalink string
|
||||||
|
// RunReportURL is the link to the expedition behind this dispatch, when the
|
||||||
|
// dispatch is the end of one and the run is still reachable. Empty is the
|
||||||
|
// common case and renders nothing.
|
||||||
|
RunReportURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||||||
@@ -336,21 +427,40 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
|||||||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The way back to what actually happened. Best-effort and usually absent: only
|
||||||
|
// the three end-of-expedition types carry a run id at all, and the run behind
|
||||||
|
// one is swept after a fortnight. A dispatch without it reads exactly as it
|
||||||
|
// did before the report existed.
|
||||||
|
// Region rides the same lookup rather than a second query. It is a *fact*
|
||||||
|
// field, never on the story row — the story is the words Pete wrote and they
|
||||||
|
// have no columns for where. So a dispatch filed before the fact table existed
|
||||||
|
// still renders regionless, which is what it always did.
|
||||||
|
runReport, region := "", ""
|
||||||
|
if ev, err := storage.AdventureEventByGUID(guid); err != nil {
|
||||||
|
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
|
||||||
|
} else {
|
||||||
|
runReport = runReportLinkFor(ev)
|
||||||
|
if ev != nil {
|
||||||
|
region = ev.Region
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
base := s.base(r)
|
base := s.base(r)
|
||||||
base.Active = "adventure"
|
base.Active = "adventure"
|
||||||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||||||
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
||||||
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
|
base.OGImage = abs + advArtURL(guid) // the dispatch's own card, for link unfurls
|
||||||
}
|
}
|
||||||
s.render(w, "story", advStoryPage{
|
s.render(w, "story", advStoryPage{
|
||||||
pageData: base,
|
pageData: base,
|
||||||
EventLabel: label,
|
EventLabel: label,
|
||||||
Emoji: emoji,
|
Emoji: emoji,
|
||||||
Headline: st.Headline,
|
Headline: st.Headline,
|
||||||
Body: body,
|
Body: body,
|
||||||
Region: "", // reserved: region isn't stored on the row yet
|
Region: region,
|
||||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||||
Permalink: s.advPermalink(guid),
|
Permalink: s.advPermalink(guid),
|
||||||
|
RunReportURL: runReport,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,6 +741,24 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
|||||||
case "milestone":
|
case "milestone":
|
||||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||||
|
case "companion_hire":
|
||||||
|
// Pete himself has been hired onto somebody's expedition. He is the one
|
||||||
|
// being reported on here, so this is the one family besides his duels that
|
||||||
|
// is properly first-person. Subject is the LEADER who hired him (and whose
|
||||||
|
// opt-out therefore applies), class_race is the seat he's filling.
|
||||||
|
seat, seatArticled := strings.ToLower(f.ClassRace), ""
|
||||||
|
if seat == "" {
|
||||||
|
seat, seatArticled = "an extra pair of hands", "an extra pair of hands"
|
||||||
|
} else {
|
||||||
|
seatArticled = withArticle(seat)
|
||||||
|
}
|
||||||
|
intoZone := ""
|
||||||
|
if f.Zone != "" {
|
||||||
|
intoZone = " into " + f.Zone
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Filling in as %s for %s.", seat, f.Subject),
|
||||||
|
fmt.Sprintf("%s needed %s and I wasn't doing much, so I'm tagging along%s%s. I'll pull my weight — the reporting can wait till we're home.",
|
||||||
|
f.Subject, seatArticled, intoZone, atLevel), true
|
||||||
}
|
}
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The dispatch card.
|
||||||
|
//
|
||||||
|
// Every adventure dispatch used to render the same image: one violet gradient,
|
||||||
|
// a swapped emoji, and a label. A death, a realm-first, and a legendary hoard
|
||||||
|
// were visually identical — and this image is not decoration, it is the og:image
|
||||||
|
// on every link Pete puts in Matrix and the thumbnail on every feed card. The
|
||||||
|
// most interesting thing that has ever happened in the realm looked exactly like
|
||||||
|
// the most routine.
|
||||||
|
//
|
||||||
|
// Two changes fix that. The card is keyed on the dispatch GUID rather than the
|
||||||
|
// event type, so it can read the fact behind the dispatch and put the actual
|
||||||
|
// NOUNS on it — the boss's name, the zone, the level, the item. And each event
|
||||||
|
// family gets its own palette, with treasure tinted by the rarity gogobee
|
||||||
|
// already computes and throws away into a sentence.
|
||||||
|
//
|
||||||
|
// Everything here stays deterministic, server-rendered, dependency-free SVG:
|
||||||
|
// same input, same bytes, no external asset, no font file, cacheable forever.
|
||||||
|
|
||||||
|
// advArtCard is the fully-resolved card: what to draw, already escaped-safe as
|
||||||
|
// plain text (the renderer escapes on write). Built by advArtCardFor.
|
||||||
|
type advArtCard struct {
|
||||||
|
Label string // the event-family chip, e.g. "THE SIEGE"
|
||||||
|
Emoji string
|
||||||
|
Noun string // the headline noun: boss, zone, item, or adventurer
|
||||||
|
Detail string // the supporting line: region, level, who
|
||||||
|
Ceremony string // ribbon text for a realm-first; "" for everything else
|
||||||
|
Palette advPalette
|
||||||
|
Bar *advArtBar // siege HP, when we have it
|
||||||
|
}
|
||||||
|
|
||||||
|
// advArtBar is the Siege health bar drawn onto a siege card. This is the W1
|
||||||
|
// deferral landing: a Matrix unfurl of "the town holds" that SHOWS the bar is
|
||||||
|
// worth ten paragraphs, and it was parked here because doing it in W1 would have
|
||||||
|
// meant threading boss HP through art plumbing this phase was going to redesign.
|
||||||
|
type advArtBar struct {
|
||||||
|
Current, Max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// advPalette is one event family's colours. From/To are the background gradient
|
||||||
|
// stops; Accent tints the chip, the ribbon and the bar fill, and is also what a
|
||||||
|
// feed card borrows for its border.
|
||||||
|
type advPalette struct {
|
||||||
|
From, To, Accent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The house palette. Dark, saturated backgrounds so white text always clears
|
||||||
|
// contrast, with an accent bright enough to read as a border on both the light
|
||||||
|
// and dark site themes.
|
||||||
|
var (
|
||||||
|
palSiege = advPalette{"#7a1f12", "#2b0a06", "#ff6b3d"} // ember: the town is on fire
|
||||||
|
palDeath = advPalette{"#3b4250", "#171a20", "#9aa6b8"} // slate: no colour, on purpose
|
||||||
|
palBoss = advPalette{"#4a1030", "#1a0714", "#ff4d6d"}
|
||||||
|
palZone = advPalette{"#14532d", "#052e16", "#4ade80"}
|
||||||
|
palMischief = advPalette{"#4c1d95", "#120524", "#a78bfa"}
|
||||||
|
palArrival = advPalette{"#0e7490", "#083344", "#22d3ee"}
|
||||||
|
palMilestone = advPalette{"#a16207", "#422006", "#fbbf24"}
|
||||||
|
palSetback = advPalette{"#78350f", "#2a1206", "#f59e0b"} // retreat, departure
|
||||||
|
palRival = advPalette{"#1e3a8a", "#0b1a3d", "#60a5fa"}
|
||||||
|
palPete = advPalette{"#7c5ce8", "#5836b8", "#c4b5fd"} // Pete's own violet
|
||||||
|
palNeutral = advPalette{"#7c5ce8", "#5836b8", "#c4b5fd"} // the old one-and-only
|
||||||
|
|
||||||
|
// Treasure is tinted by rarity — the loot-game convention, and gogobee
|
||||||
|
// already computes the word (treasureRarityWord) and spends it on prose.
|
||||||
|
palLegendary = advPalette{"#b4530a", "#4a1d02", "#ffb020"}
|
||||||
|
palEpic = advPalette{"#5b21b6", "#2e1065", "#c084fc"}
|
||||||
|
palRare = advPalette{"#1e3a8a", "#0b1a3d", "#60a5fa"}
|
||||||
|
palUncommon = advPalette{"#14532d", "#052e16", "#4ade80"}
|
||||||
|
palCommon = advPalette{"#3f3f46", "#18181b", "#a1a1aa"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// advPaletteFor picks the family colours. outcome carries the treasure rarity
|
||||||
|
// and is ignored everywhere else.
|
||||||
|
func advPaletteFor(eventType, outcome string) advPalette {
|
||||||
|
switch eventType {
|
||||||
|
case "siege_start", "siege_win", "siege_loss":
|
||||||
|
return palSiege
|
||||||
|
case "death":
|
||||||
|
return palDeath
|
||||||
|
case "boss_first", "boss_kill":
|
||||||
|
return palBoss
|
||||||
|
case "zone_first", "zone_clear":
|
||||||
|
return palZone
|
||||||
|
case "treasure_found":
|
||||||
|
switch strings.ToLower(outcome) {
|
||||||
|
case "legendary":
|
||||||
|
return palLegendary
|
||||||
|
case "epic":
|
||||||
|
return palEpic
|
||||||
|
case "rare":
|
||||||
|
return palRare
|
||||||
|
case "uncommon":
|
||||||
|
return palUncommon
|
||||||
|
case "common":
|
||||||
|
return palCommon
|
||||||
|
}
|
||||||
|
return palLegendary // story-grade finds are typically tier 5
|
||||||
|
case "mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled":
|
||||||
|
return palMischief
|
||||||
|
case "arrival":
|
||||||
|
return palArrival
|
||||||
|
case "milestone":
|
||||||
|
return palMilestone
|
||||||
|
case "retreat", "departure":
|
||||||
|
return palSetback
|
||||||
|
case "standings", "rival_result", "pete_duel_win", "pete_duel_loss":
|
||||||
|
return palRival
|
||||||
|
case "companion_hire":
|
||||||
|
return palPete
|
||||||
|
}
|
||||||
|
return palNeutral
|
||||||
|
}
|
||||||
|
|
||||||
|
// advIsRealmFirst reports whether a dispatch is the first time anything like it
|
||||||
|
// has ever happened in the realm. gogobee already computes this — it is the
|
||||||
|
// priority/bulletin split claimRealmFirst applies — and until now it only
|
||||||
|
// decided whether Matrix got pinged. A thing nobody has ever done should also
|
||||||
|
// LOOK different from the ninth time somebody did it.
|
||||||
|
func advIsRealmFirst(eventType, tier string) bool {
|
||||||
|
switch eventType {
|
||||||
|
case "boss_first", "zone_first":
|
||||||
|
return true
|
||||||
|
case "treasure_found":
|
||||||
|
// A realm-first hoard rides the priority tier, the same split
|
||||||
|
// BuildTrophyCase counts on.
|
||||||
|
return tier == "priority"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// advCardAccent is the feed-card tint for a dispatch: the family accent colour
|
||||||
|
// and whether it earns the realm-first ring. Returns "" for a non-adventure or
|
||||||
|
// unknown story so the caller leaves the card's default border alone.
|
||||||
|
func advCardAccent(eventType, tier, outcome string) (accent string, ceremony bool) {
|
||||||
|
if eventType == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return advPaletteFor(eventType, outcome).Accent, advIsRealmFirst(eventType, tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advArtCardFor resolves a dispatch into everything the card draws.
|
||||||
|
//
|
||||||
|
// ev is nil for a dispatch with no stored fact — anything that predates the fact
|
||||||
|
// table, plus the brief window where the story row exists and the best-effort
|
||||||
|
// fact insert failed. That degrades to exactly the old card (family palette,
|
||||||
|
// emoji, label) rather than to a broken one.
|
||||||
|
func advArtCardFor(eventType string, ev *storage.AdvEvent) advArtCard {
|
||||||
|
label, emoji := advEventMeta(eventType)
|
||||||
|
card := advArtCard{Label: strings.ToUpper(label), Emoji: emoji, Palette: advPaletteFor(eventType, "")}
|
||||||
|
if ev == nil {
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
card.Palette = advPaletteFor(eventType, ev.Outcome)
|
||||||
|
if advIsRealmFirst(eventType, ev.Tier) {
|
||||||
|
card.Ceremony = "REALM FIRST"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The noun is whatever the dispatch is ABOUT — which is not the same field
|
||||||
|
// from family to family. A siege is about the boss; a treasure is about the
|
||||||
|
// item; a death is about the person.
|
||||||
|
switch eventType {
|
||||||
|
case "siege_start", "siege_win", "siege_loss":
|
||||||
|
card.Noun = ev.Boss
|
||||||
|
card.Detail = advSiegeDetail(eventType, ev.Tally)
|
||||||
|
if cur, max, ok := storage.SiegeBarForBoss(ev.Boss, ev.OccurredAt); ok {
|
||||||
|
card.Bar = &advArtBar{Current: cur, Max: max}
|
||||||
|
}
|
||||||
|
case "boss_first", "boss_kill":
|
||||||
|
card.Noun = ev.Boss
|
||||||
|
card.Detail = advJoinDetail(ev.Subject, ev.Zone, ev.Level)
|
||||||
|
case "zone_first", "zone_clear":
|
||||||
|
card.Noun = ev.Zone
|
||||||
|
card.Detail = advJoinDetail(ev.Subject, ev.Region, ev.Level)
|
||||||
|
case "treasure_found":
|
||||||
|
card.Noun = ev.Stakes // the item's name
|
||||||
|
card.Detail = advJoinDetail(ev.Subject, ev.Zone, ev.Level)
|
||||||
|
if ev.Outcome != "" {
|
||||||
|
card.Label = strings.ToUpper(ev.Outcome)
|
||||||
|
}
|
||||||
|
case "mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled":
|
||||||
|
card.Noun = ev.Subject
|
||||||
|
card.Detail = advJoinDetail(ev.Boss, ev.Zone, ev.Level)
|
||||||
|
case "companion_hire":
|
||||||
|
card.Noun = ev.Subject
|
||||||
|
card.Detail = advJoinDetail("", ev.Zone, ev.Level)
|
||||||
|
case "milestone":
|
||||||
|
card.Noun = ev.Subject
|
||||||
|
card.Detail = ev.Milestone
|
||||||
|
default:
|
||||||
|
card.Noun = ev.Subject
|
||||||
|
card.Detail = advJoinDetail("", ev.Zone, ev.Level)
|
||||||
|
}
|
||||||
|
if card.Noun == "" { // a fact missing its own subject still gets a card
|
||||||
|
card.Noun = ev.Subject
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
// advSiegeDetail is the siege card's supporting line. Tally is the defender
|
||||||
|
// count on a win; a start has none yet.
|
||||||
|
func advSiegeDetail(eventType string, defenders int) string {
|
||||||
|
switch {
|
||||||
|
case eventType == "siege_start":
|
||||||
|
return "the town is called out"
|
||||||
|
case defenders == 1:
|
||||||
|
return "1 defender"
|
||||||
|
case defenders > 1:
|
||||||
|
return fmt.Sprintf("%d defenders", defenders)
|
||||||
|
case eventType == "siege_win":
|
||||||
|
return "the town holds"
|
||||||
|
}
|
||||||
|
return "the gates gave way"
|
||||||
|
}
|
||||||
|
|
||||||
|
// advJoinDetail assembles the supporting line from whichever of who/where/level
|
||||||
|
// the fact actually has, dot-separated, skipping the empties. A card with one
|
||||||
|
// real field reads better than one padded out with "unknown".
|
||||||
|
func advJoinDetail(who, where string, level int) string {
|
||||||
|
var parts []string
|
||||||
|
if who != "" {
|
||||||
|
parts = append(parts, who)
|
||||||
|
}
|
||||||
|
if where != "" {
|
||||||
|
parts = append(parts, where)
|
||||||
|
}
|
||||||
|
if level > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("level %d", level))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// advArtURL is the card/OG image for a dispatch, keyed on the dispatch GUID so
|
||||||
|
// the renderer can read the fact behind it and name names. Root-relative, so it
|
||||||
|
// bypasses the external-image thumbnailer.
|
||||||
|
//
|
||||||
|
// The guid is path-escaped for the same reason advPermalink escapes it: it is
|
||||||
|
// ingest-supplied, and a stray "/" would produce a URL that routes somewhere
|
||||||
|
// else entirely.
|
||||||
|
//
|
||||||
|
// Older stories have an event-type URL baked into their image_url column
|
||||||
|
// (/adventure/art/death.svg). Those keep working — handleAdventureArt falls back
|
||||||
|
// to the type-only card when the path isn't a guid it knows — so nothing has to
|
||||||
|
// be backfilled and no card ever 404s.
|
||||||
|
func advArtURL(guid string) string {
|
||||||
|
return "/adventure/art/" + url.PathEscape(guid) + ".svg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdventureArt renders one dispatch's card.
|
||||||
|
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
||||||
|
|
||||||
|
// The path is either a guid ("death:<hash>:<ts>") or, for a story from
|
||||||
|
// before this was guid-keyed, a bare event type. Both start with the event
|
||||||
|
// type, so the family colours are right either way; only the nouns need the
|
||||||
|
// fact row.
|
||||||
|
ev, err := storage.AdventureEventByGUID(key)
|
||||||
|
if err != nil {
|
||||||
|
ev = nil // a read failure is a thinner card, not a broken image
|
||||||
|
}
|
||||||
|
eventType := key
|
||||||
|
if t, _, hasSep := strings.Cut(key, ":"); hasSep {
|
||||||
|
eventType = t
|
||||||
|
}
|
||||||
|
card := advArtCardFor(eventType, ev)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||||||
|
// A finished card never changes, so cache it hard. The exception is a siege
|
||||||
|
// card still waiting on its bar: the win/loss dispatch is filed before the
|
||||||
|
// war-room push that explains it, and an unfurl fetched in that window would
|
||||||
|
// otherwise be pinned barless for a day.
|
||||||
|
if card.Bar == nil && strings.HasPrefix(eventType, "siege_") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(advRenderArt(card)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card geometry. 1200×630 is the OG ratio, so the same image works as a
|
||||||
|
// link-preview and as a feed thumbnail.
|
||||||
|
const (
|
||||||
|
advArtW = 1200
|
||||||
|
advArtH = 630
|
||||||
|
|
||||||
|
// advSafeX is the horizontal margin anything readable has to stay inside.
|
||||||
|
//
|
||||||
|
// The card is 1200×630 for the OG ratio, but the feed thumbnail is a 16/10
|
||||||
|
// object-cover box — it keeps the full height and crops the WIDTH to
|
||||||
|
// 630×1.6 = 1008px, taking 96px off each side. A chip pinned at x=64 renders
|
||||||
|
// perfectly on the permalink and as "EGENDARY" in the feed. Centred text is
|
||||||
|
// unaffected; only the corner furniture has to respect this.
|
||||||
|
advSafeX = 116
|
||||||
|
)
|
||||||
|
|
||||||
|
// advDisplayFont is the site's display stack. No @font-face: an SVG served as an
|
||||||
|
// image can't fetch one, so this resolves against whatever the renderer has and
|
||||||
|
// falls through to the system UI face.
|
||||||
|
const advDisplayFont = "Fredoka, Nunito, system-ui, sans-serif"
|
||||||
|
|
||||||
|
// advRenderArt draws the card. Deterministic: same card in, same bytes out.
|
||||||
|
func advRenderArt(c advArtCard) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
|
||||||
|
advArtW, advArtH, advArtW, advArtH, esc(c.Label+" "+c.Noun))
|
||||||
|
fmt.Fprintf(&b, `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%s"/><stop offset="1" stop-color="%s"/></linearGradient>`,
|
||||||
|
esc(c.Palette.From), esc(c.Palette.To))
|
||||||
|
// A soft radial lift behind the emoji so the middle of the card isn't flat.
|
||||||
|
fmt.Fprintf(&b, `<radialGradient id="v" cx="0.5" cy="0.34" r="0.62"><stop offset="0" stop-color="%s" stop-opacity="0.35"/><stop offset="1" stop-color="%s" stop-opacity="0"/></radialGradient></defs>`,
|
||||||
|
esc(c.Palette.Accent), esc(c.Palette.Accent))
|
||||||
|
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="url(#g)"/><rect width="%d" height="%d" fill="url(#v)"/>`,
|
||||||
|
advArtW, advArtH, advArtW, advArtH)
|
||||||
|
// An accent hairline along the bottom, so even a card cropped to a strip in
|
||||||
|
// a feed still carries its family colour.
|
||||||
|
fmt.Fprintf(&b, `<rect x="0" y="%d" width="%d" height="8" fill="%s"/>`, advArtH-8, advArtW, esc(c.Palette.Accent))
|
||||||
|
|
||||||
|
advDrawChip(&b, c)
|
||||||
|
if c.Ceremony != "" {
|
||||||
|
advDrawRibbon(&b, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical rhythm: emoji, noun, detail, and the bar when there is one. The
|
||||||
|
// block sits higher when a bar has to fit under it. The gaps are wider than
|
||||||
|
// they look on paper because an emoji is drawn from its own centre and a
|
||||||
|
// name from its baseline — set them any closer and a tall glyph sits on top
|
||||||
|
// of the capital letters underneath it.
|
||||||
|
emojiY, nounY, detailY := 250, 420, 488
|
||||||
|
if c.Bar != nil {
|
||||||
|
emojiY, nounY, detailY = 200, 350, 412
|
||||||
|
}
|
||||||
|
if c.Noun == "" {
|
||||||
|
// Nothing to name (a pre-fact-table dispatch): centre the emoji and let
|
||||||
|
// the label carry the card, which is what the old emblem did.
|
||||||
|
emojiY, nounY, detailY = 300, 0, 500
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" text-anchor="middle" dominant-baseline="central">%s</text>`,
|
||||||
|
emojiY, advEmojiSize(c), esc(c.Emoji))
|
||||||
|
|
||||||
|
if c.Noun != "" {
|
||||||
|
noun := advClamp(c.Noun, 38)
|
||||||
|
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" font-family="%s" font-weight="700" fill="#ffffff" text-anchor="middle">%s</text>`,
|
||||||
|
nounY, advFitSize(noun, 1060, 82, 40), advDisplayFont, esc(noun))
|
||||||
|
}
|
||||||
|
if c.Detail != "" {
|
||||||
|
detail := advClamp(c.Detail, 64)
|
||||||
|
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" font-family="%s" font-weight="600" fill="#ffffff" fill-opacity="0.72" text-anchor="middle">%s</text>`,
|
||||||
|
detailY, advFitSize(detail, 1040, 38, 26), advDisplayFont, esc(detail))
|
||||||
|
}
|
||||||
|
if c.Bar != nil {
|
||||||
|
advDrawBar(&b, c)
|
||||||
|
}
|
||||||
|
b.WriteString(`</svg>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// advEmojiSize shrinks the emblem when the card also has to carry a name — the
|
||||||
|
// old 260px glyph was the whole design, and next to a boss name it just crowds
|
||||||
|
// it out.
|
||||||
|
func advEmojiSize(c advArtCard) int {
|
||||||
|
if c.Noun == "" {
|
||||||
|
return 260
|
||||||
|
}
|
||||||
|
if c.Bar != nil {
|
||||||
|
return 110
|
||||||
|
}
|
||||||
|
return 148
|
||||||
|
}
|
||||||
|
|
||||||
|
// advDrawChip draws the event-family label as a pill in the top-left.
|
||||||
|
func advDrawChip(b *strings.Builder, c advArtCard) {
|
||||||
|
label := advClamp(c.Label, 28)
|
||||||
|
// Letter-spaced small caps: width is the glyph run plus the tracking.
|
||||||
|
const size, track = 30, 5.0
|
||||||
|
w := int(float64(utf8.RuneCountInString(label))*(float64(size)*0.62+track)) + 56
|
||||||
|
fmt.Fprintf(b, `<rect x="%d" y="56" width="%d" height="60" rx="30" fill="%s" fill-opacity="0.22" stroke="%s" stroke-opacity="0.55" stroke-width="2"/>`,
|
||||||
|
advSafeX, w, esc(c.Palette.Accent), esc(c.Palette.Accent))
|
||||||
|
fmt.Fprintf(b, `<text x="%d" y="86" font-size="%d" font-family="%s" font-weight="700" fill="#ffffff" letter-spacing="%.0f" text-anchor="middle" dominant-baseline="central">%s</text>`,
|
||||||
|
advSafeX+w/2, size, advDisplayFont, track, esc(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
// advDrawRibbon draws the realm-first banner in the top-right. Filled with the
|
||||||
|
// accent at full strength — this is the one card element allowed to shout.
|
||||||
|
func advDrawRibbon(b *strings.Builder, c advArtCard) {
|
||||||
|
label := advClamp(c.Ceremony, 24)
|
||||||
|
const size, track = 28, 5.0
|
||||||
|
w := int(float64(utf8.RuneCountInString(label))*(float64(size)*0.62+track)) + 52
|
||||||
|
x := advArtW - advSafeX - w
|
||||||
|
fmt.Fprintf(b, `<rect x="%d" y="56" width="%d" height="60" rx="12" fill="%s"/>`, x, w, esc(c.Palette.Accent))
|
||||||
|
fmt.Fprintf(b, `<text x="%d" y="86" font-size="%d" font-family="%s" font-weight="700" fill="#0f0a04" letter-spacing="%.0f" text-anchor="middle" dominant-baseline="central">%s</text>`,
|
||||||
|
x+w/2, size, advDisplayFont, track, esc(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
// advDrawBar draws the Siege health bar: the track, the fill, and the numbers.
|
||||||
|
//
|
||||||
|
// HP remaining, not damage dealt — the same direction the war room page draws,
|
||||||
|
// so the unfurl and the page you land on from it agree. The caption is
|
||||||
|
// event-aware because the bar alone doesn't say who won: an empty track on a
|
||||||
|
// victory card is the best possible outcome and would otherwise read at a glance
|
||||||
|
// as a wipe.
|
||||||
|
func advDrawBar(b *strings.Builder, c advArtCard) {
|
||||||
|
const x, y, w, h = 190, 470, 820, 34
|
||||||
|
frac := 0.0
|
||||||
|
if c.Bar.Max > 0 {
|
||||||
|
frac = float64(c.Bar.Current) / float64(c.Bar.Max)
|
||||||
|
}
|
||||||
|
if frac < 0 {
|
||||||
|
frac = 0
|
||||||
|
}
|
||||||
|
if frac > 1 {
|
||||||
|
frac = 1
|
||||||
|
}
|
||||||
|
fill := int(frac * w)
|
||||||
|
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d" fill="#000000" fill-opacity="0.38"/>`, x, y, w, h, h/2)
|
||||||
|
if fill > 0 {
|
||||||
|
// rx on a very short fill would round it away to nothing; clamp the
|
||||||
|
// corner radius to half the drawn width so a nearly-dead boss still
|
||||||
|
// shows a sliver.
|
||||||
|
rx := h / 2
|
||||||
|
if fill/2 < rx {
|
||||||
|
rx = fill / 2
|
||||||
|
}
|
||||||
|
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d" fill="%s"/>`, x, y, fill, h, rx, esc(c.Palette.Accent))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(b, `<text x="600" y="%d" font-size="30" font-family="%s" font-weight="700" fill="#ffffff" fill-opacity="0.82" text-anchor="middle">%s</text>`,
|
||||||
|
y+h+40, advDisplayFont, esc(advBarCaption(c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// advBarCaption says what the bar means. A siege_win's bar is empty because the
|
||||||
|
// town emptied it.
|
||||||
|
func advBarCaption(c advArtCard) string {
|
||||||
|
switch {
|
||||||
|
case c.Bar.Current <= 0:
|
||||||
|
return fmt.Sprintf("all %s of it, brought to zero", advComma(c.Bar.Max))
|
||||||
|
case c.Bar.Current >= c.Bar.Max:
|
||||||
|
return fmt.Sprintf("%s HP, untouched so far", advComma(c.Bar.Max))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s HP left of %s", advComma(c.Bar.Current), advComma(c.Bar.Max))
|
||||||
|
}
|
||||||
|
|
||||||
|
// advComma renders an int with thousands separators. Siege pools run to five and
|
||||||
|
// six figures, and "18000" is a number you have to stop and parse.
|
||||||
|
func advComma(n int) string {
|
||||||
|
s := fmt.Sprintf("%d", n)
|
||||||
|
neg := strings.HasPrefix(s, "-")
|
||||||
|
s = strings.TrimPrefix(s, "-")
|
||||||
|
var out []byte
|
||||||
|
for i, d := range []byte(s) {
|
||||||
|
if i > 0 && (len(s)-i)%3 == 0 {
|
||||||
|
out = append(out, ',')
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
return "-" + string(out)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advFitSize shrinks a font size until the string is likely to fit maxWidth,
|
||||||
|
// never below min. SVG has no measurement API on the server, so this is the
|
||||||
|
// standard approximation: ~0.58em per glyph for a humanist sans. Being a little
|
||||||
|
// conservative is the correct failure — a name that renders a shade small is
|
||||||
|
// fine, a name that runs off the card is not.
|
||||||
|
func advFitSize(s string, maxWidth, base, min int) int {
|
||||||
|
n := utf8.RuneCountInString(s)
|
||||||
|
if n == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
size := base
|
||||||
|
for size > min && float64(n)*float64(size)*0.58 > float64(maxWidth) {
|
||||||
|
size -= 2
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
// advClamp truncates to n runes with an ellipsis. Rune-aware so a non-ASCII
|
||||||
|
// name isn't cut mid-character into a replacement glyph.
|
||||||
|
func advClamp(s string, n int) string {
|
||||||
|
if utf8.RuneCountInString(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
r := []rune(s)
|
||||||
|
return strings.TrimRight(string(r[:n-1]), " ·") + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// esc escapes text for an SVG text node or attribute value. Everything on a card
|
||||||
|
// is either game-authored (boss, zone, item) or a character name that already
|
||||||
|
// passed the ingest fact-guard, so this is defence in depth rather than the only
|
||||||
|
// line — but the card is a public URL and it stays escaped regardless.
|
||||||
|
func esc(s string) string { return template.HTMLEscapeString(s) }
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestArtPaletteSplitsFamilies is the regression for the whole point of W3: two
|
||||||
|
// different kinds of dispatch must not render the same image. Before this, every
|
||||||
|
// card in the section was the same violet gradient with a swapped emoji.
|
||||||
|
func TestArtPaletteSplitsFamilies(t *testing.T) {
|
||||||
|
death := advPaletteFor("death", "")
|
||||||
|
siege := advPaletteFor("siege_win", "")
|
||||||
|
zone := advPaletteFor("zone_clear", "")
|
||||||
|
if death == siege || siege == zone || death == zone {
|
||||||
|
t.Errorf("families share a palette: death=%v siege=%v zone=%v", death, siege, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treasure is tinted by the rarity gogobee already computes and currently
|
||||||
|
// spends on an adjective in a sentence.
|
||||||
|
leg := advPaletteFor("treasure_found", "legendary")
|
||||||
|
rare := advPaletteFor("treasure_found", "rare")
|
||||||
|
if leg == rare {
|
||||||
|
t.Errorf("legendary and rare hoards render identically: %v", leg)
|
||||||
|
}
|
||||||
|
// Case shouldn't matter — the rarity word arrives however gogobee wrote it.
|
||||||
|
if advPaletteFor("treasure_found", "Legendary") != leg {
|
||||||
|
t.Error("rarity match is case-sensitive")
|
||||||
|
}
|
||||||
|
// An unrecognised rarity still gets the story-grade treatment rather than
|
||||||
|
// falling through to the neutral card.
|
||||||
|
if advPaletteFor("treasure_found", "mythic") != leg {
|
||||||
|
t.Error("unknown rarity dropped to the neutral palette")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealmFirstEarnsCeremony pins that the priority/bulletin split gogobee
|
||||||
|
// already computes now changes how a card LOOKS, not just whether Matrix gets
|
||||||
|
// pinged. A first-ever clear and the ninth repeat of it must be distinguishable.
|
||||||
|
func TestRealmFirstEarnsCeremony(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
eventType, tier string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"zone_first", "priority", true},
|
||||||
|
{"zone_clear", "bulletin", false},
|
||||||
|
{"boss_first", "priority", true},
|
||||||
|
{"boss_kill", "bulletin", false},
|
||||||
|
{"treasure_found", "priority", true}, // realm-first hoard
|
||||||
|
{"treasure_found", "bulletin", false}, // someone else already pulled it
|
||||||
|
{"death", "priority", false}, // priority, but not a "first"
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := advIsRealmFirst(c.eventType, c.tier); got != c.want {
|
||||||
|
t.Errorf("%s/%s ceremony = %v, want %v", c.eventType, c.tier, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it reaches the card as a ribbon.
|
||||||
|
ev := &storage.AdvEvent{EventType: "zone_first", Tier: "priority", Subject: "Josie", Zone: "The Sump", Level: 9}
|
||||||
|
card := advArtCardFor("zone_first", ev)
|
||||||
|
if card.Ceremony == "" {
|
||||||
|
t.Error("realm-first card has no ribbon")
|
||||||
|
}
|
||||||
|
if !strings.Contains(advRenderArt(card), card.Ceremony) {
|
||||||
|
t.Error("ribbon text never reached the SVG")
|
||||||
|
}
|
||||||
|
plain := advArtCardFor("zone_clear", &storage.AdvEvent{EventType: "zone_clear", Tier: "bulletin", Subject: "Josie", Zone: "The Sump"})
|
||||||
|
if plain.Ceremony != "" {
|
||||||
|
t.Error("a repeat clear got the realm-first ribbon")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArtCardCarriesNouns: the card names the thing the dispatch is about, and
|
||||||
|
// which field that is differs per family. A siege is about the boss; a treasure
|
||||||
|
// is about the item.
|
||||||
|
func TestArtCardCarriesNouns(t *testing.T) {
|
||||||
|
treasure := advArtCardFor("treasure_found", &storage.AdvEvent{
|
||||||
|
EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
|
||||||
|
Zone: "The Sump", Stakes: "Ring of Nine Sorrows", Outcome: "epic", Level: 12,
|
||||||
|
})
|
||||||
|
if treasure.Noun != "Ring of Nine Sorrows" {
|
||||||
|
t.Errorf("treasure noun = %q, want the item", treasure.Noun)
|
||||||
|
}
|
||||||
|
if treasure.Label != "EPIC" {
|
||||||
|
t.Errorf("treasure label = %q, want the rarity", treasure.Label)
|
||||||
|
}
|
||||||
|
svg := advRenderArt(treasure)
|
||||||
|
for _, want := range []string{"Ring of Nine Sorrows", "Josie", "The Sump", "level 12", "EPIC"} {
|
||||||
|
if !strings.Contains(svg, want) {
|
||||||
|
t.Errorf("treasure card missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boss := advArtCardFor("boss_kill", &storage.AdvEvent{
|
||||||
|
EventType: "boss_kill", Subject: "Josie", Boss: "Aldric the Pale", Zone: "dragons_lair", Level: 14,
|
||||||
|
})
|
||||||
|
if boss.Noun != "Aldric the Pale" {
|
||||||
|
t.Errorf("boss noun = %q, want the boss", boss.Noun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dispatch with no stored fact still renders — that is every story from
|
||||||
|
// before the fact table, plus the window where the best-effort fact insert
|
||||||
|
// failed. It degrades to the old emblem, not to a broken image.
|
||||||
|
bare := advArtCardFor("death", nil)
|
||||||
|
if bare.Noun != "" || bare.Label == "" {
|
||||||
|
t.Errorf("factless card = %+v, want label-only", bare)
|
||||||
|
}
|
||||||
|
if b := advRenderArt(bare); !strings.Contains(b, "<svg") || !strings.Contains(b, "🪦") {
|
||||||
|
t.Errorf("factless card didn't render an emblem: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeCardDrawsTheBar is the W1 deferral landing. A Matrix unfurl of "the
|
||||||
|
// town holds" that shows the bar is worth ten paragraphs — and the bar has to
|
||||||
|
// come from the war-room snapshot, because the siege fact carries the boss and
|
||||||
|
// the defender count but never the HP.
|
||||||
|
func TestSiegeCardDrawsTheBar(t *testing.T) {
|
||||||
|
newAdvServer(t, "t") // fresh temp DB
|
||||||
|
|
||||||
|
if err := storage.ReplaceSiege(storage.Siege{
|
||||||
|
Active: false,
|
||||||
|
History: []storage.SiegePast{{
|
||||||
|
BossID: 7, BossName: "The Rust Sovereign", Tier: 5, Outcome: "defeated",
|
||||||
|
HPRemaining: 0, HPMax: 18000, Defenders: 6, EndedAt: 5000,
|
||||||
|
}},
|
||||||
|
}, 5000); err != nil {
|
||||||
|
t.Fatalf("seed siege: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := &storage.AdvEvent{EventType: "siege_win", Tier: "priority",
|
||||||
|
Boss: "The Rust Sovereign", Tally: 6, OccurredAt: 5000}
|
||||||
|
card := advArtCardFor("siege_win", ev)
|
||||||
|
if card.Bar == nil {
|
||||||
|
t.Fatal("siege card has no bar")
|
||||||
|
}
|
||||||
|
if card.Bar.Max != 18000 {
|
||||||
|
t.Errorf("bar max = %d, want the pool", card.Bar.Max)
|
||||||
|
}
|
||||||
|
svg := advRenderArt(card)
|
||||||
|
for _, want := range []string{"The Rust Sovereign", "6 defenders", "brought to zero"} {
|
||||||
|
if !strings.Contains(svg, want) {
|
||||||
|
t.Errorf("siege card missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live Siege draws off the live row, so a siege_start card keeps sliding
|
||||||
|
// as the town chips away rather than freezing at the spawn value.
|
||||||
|
if err := storage.ReplaceSiege(storage.Siege{
|
||||||
|
Active: true, BossID: 8, BossName: "The Rust Sovereign", Tier: 5,
|
||||||
|
HPCurrent: 6200, HPMax: 18000, EndsAt: 9000,
|
||||||
|
}, 6000); err != nil {
|
||||||
|
t.Fatalf("seed live siege: %v", err)
|
||||||
|
}
|
||||||
|
live := advArtCardFor("siege_start", &storage.AdvEvent{
|
||||||
|
EventType: "siege_start", Boss: "The Rust Sovereign", OccurredAt: 6000})
|
||||||
|
if live.Bar == nil || live.Bar.Current != 6200 {
|
||||||
|
t.Fatalf("live siege bar = %+v, want the current pool", live.Bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown boss gets a siege card with no bar rather than a wrong one.
|
||||||
|
// This is the real window between a win being filed and the war-room push
|
||||||
|
// that explains it landing two minutes later.
|
||||||
|
orphan := advArtCardFor("siege_loss", &storage.AdvEvent{
|
||||||
|
EventType: "siege_loss", Boss: "Nobody In Particular", OccurredAt: 1})
|
||||||
|
if orphan.Bar != nil {
|
||||||
|
t.Errorf("drew a bar for a boss we have no snapshot of: %+v", orphan.Bar)
|
||||||
|
}
|
||||||
|
if !strings.Contains(advRenderArt(orphan), "Nobody In Particular") {
|
||||||
|
t.Error("barless siege card lost its boss name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArtRenderIsDeterministicAndEscaped: the card is a public URL served as an
|
||||||
|
// image, and it must be byte-stable so it can be cached hard.
|
||||||
|
func TestArtRenderIsDeterministicAndEscaped(t *testing.T) {
|
||||||
|
card := advArtCardFor("death", &storage.AdvEvent{
|
||||||
|
EventType: "death", Subject: `Bob<script>alert(1)</script>`, Zone: "the Underforge", Level: 3})
|
||||||
|
a := advRenderArt(card)
|
||||||
|
if a != advRenderArt(card) {
|
||||||
|
t.Error("render is not deterministic")
|
||||||
|
}
|
||||||
|
if strings.Contains(a, "<script>") {
|
||||||
|
t.Errorf("unescaped markup reached the SVG: %s", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long names shrink to fit instead of running off the 1200px card.
|
||||||
|
long := advArtCardFor("boss_kill", &storage.AdvEvent{
|
||||||
|
EventType: "boss_kill", Boss: strings.Repeat("Nebuchadnezzar ", 6)})
|
||||||
|
if got := advFitSize(long.Noun, 1060, 82, 40); got >= 82 {
|
||||||
|
t.Errorf("long name kept the full font size (%d)", got)
|
||||||
|
}
|
||||||
|
if n := len([]rune(advClamp(long.Noun, 38))); n > 38 {
|
||||||
|
t.Errorf("clamp let %d runes through", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCardAccentTintsTheFeed pins the border tint the feed cards read. It is the
|
||||||
|
// same palette the art uses, so a card and its thumbnail agree.
|
||||||
|
func TestCardAccentTintsTheFeed(t *testing.T) {
|
||||||
|
legendary, first := advCardAccent("treasure_found", "priority", "legendary")
|
||||||
|
if legendary == "" || !first {
|
||||||
|
t.Errorf("legendary realm-first accent = %q ceremony=%v", legendary, first)
|
||||||
|
}
|
||||||
|
common, _ := advCardAccent("treasure_found", "bulletin", "common")
|
||||||
|
if common == legendary {
|
||||||
|
t.Error("a common find is tinted like a legendary one")
|
||||||
|
}
|
||||||
|
if a, _ := advCardAccent("", "", ""); a != "" {
|
||||||
|
t.Error("a non-adventure story got an accent")
|
||||||
|
}
|
||||||
|
if legendary != advPaletteFor("treasure_found", "legendary").Accent {
|
||||||
|
t.Error("feed accent and card art disagree on the colour")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedCardRendersTheTint renders the real adventure page through the real
|
||||||
|
// template, because the interesting failure here is not in Go.
|
||||||
|
//
|
||||||
|
// html/template escapes a style attribute in CSS context and will replace a
|
||||||
|
// value it doesn't trust with ZgotmplZ, silently — the card would render with no
|
||||||
|
// border colour and nothing would say why. This is also the reason the accent is
|
||||||
|
// an inline style at all: a generated Tailwind class would be purged out of the
|
||||||
|
// stylesheet, which fails the same way and just as quietly.
|
||||||
|
func TestFeedCardRendersTheTint(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "t")
|
||||||
|
|
||||||
|
first := AdvFact{GUID: "zone_first:a:1000", EventType: "zone_first", Tier: "priority",
|
||||||
|
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1000}
|
||||||
|
repeat := AdvFact{GUID: "zone_clear:b:1001", EventType: "zone_clear", Tier: "bulletin",
|
||||||
|
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1001}
|
||||||
|
for _, f := range []AdvFact{first, repeat} {
|
||||||
|
if rw := postFact(t, s, "t", f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest %s: status %d", f.GUID, rw.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/adventure", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
accent := advPaletteFor("zone_first", "").Accent
|
||||||
|
if !strings.Contains(body, "border-color:"+accent) {
|
||||||
|
t.Errorf("zone accent %q never reached the card border", accent)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "ZgotmplZ") {
|
||||||
|
t.Error("html/template rejected the accent as an unsafe CSS value")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Realm first") {
|
||||||
|
t.Error("the realm-first card has no ribbon in the feed")
|
||||||
|
}
|
||||||
|
// Exactly one of the two cards is a first. If both got the badge the split
|
||||||
|
// isn't doing anything.
|
||||||
|
if n := strings.Count(body, "Realm first"); n != 1 {
|
||||||
|
t.Errorf("realm-first badges = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommaFormatsPools(t *testing.T) {
|
||||||
|
for in, want := range map[int]string{0: "0", 999: "999", 1000: "1,000", 18000: "18,000", 1234567: "1,234,567"} {
|
||||||
|
if got := advComma(in); got != want {
|
||||||
|
t.Errorf("advComma(%d) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,10 +162,12 @@ func TestAdventureDigest(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Two bulletins + one priority (which posts live and must be excluded).
|
// Two bulletins + one priority (which posts live and must be excluded).
|
||||||
|
// Both bulletin types are ones TwinBee does NOT announce itself — a
|
||||||
|
// room-silent type never reaches the digest (see TestAdventureRoomSilent).
|
||||||
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
||||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
||||||
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
|
postFact(t, s, token, AdvFact{GUID: "milestone:b:2", EventType: "milestone", Tier: "bulletin",
|
||||||
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
|
Actors: []string{"Kif"}, Subject: "Kif", Milestone: "Ten zones cleared", OccurredAt: now.Unix()})
|
||||||
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
||||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
||||||
if len(*posted) != 1 {
|
if len(*posted) != 1 {
|
||||||
@@ -191,6 +193,43 @@ func TestAdventureDigest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdventureRoomSilent: an event type gogobee announces in the games room
|
||||||
|
// itself is published to the site but never reaches Matrix — not as a live
|
||||||
|
// priority beat, and not swept into the next digest either.
|
||||||
|
func TestAdventureRoomSilent(t *testing.T) {
|
||||||
|
const token = "t"
|
||||||
|
s, posted := newAdvServer(t, token)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
postFact(t, s, token, AdvFact{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority",
|
||||||
|
Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20,
|
||||||
|
Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()})
|
||||||
|
if len(*posted) != 0 {
|
||||||
|
t.Fatalf("room-silent beat posted live: %d posts, want 0", len(*posted))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The site keeps the full record — suppression is Matrix-only.
|
||||||
|
got, err := storage.GetStoryByGUID("treasure_found:e:5")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("room-silent beat missing from the site: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it doesn't come back around in the roundup.
|
||||||
|
s.postDailyDigest(now.UTC())
|
||||||
|
if len(*posted) != 0 {
|
||||||
|
t.Errorf("room-silent beat swept into digest: %d posts, want 0", len(*posted))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit empty list turns suppression off: the same beat posts live.
|
||||||
|
s.roomSilent = config.AdventureConfig{RoomSilentTypes: []string{}}.RoomSilentSet()
|
||||||
|
postFact(t, s, token, AdvFact{GUID: "treasure_found:e:6", EventType: "treasure_found", Tier: "priority",
|
||||||
|
Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20,
|
||||||
|
Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()})
|
||||||
|
if len(*posted) != 1 {
|
||||||
|
t.Errorf("suppression off: %d posts, want 1", len(*posted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
||||||
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
||||||
// page is noindex with an og:image.
|
// page is noindex with an og:image.
|
||||||
@@ -249,7 +288,9 @@ func TestAdventureArtAndMeta(t *testing.T) {
|
|||||||
const token = "t"
|
const token = "t"
|
||||||
s, _ := newAdvServer(t, token)
|
s, _ := newAdvServer(t, token)
|
||||||
|
|
||||||
// Emblem endpoint returns SVG with the event's emoji.
|
// A bare event type still renders — that is what every story ingested before
|
||||||
|
// the card became guid-keyed has in its image_url column, and those links
|
||||||
|
// must not start 404ing.
|
||||||
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
||||||
areq.SetPathValue("type", "death.svg")
|
areq.SetPathValue("type", "death.svg")
|
||||||
arw := httptest.NewRecorder()
|
arw := httptest.NewRecorder()
|
||||||
@@ -264,18 +305,30 @@ func TestAdventureArtAndMeta(t *testing.T) {
|
|||||||
t.Errorf("art body missing emblem: %s", b)
|
t.Errorf("art body missing emblem: %s", b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingest sets the card image to the local emblem path.
|
// Ingest keys the card image on the GUID, not the event type: the renderer
|
||||||
|
// reads the fact behind the dispatch so it can name the zone and the level
|
||||||
|
// instead of drawing the identical emblem for every death.
|
||||||
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
||||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
t.Fatalf("ingest status = %d", rw.Code)
|
t.Fatalf("ingest status = %d", rw.Code)
|
||||||
}
|
}
|
||||||
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
||||||
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
|
if got == nil || got.ImageURL != "/adventure/art/death:abc:1000.svg" {
|
||||||
t.Errorf("story image = %q", got.ImageURL)
|
t.Errorf("story image = %q", got.ImageURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permalink page is noindex with an og:image.
|
// And that card carries the nouns.
|
||||||
|
greq := httptest.NewRequest("GET", "/adventure/art/death:abc:1000.svg", nil)
|
||||||
|
greq.SetPathValue("type", "death:abc:1000.svg")
|
||||||
|
grw := httptest.NewRecorder()
|
||||||
|
s.handleAdventureArt(grw, greq)
|
||||||
|
gb := grw.Body.String()
|
||||||
|
if !strings.Contains(gb, "Brannigan") || !strings.Contains(gb, "the Underforge") || !strings.Contains(gb, "level 4") {
|
||||||
|
t.Errorf("guid card missing the fact's nouns: %s", gb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permalink page is noindex with an og:image pointing at that same card.
|
||||||
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||||
preq.SetPathValue("guid", "death:abc:1000")
|
preq.SetPathValue("guid", "death:abc:1000")
|
||||||
prw := httptest.NewRecorder()
|
prw := httptest.NewRecorder()
|
||||||
@@ -284,7 +337,7 @@ func TestAdventureArtAndMeta(t *testing.T) {
|
|||||||
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
||||||
t.Error("permalink not noindex")
|
t.Error("permalink not noindex")
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
|
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death:abc:1000.svg"`) {
|
||||||
t.Errorf("permalink missing og:image; body=%s", body)
|
t.Errorf("permalink missing og:image; body=%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,8 +393,10 @@ func TestAdventureDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
||||||
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
|
// untemplated type no longer 400s — it publishes on the neutral fallback (see
|
||||||
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
|
// TestUnknownEventTypePublishes) — so what is at stake here is voice, not data
|
||||||
|
// loss: these four carry the anonymity mechanic, and the generic fallback would
|
||||||
|
// strip out the part that makes it work.
|
||||||
//
|
//
|
||||||
// It also pins the anonymity contract, which is the feature's whole social
|
// It also pins the anonymity contract, which is the feature's whole social
|
||||||
// engine: an unsigned contract must not name the buyer, and a survival must.
|
// engine: an unsigned contract must not name the buyer, and a survival must.
|
||||||
@@ -396,3 +451,191 @@ func TestRenderMischief(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenderCompanionHire pins the template whose absence was a live bug.
|
||||||
|
//
|
||||||
|
// gogobee has emitted companion_hire from `!expedition hire` since the combat-
|
||||||
|
// engine work landed (expedition_companion_cmd.go). Pete had no case for it, so
|
||||||
|
// every one of those dispatches 400'd, retried to peteclient's cap, and parked
|
||||||
|
// forever. Nothing surfaced the loss: the game logged a successful emit, the
|
||||||
|
// queue row just never sent.
|
||||||
|
//
|
||||||
|
// The unknown-type inversion (TestUnknownEventTypePublishes) means a repeat of
|
||||||
|
// this costs a thin card rather than a deleted event — but the template is still
|
||||||
|
// the point, and this test is what says so.
|
||||||
|
func TestRenderCompanionHire(t *testing.T) {
|
||||||
|
f := AdvFact{EventType: "companion_hire", Tier: "bulletin",
|
||||||
|
Subject: "Josie", ClassRace: "Cleric", Zone: "holymachina", Level: 14}
|
||||||
|
hl, lede, ok := renderAdventure(f)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("companion_hire did not render — this is the bug, do not re-break it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(hl, "cleric") {
|
||||||
|
t.Errorf("headline lost the seat Pete is filling: %q", hl)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lede, "Josie") || !strings.Contains(lede, "holymachina") {
|
||||||
|
t.Errorf("lede lost the leader or the zone: %q", lede)
|
||||||
|
}
|
||||||
|
// He is talking about himself here, like his duels. Third-person Pete filling
|
||||||
|
// in as a cleric reads as someone else reporting on him.
|
||||||
|
if !strings.Contains(lede, "I'm") && !strings.Contains(lede, "I ") {
|
||||||
|
t.Errorf("companion_hire should be first-person Pete: %q", lede)
|
||||||
|
}
|
||||||
|
// "needed a cleric", never "needed cleric".
|
||||||
|
if !strings.Contains(lede, "a cleric") {
|
||||||
|
t.Errorf("seat needs its article in the lede: %q", lede)
|
||||||
|
}
|
||||||
|
if lbl, _ := advEventMeta("companion_hire"); lbl == "Dispatch" {
|
||||||
|
t.Error("companion_hire has no permalink label")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing class must not produce "needed a ." — the fallback seat carries
|
||||||
|
// its own article.
|
||||||
|
bare := AdvFact{EventType: "companion_hire", Subject: "Josie"}
|
||||||
|
_, bareLede, ok := renderAdventure(bare)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("companion_hire with no class did not render")
|
||||||
|
}
|
||||||
|
if strings.Contains(bareLede, "a .") || strings.Contains(bareLede, "needed ") {
|
||||||
|
t.Errorf("empty class produced malformed prose: %q", bareLede)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownEventTypePublishes is the regression for the whole class of bug.
|
||||||
|
//
|
||||||
|
// An event type Pete has no template for must PUBLISH, not 400. A 400 is retried
|
||||||
|
// to peteclient's cap and then parked forever, so rejecting an unrecognised type
|
||||||
|
// does not defer the event — it deletes it, permanently, and that is how
|
||||||
|
// companion_hire went missing. The site can carry a thin card; it cannot recover
|
||||||
|
// a dispatch gogobee has given up on.
|
||||||
|
func TestUnknownEventTypePublishes(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, posted := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "brand_new_thing:abc:5000", EventType: "brand_new_thing",
|
||||||
|
Tier: "priority", // claims priority, and still must not interrupt Matrix
|
||||||
|
Subject: "Josie", Actors: []string{"Josie"}, Zone: "holymachina",
|
||||||
|
OccurredAt: 5000,
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("unknown event_type: status = %d, want 200 — a 400 parks the dispatch forever", rw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := storage.GetStoryByGUID("brand_new_thing:abc:5000")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatal("unknown event_type was not stored; the event is lost")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Headline+got.Lede, "Josie") {
|
||||||
|
t.Errorf("fallback dropped the subject: %q / %q", got.Headline, got.Lede)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untemplated types never post live, whatever tier they claim: a thin card on
|
||||||
|
// the site is cheap, a thin ping to everyone in the room is not. It still
|
||||||
|
// reaches Matrix via the daily digest.
|
||||||
|
if len(*posted) != 0 {
|
||||||
|
t.Errorf("untemplated priority fact posted live to Matrix: %+v", *posted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the operator can see what Pete needs to learn.
|
||||||
|
if AdvUnknownTypeCounts()["brand_new_thing"] == 0 {
|
||||||
|
t.Error("unknown type was not counted for the status page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownEventTypeUsesProse: the inversion is not a downgrade in practice.
|
||||||
|
// gogobee authors LLM prose from the fact's fields with no per-type switch
|
||||||
|
// (authorDispatch), so a type Pete has never heard of still arrives with a real
|
||||||
|
// headline and lede — and must be allowed to use them. The thin fallback is only
|
||||||
|
// for when the model is off or the prose-guard rejected the output.
|
||||||
|
func TestUnknownEventTypeUsesProse(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "another_new_thing:def:6000", EventType: "another_new_thing",
|
||||||
|
Tier: "bulletin", Subject: "Josie", Actors: []string{"Josie"},
|
||||||
|
OccurredAt: 6000,
|
||||||
|
Headline: "Josie has taken up beekeeping.",
|
||||||
|
Lede: "Not the news I expected today, but there she is, out behind the chapel with a smoker and a very calm expression.",
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("status = %d, want 200", rw.Code)
|
||||||
|
}
|
||||||
|
got, err := storage.GetStoryByGUID("another_new_thing:def:6000")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatal("story not stored")
|
||||||
|
}
|
||||||
|
if got.Headline != f.Headline {
|
||||||
|
t.Errorf("LLM prose was discarded for an unknown type: got %q", got.Headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownEventTypeStillGuarded: publishing an untemplated type must not
|
||||||
|
// weaken the name guards. The fact-guard rejection is still a 400, because a
|
||||||
|
// fact naming someone it did not authorise is genuinely invalid — unlike a type
|
||||||
|
// Pete simply hasn't learned to phrase.
|
||||||
|
func TestUnknownEventTypeStillGuarded(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "unknowable:evil:1", EventType: "unknowable",
|
||||||
|
Subject: "Josie", Actors: []string{"Brannigan"}, OccurredAt: 1,
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||||
|
t.Errorf("unguarded subject on an unknown type: status = %d, want 400", rw.Code)
|
||||||
|
}
|
||||||
|
if storage.IsGUIDSeen("unknowable:evil:1") {
|
||||||
|
t.Error("fact-guard rejection was stored anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPermalinkNamesTheRegion. Region is a fact field, never a story column — the
|
||||||
|
// story is Pete's words and they have no place for a where — so the permalink has
|
||||||
|
// to read it off the fact row it already loads for the run-report link. It was
|
||||||
|
// hardcoded empty with a `// reserved` comment for the whole life of the page.
|
||||||
|
//
|
||||||
|
// The second half matters as much: a multi-region zone is the only place a region
|
||||||
|
// exists, so a dispatch without one must not print an empty separator.
|
||||||
|
func TestPermalinkNamesTheRegion(t *testing.T) {
|
||||||
|
const token = "t"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
|
||||||
|
withRegion := AdvFact{
|
||||||
|
GUID: "zone_clear:reg:1000", EventType: "zone_clear", Tier: "bulletin",
|
||||||
|
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||||
|
Zone: "the Underforge", Region: "the Cinder Reach", Level: 14, OccurredAt: 1000,
|
||||||
|
}
|
||||||
|
without := AdvFact{
|
||||||
|
GUID: "zone_clear:noreg:1001", EventType: "zone_clear", Tier: "bulletin",
|
||||||
|
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||||
|
Zone: "the Underforge", Level: 14, OccurredAt: 1001,
|
||||||
|
}
|
||||||
|
for _, f := range []AdvFact{withRegion, without} {
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest %s = %d", f.GUID, rw.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
story := func(guid string) string {
|
||||||
|
req := httptest.NewRequest("GET", "/adventure/"+guid, nil)
|
||||||
|
req.SetPathValue("guid", guid)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
s.handleAdventureStory(rw, req)
|
||||||
|
if rw.Code != 200 {
|
||||||
|
t.Fatalf("permalink %s = %d", guid, rw.Code)
|
||||||
|
}
|
||||||
|
return rw.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(story(withRegion.GUID), "the Cinder Reach") {
|
||||||
|
t.Error("permalink does not name the region the fact carries")
|
||||||
|
}
|
||||||
|
// The template joins the region on with " · "; a regionless dispatch must not
|
||||||
|
// render a dangling one.
|
||||||
|
if body := story(without.GUID); strings.Contains(body, "Reported ") &&
|
||||||
|
strings.Contains(body, " · </p>") {
|
||||||
|
t.Error("a dispatch with no region printed an empty separator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "While you were away" — the one panel on the site that is about the reader.
|
||||||
|
//
|
||||||
|
// Everything else in the adventure section is the realm's news: the board, the
|
||||||
|
// Siege, the standings. This is the owner's own adventurer, and only what has
|
||||||
|
// happened to them since they last looked. It pairs with W6's push alerts and
|
||||||
|
// covers the gap those deliberately leave: the alerts are four opt-in categories
|
||||||
|
// chosen for being worth interrupting somebody over, while this catches
|
||||||
|
// everything, for people who would rather not be interrupted at all.
|
||||||
|
//
|
||||||
|
// It renders on /adventure page 1 only. The panel is present tense and page 2 of
|
||||||
|
// an archive is not where anybody looks for what just happened, which is the same
|
||||||
|
// rule the roster and the Siege strip already follow.
|
||||||
|
|
||||||
|
// awayCap bounds the panel. Six lines is a glance; a longer list is the trail on
|
||||||
|
// the adventurer's own page, which is where the "all of it" link goes.
|
||||||
|
const awayCap = 6
|
||||||
|
|
||||||
|
// awayView is the panel. Has is false in every case where there is nothing
|
||||||
|
// honest to show — not signed in, no adventurer, first ever visit, or simply
|
||||||
|
// nothing new — and the template renders nothing at all rather than an empty box
|
||||||
|
// announcing that nothing happened.
|
||||||
|
type awayView struct {
|
||||||
|
Has bool
|
||||||
|
Name string // the reader's own character
|
||||||
|
Since string // "3 hours", "2 days" — how long they were gone
|
||||||
|
Lines []awayLine
|
||||||
|
// HasMore says there is more than the cap, without saying how much more. The
|
||||||
|
// window query reads one row past the cap to learn this; an exact count would
|
||||||
|
// need a second query over the same window to tell somebody a number they are
|
||||||
|
// about to click past anyway.
|
||||||
|
HasMore bool
|
||||||
|
Token string // their adventurer page, where the rest of the trail is
|
||||||
|
}
|
||||||
|
|
||||||
|
// awayLine is one thing that happened, in the trail's own shape. Built from the
|
||||||
|
// fact rather than from the dispatch headline for the same reason buildTimeline
|
||||||
|
// is: a headline is a news sentence written to be shouted once, and six of them
|
||||||
|
// stacked in a panel read as shouting.
|
||||||
|
type awayLine struct {
|
||||||
|
Emoji string
|
||||||
|
Label string
|
||||||
|
Line string
|
||||||
|
When string // relative: this panel is about recency
|
||||||
|
Permalink string
|
||||||
|
Notable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// awayPanel builds the panel for whoever is asking, and stamps their visit.
|
||||||
|
//
|
||||||
|
// The stamp is written even when the panel comes back empty — even for a signed-in
|
||||||
|
// user with no adventurer at all — and that is deliberate: a clock that only
|
||||||
|
// advances when there is something to show would hand somebody their entire
|
||||||
|
// backlog on the day they finally rolled a character.
|
||||||
|
func (s *Server) awayPanel(r *http.Request) awayView {
|
||||||
|
if s.auth == nil {
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
u := s.auth.userFromRequest(r)
|
||||||
|
if u == nil {
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
from, first, err := storage.AdvVisitWindow(u.Sub, now)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("away: visit clock failed", "sub", u.Sub, "err", err)
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
// Never seen before. Their history is not news to them, and a first visit
|
||||||
|
// greeted by every death their character ever suffered is a worse welcome
|
||||||
|
// than no panel at all.
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ownership join, re-read on every request rather than cached anywhere —
|
||||||
|
// same discipline as the alert sender and the run report link. It fails closed
|
||||||
|
// on an opt-out and on a player gogobee has stopped pushing, both of which mean
|
||||||
|
// Pete cannot honestly say which adventurer is this reader's.
|
||||||
|
lp := buyerLocalpart(u)
|
||||||
|
if lp == "" {
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
name, ok := storage.AdvCharacterForOwner(lp)
|
||||||
|
if !ok {
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One extra row is fetched past the cap purely to answer "is there more",
|
||||||
|
// without a second COUNT query over the same window.
|
||||||
|
events, err := storage.EventsBySubjectSince(name, from, awayCap+1)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("away: dispatch lookup failed", "subject", name, "err", err)
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
if len(events) == 0 {
|
||||||
|
return awayView{}
|
||||||
|
}
|
||||||
|
|
||||||
|
v := awayView{Has: true, Name: name, Since: awaySince(now - from)}
|
||||||
|
if token, ok := storage.SelfToken(lp); ok {
|
||||||
|
v.Token = token
|
||||||
|
}
|
||||||
|
if len(events) > awayCap {
|
||||||
|
v.HasMore = true
|
||||||
|
events = events[:awayCap]
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
label, emoji := advEventMeta(e.EventType)
|
||||||
|
v.Lines = append(v.Lines, awayLine{
|
||||||
|
Emoji: emoji,
|
||||||
|
Label: label,
|
||||||
|
Line: timelineLine(name, e),
|
||||||
|
When: awayAgo(now - e.OccurredAt),
|
||||||
|
Permalink: s.advPermalink(e.GUID),
|
||||||
|
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" ||
|
||||||
|
e.EventType == "death",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaySince phrases the gap the panel covers. Rounded down, and it never claims
|
||||||
|
// less than an hour: the window is at least one session gap wide, and "since 34
|
||||||
|
// minutes ago" is a precision the clock behind it does not have.
|
||||||
|
func awaySince(secs int64) string {
|
||||||
|
switch d := time.Duration(secs) * time.Second; {
|
||||||
|
case d < 2*time.Hour:
|
||||||
|
return "an hour"
|
||||||
|
case d < 48*time.Hour:
|
||||||
|
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||||
|
case d < 14*24*time.Hour:
|
||||||
|
return fmt.Sprintf("%d days", int(d.Hours())/24)
|
||||||
|
default:
|
||||||
|
return "a while"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// awayAgo is a compact relative stamp for one line. Deliberately not the trail's
|
||||||
|
// "Jan 2, 2006": everything in this panel is recent by construction, and a date
|
||||||
|
// on it would make the reader do the subtraction themselves.
|
||||||
|
func awayAgo(secs int64) string {
|
||||||
|
switch d := time.Duration(secs) * time.Second; {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The "while you were away" panel. Two things are worth pinning: it never shows
|
||||||
|
// somebody else's adventurer, and its window survives a page refresh — the
|
||||||
|
// failure that would make the whole panel useless without breaking anything a
|
||||||
|
// unit test would normally notice.
|
||||||
|
|
||||||
|
// awayReq builds a request for /adventure as a signed-in user, or anonymously
|
||||||
|
// when sub is empty.
|
||||||
|
func awayReq(t *testing.T, s *Server, sub, username string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("GET", "/adventure", nil)
|
||||||
|
if sub != "" {
|
||||||
|
payload, _ := json.Marshal(SessionUser{
|
||||||
|
Sub: sub, Username: username, Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAwayOwner puts one adventurer on the board owned by localpart, the way the
|
||||||
|
// two real pushes do.
|
||||||
|
func seedAwayOwner(t *testing.T, localpart, character string) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := storage.ReplaceRoster([]storage.RosterEntry{{
|
||||||
|
Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle",
|
||||||
|
}}, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{
|
||||||
|
Localpart: localpart, Token: "tok-" + localpart,
|
||||||
|
}}, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAwayEvent(t *testing.T, guid, kind, subject string, at int64) {
|
||||||
|
t.Helper()
|
||||||
|
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||||||
|
GUID: guid, EventType: kind, Subject: subject, Zone: "holymachina",
|
||||||
|
OccurredAt: at,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayPanelIsSilentOnAFirstVisit. A brand-new row means "never seen before",
|
||||||
|
// and treating that as "away since the epoch" would greet somebody's first
|
||||||
|
// sign-in with every death their character ever suffered.
|
||||||
|
func TestAwayPanelIsSilentOnAFirstVisit(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
seedAwayOwner(t, "josie", "Josie")
|
||||||
|
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Hour).Unix())
|
||||||
|
|
||||||
|
if v := s.awayPanel(awayReq(t, s, "sub-1", "josie")); v.Has {
|
||||||
|
t.Errorf("first visit rendered a panel of %d lines; it must be silent", len(v.Lines))
|
||||||
|
}
|
||||||
|
// And the clock was still stamped, so the next visit has a window to read from.
|
||||||
|
from, first, err := storage.AdvVisitWindow("sub-1", time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first || from == 0 {
|
||||||
|
t.Errorf("visit clock not stamped on the first pass (from=%d first=%v)", from, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayPanelSurvivesARefresh is the reason adventure_visit has two columns.
|
||||||
|
// The naive one-column version shows the news, moves the stamp to now, and then
|
||||||
|
// renders an empty box over the same events the moment the reader reloads —
|
||||||
|
// which is exactly what somebody does after clicking into a dispatch and back.
|
||||||
|
func TestAwayPanelSurvivesARefresh(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
seedAwayOwner(t, "josie", "Josie")
|
||||||
|
|
||||||
|
// A visit two hours ago established the clock. Stamped directly rather than
|
||||||
|
// through awayPanel, because the panel reads the wall clock and this test is
|
||||||
|
// about what happens between two visits rather than inside one.
|
||||||
|
if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-2*time.Hour).Unix()); err != nil || !first {
|
||||||
|
t.Fatalf("seed visit: first=%v err=%v", first, err)
|
||||||
|
}
|
||||||
|
// Then something happened to Josie.
|
||||||
|
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix())
|
||||||
|
|
||||||
|
first := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||||
|
if !first.Has || len(first.Lines) != 1 {
|
||||||
|
t.Fatalf("panel = %+v, want one line about the death", first)
|
||||||
|
}
|
||||||
|
if first.Name != "Josie" {
|
||||||
|
t.Errorf("panel names %q, want Josie", first.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refresh. Same panel, not an empty one.
|
||||||
|
again := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||||
|
if !again.Has || len(again.Lines) != len(first.Lines) {
|
||||||
|
t.Errorf("refresh emptied the panel: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayPanelNeverShowsAnotherPlayersNews. The panel is keyed on a fact's
|
||||||
|
// character name, resolved through the owner join — the same join the alert
|
||||||
|
// sender uses, and the same failure-closed rule. A signed-in visitor who owns
|
||||||
|
// nothing must see nothing, never the realm's news relabelled as their own.
|
||||||
|
func TestAwayPanelNeverShowsAnotherPlayersNews(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
seedAwayOwner(t, "josie", "Josie")
|
||||||
|
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix())
|
||||||
|
|
||||||
|
// Anonymous: no panel, and no visit row to create either.
|
||||||
|
if v := s.awayPanel(awayReq(t, s, "", "")); v.Has {
|
||||||
|
t.Error("an anonymous visitor got a personal panel")
|
||||||
|
}
|
||||||
|
// Signed in, but owns no adventurer on the board.
|
||||||
|
if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has {
|
||||||
|
t.Errorf("a visitor with no adventurer got %+v", v)
|
||||||
|
}
|
||||||
|
// Second pass, now that their visit row exists — the branch that would fall
|
||||||
|
// through to a broadcast if the ownership join were ever treated as optional.
|
||||||
|
if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has {
|
||||||
|
t.Errorf("a visitor with no adventurer got %+v on their second visit", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayPanelCapsAndCounts: six lines is a glance, and the overflow has to be
|
||||||
|
// counted rather than silently dropped.
|
||||||
|
func TestAwayPanelCapsAndCounts(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
seedAwayOwner(t, "josie", "Josie")
|
||||||
|
|
||||||
|
if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-4*time.Hour).Unix()); err != nil || !first {
|
||||||
|
t.Fatalf("seed visit: first=%v err=%v", first, err)
|
||||||
|
}
|
||||||
|
base := time.Now().Add(-time.Hour).Unix()
|
||||||
|
for i := 0; i < awayCap+3; i++ {
|
||||||
|
seedAwayEvent(t, "boss_kill:"+string(rune('a'+i))+":1", "boss_kill", "Josie", base+int64(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||||
|
if len(v.Lines) != awayCap {
|
||||||
|
t.Errorf("panel drew %d lines, want the cap of %d", len(v.Lines), awayCap)
|
||||||
|
}
|
||||||
|
if !v.HasMore {
|
||||||
|
t.Error("overflow was not flagged; the extra events would read as if they never happened")
|
||||||
|
}
|
||||||
|
if v.Token != "tok-josie" {
|
||||||
|
t.Errorf("panel links to %q, want the reader's own adventurer page", v.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Tailwind purge trap, made loud.
|
||||||
|
//
|
||||||
|
// tailwind.config.js has input.css in its OWN content glob, so a hand-written
|
||||||
|
// component class survives the purge only if its literal name can be *extracted*
|
||||||
|
// from that file. A name that only ever appears glued to something else — the
|
||||||
|
// canonical case is a rule written solely as `.foo::before` — is not extractable
|
||||||
|
// and Tailwind drops the rule from output.css. Nothing errors. The page just
|
||||||
|
// renders unstyled, and it is invisible until somebody looks at that exact
|
||||||
|
// element on that exact page.
|
||||||
|
//
|
||||||
|
// That has now cost three phases: flagged twice, and actually bitten once when
|
||||||
|
// `.firsts-entry-zone::before` was silently dropped. The mitigation everybody
|
||||||
|
// reached for — "remember to grep output.css after make css" — is the discipline
|
||||||
|
// that failed, and it is worse than it looks because Tailwind ESCAPES class
|
||||||
|
// names in its output (`.text-[color:var(--warn)]` is written
|
||||||
|
// `.text-\[color\:var\(--warn\)\]`), so a naive grep for the literal name
|
||||||
|
// reports a false negative that looks exactly like a purge failure.
|
||||||
|
//
|
||||||
|
// So: a test. It needs no list to maintain — the list IS input.css — and it
|
||||||
|
// turns a silent styling failure into a red build.
|
||||||
|
//
|
||||||
|
// A Tailwind `safelist` was the other option and is worse: a list somebody has
|
||||||
|
// to remember to add to is the same failure mode one level up.
|
||||||
|
|
||||||
|
// cssClassInSelector matches a class name in a selector. The leading dot must
|
||||||
|
// not be preceded by an identifier character, so `1.5rem` in a declaration is
|
||||||
|
// never mistaken for a class.
|
||||||
|
var cssClassInSelector = regexp.MustCompile(`\.(-?[A-Za-z_][-\w]*)`)
|
||||||
|
|
||||||
|
// cssComment strips /* ... */ so a class name mentioned in prose can't be read
|
||||||
|
// as a declaration. Several of the component blocks have long explanatory
|
||||||
|
// comments that name other classes.
|
||||||
|
var cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||||||
|
|
||||||
|
// componentClasses returns every class name declared inside an `@layer
|
||||||
|
// components` block of input.css.
|
||||||
|
//
|
||||||
|
// It walks the file rather than regexing whole rules because a component block
|
||||||
|
// can contain nested at-rules (`@media`, `@supports`) and because a selector can
|
||||||
|
// be a list spanning several lines. The walk collects each *prelude* — the text
|
||||||
|
// between one brace and the next — and reads class names out of it. An at-rule
|
||||||
|
// prelude (`@media ...`) is skipped; a declaration body is never a prelude
|
||||||
|
// because it is followed by `}`, not `{`.
|
||||||
|
func componentClasses(t *testing.T, css string) []string {
|
||||||
|
t.Helper()
|
||||||
|
css = cssComment.ReplaceAllString(css, " ")
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
|
||||||
|
// Find each `@layer components` block by brace-counting from its opening
|
||||||
|
// brace, then walk only inside it.
|
||||||
|
const marker = "@layer components"
|
||||||
|
for idx := 0; ; {
|
||||||
|
i := strings.Index(css[idx:], marker)
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += idx
|
||||||
|
open := strings.Index(css[i:], "{")
|
||||||
|
if open < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
open += i
|
||||||
|
|
||||||
|
depth := 0
|
||||||
|
var prelude strings.Builder
|
||||||
|
end := len(css)
|
||||||
|
for j := open; j < len(css); j++ {
|
||||||
|
switch css[j] {
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
// depth 1 is the @layer's own brace; anything deeper opened on a
|
||||||
|
// prelude we have been buffering.
|
||||||
|
if depth > 1 {
|
||||||
|
sel := strings.TrimSpace(prelude.String())
|
||||||
|
if !strings.HasPrefix(sel, "@") {
|
||||||
|
for _, m := range cssClassInSelector.FindAllStringSubmatch(sel, -1) {
|
||||||
|
if !seen[m[1]] {
|
||||||
|
seen[m[1]] = true
|
||||||
|
out = append(out, m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prelude.Reset()
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
prelude.Reset()
|
||||||
|
if depth == 0 {
|
||||||
|
end = j
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
prelude.WriteByte(css[j])
|
||||||
|
}
|
||||||
|
if depth == 0 && j > open {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cssEscape renders a class name the way Tailwind writes it into output.css:
|
||||||
|
// every character outside [A-Za-z0-9_-] is backslash-escaped. This is the half
|
||||||
|
// of the check that a grep gets wrong.
|
||||||
|
func cssEscape(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
if r == '-' || r == '_' || (r >= '0' && r <= '9') ||
|
||||||
|
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r > 127 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectorPresent reports whether `.name` appears in the (minified) stylesheet
|
||||||
|
// as a selector rather than as a prefix of a longer class name. Tailwind's
|
||||||
|
// output has no line breaks, so the boundary check is the whole test.
|
||||||
|
func selectorPresent(css, name string) bool {
|
||||||
|
needle := "." + cssEscape(name)
|
||||||
|
for i := 0; ; {
|
||||||
|
j := strings.Index(css[i:], needle)
|
||||||
|
if j < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
j += i
|
||||||
|
i = j + 1
|
||||||
|
// A match must not be the head of a longer name: `.map` inside
|
||||||
|
// `.map-svg` ends on '-', which is an identifier character.
|
||||||
|
if k := j + len(needle); k < len(css) {
|
||||||
|
c := css[k]
|
||||||
|
if c == '-' || c == '_' || c == '\\' ||
|
||||||
|
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEveryComponentClassSurvivesThePurge is the whole point of this file. If it
|
||||||
|
// fails, run `make css` first — a stale output.css looks identical to a purged
|
||||||
|
// class from here, and that is deliberate: shipping a stylesheet that predates
|
||||||
|
// the rule you just wrote is the same bug wearing a different hat.
|
||||||
|
func TestEveryComponentClassSurvivesThePurge(t *testing.T) {
|
||||||
|
inRaw, err := os.ReadFile("static/css/input.css")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read input.css: %v", err)
|
||||||
|
}
|
||||||
|
outRaw, err := os.ReadFile("static/css/output.css")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read output.css: %v — run `make css`", err)
|
||||||
|
}
|
||||||
|
in, out := string(inRaw), string(outRaw)
|
||||||
|
|
||||||
|
classes := componentClasses(t, in)
|
||||||
|
if len(classes) < 50 {
|
||||||
|
t.Fatalf("only found %d component classes in input.css — the parser has stopped working, "+
|
||||||
|
"which would make this test pass for the wrong reason", len(classes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
for _, c := range classes {
|
||||||
|
if !selectorPresent(out, c) {
|
||||||
|
missing = append(missing, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
t.Errorf("%d class(es) declared in input.css's @layer components are absent from output.css: %s\n"+
|
||||||
|
"Either run `make css`, or the name is not extractable from input.css — a rule written only as "+
|
||||||
|
"`.foo::before` or only inside a nested selector cannot be extracted, and Tailwind purges it "+
|
||||||
|
"silently. Give it a plain `.foo { ... }` declaration (a custom property is enough).",
|
||||||
|
len(missing), strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurgeCheckCatchesAPseudoOnlyClass proves the check would have caught the
|
||||||
|
// W4 bug, using a synthetic pair rather than trusting that the real stylesheet
|
||||||
|
// happens to exercise the path. Without this, a parser that silently found
|
||||||
|
// nothing would leave the real test green forever.
|
||||||
|
func TestPurgeCheckCatchesAPseudoOnlyClass(t *testing.T) {
|
||||||
|
in := `@layer components {
|
||||||
|
/* a comment naming .decoy-class, which must not be collected */
|
||||||
|
.kept { color: red; }
|
||||||
|
.pseudo-only::before { content: ""; }
|
||||||
|
@media (min-width: 40rem) {
|
||||||
|
.nested { display: none; }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
got := componentClasses(t, in)
|
||||||
|
want := []string{"kept", "nested", "pseudo-only"}
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("componentClasses = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailwind's output as it would be if `.pseudo-only` were not extractable.
|
||||||
|
out := `.kept{color:red}.nested-thing{display:block}`
|
||||||
|
if !selectorPresent(out, "kept") {
|
||||||
|
t.Error("kept should be present")
|
||||||
|
}
|
||||||
|
if selectorPresent(out, "pseudo-only") {
|
||||||
|
t.Error("pseudo-only should be reported missing — this is the W4 bug")
|
||||||
|
}
|
||||||
|
// The boundary check: `.nested` must not match inside `.nested-thing`.
|
||||||
|
if selectorPresent(out, "nested") {
|
||||||
|
t.Error("nested matched the prefix of .nested-thing — the boundary check is broken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurgeCheckComparesEscapedForms is the W6 lesson as a test: a raw-name grep
|
||||||
|
// reports a false negative on any class Tailwind had to escape, which reads
|
||||||
|
// exactly like a purge failure and once cost a session's time "fixing" a class
|
||||||
|
// that was never broken.
|
||||||
|
func TestPurgeCheckComparesEscapedForms(t *testing.T) {
|
||||||
|
out := `.text-\[color\:var\(--warn\)\]{color:var(--warn)}`
|
||||||
|
if !selectorPresent(out, "text-[color:var(--warn)]") {
|
||||||
|
t.Error("escaped class reported missing — the check must escape before comparing")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, ".text-[color:var(--warn)]") {
|
||||||
|
t.Error("fixture is wrong: the raw form should not appear in Tailwind output")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const pageSize = 24
|
|||||||
// StoryView is the trimmed-down record used in templates.
|
// StoryView is the trimmed-down record used in templates.
|
||||||
type StoryView struct {
|
type StoryView struct {
|
||||||
ID int64
|
ID int64
|
||||||
|
GUID string
|
||||||
Headline string
|
Headline string
|
||||||
Lede string
|
Lede string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
@@ -29,11 +30,14 @@ type StoryView struct {
|
|||||||
Channel string // channel slug; also the theme key
|
Channel string // channel slug; also the theme key
|
||||||
ReadMins int // estimated reading time in minutes; 0 = unknown (no chip)
|
ReadMins int // estimated reading time in minutes; 0 = unknown (no chip)
|
||||||
Views int // all-time reader-mode opens; 0 = none yet (no badge)
|
Views int // all-time reader-mode opens; 0 = none yet (no badge)
|
||||||
|
Accent string // adventure only: the event family's colour, "" for everything else
|
||||||
|
Ceremony bool // adventure only: this is a realm-first and gets the ribbon
|
||||||
}
|
}
|
||||||
|
|
||||||
func toView(s storage.Story) StoryView {
|
func toView(s storage.Story) StoryView {
|
||||||
return StoryView{
|
return StoryView{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
|
GUID: s.GUID,
|
||||||
Headline: s.Headline,
|
Headline: s.Headline,
|
||||||
Lede: s.Lede,
|
Lede: s.Lede,
|
||||||
ImageURL: s.ImageURL,
|
ImageURL: s.ImageURL,
|
||||||
@@ -73,6 +77,44 @@ func decorate(groups ...[]StoryView) {
|
|||||||
g[i].Views = views[g[i].ID]
|
g[i].Views = views[g[i].ID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
decorateAdventure(groups...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorateAdventure tints adventure cards by what they are: the event family's
|
||||||
|
// accent on the border, and the realm-first ribbon on a first-ever.
|
||||||
|
//
|
||||||
|
// The information was always there — gogobee computes the rarity of a find and
|
||||||
|
// whether a clear is the realm's first, and both were being spent on a sentence
|
||||||
|
// and then thrown away. A feed where a legendary hoard and a routine repeat look
|
||||||
|
// identical is throwing away the game's own sense of occasion.
|
||||||
|
//
|
||||||
|
// One batched read over the guids of the adventure cards only, skipped entirely
|
||||||
|
// on a page with none — which is most pages. Best-effort like the rest of
|
||||||
|
// decorate: a miss leaves the default border, not a broken card.
|
||||||
|
func decorateAdventure(groups ...[]StoryView) {
|
||||||
|
var guids []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, v := range g {
|
||||||
|
if v.Channel == "adventure" && v.GUID != "" && !seen[v.GUID] {
|
||||||
|
seen[v.GUID] = true
|
||||||
|
guids = append(guids, v.GUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(guids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
facets := storage.AdventureEventFacets(guids)
|
||||||
|
for _, g := range groups {
|
||||||
|
for i := range g {
|
||||||
|
ev, ok := facets[g[i].GUID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g[i].Accent, g[i].Ceremony = advCardAccent(ev.EventType, ev.Tier, ev.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readMinutes turns a character count into a rounded minutes-to-read estimate,
|
// readMinutes turns a character count into a rounded minutes-to-read estimate,
|
||||||
@@ -104,6 +146,7 @@ type pageData struct {
|
|||||||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
||||||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||||||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||||||
|
AdvEnabled bool // the adventure section is configured (shows its alert categories in settings)
|
||||||
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||||||
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
|
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
|
||||||
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
|
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
|
||||||
@@ -126,6 +169,17 @@ type channelPage struct {
|
|||||||
Roster []RosterView
|
Roster []RosterView
|
||||||
RosterStale bool
|
RosterStale bool
|
||||||
ShowRoster bool
|
ShowRoster bool
|
||||||
|
|
||||||
|
// Siege is the war room, summarised into a strip at the top of the section.
|
||||||
|
// Same page-1-only rule as the roster and for the same reason. It renders
|
||||||
|
// even with nothing camped, because the link to the history is the other half
|
||||||
|
// of what makes a live Siege feel like it counts.
|
||||||
|
Siege SiegeView
|
||||||
|
|
||||||
|
// Away is the signed-in owner's "while you were away" panel: what happened to
|
||||||
|
// their own adventurer since their last visit. Zero for everyone else, and
|
||||||
|
// zero for an owner who has missed nothing — see awayPanel.
|
||||||
|
Away awayView
|
||||||
}
|
}
|
||||||
|
|
||||||
type indexPage struct {
|
type indexPage struct {
|
||||||
@@ -176,6 +230,7 @@ func (s *Server) base(r *http.Request) pageData {
|
|||||||
PostingEnabled: s.postingEnabled,
|
PostingEnabled: s.postingEnabled,
|
||||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||||
|
AdvEnabled: s.adv.Enabled,
|
||||||
TTS: template.JS("null"),
|
TTS: template.JS("null"),
|
||||||
}
|
}
|
||||||
if s.tts != nil {
|
if s.tts != nil {
|
||||||
@@ -353,6 +408,8 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
|||||||
if ch.Slug == "adventure" && page == 1 {
|
if ch.Slug == "adventure" && page == 1 {
|
||||||
data.Roster, data.RosterStale, _ = s.roster()
|
data.Roster, data.RosterStale, _ = s.roster()
|
||||||
data.ShowRoster = true
|
data.ShowRoster = true
|
||||||
|
data.Siege = s.siege()
|
||||||
|
data.Away = s.awayPanel(r)
|
||||||
}
|
}
|
||||||
s.render(w, "channel", data)
|
s.render(w, "channel", data)
|
||||||
}
|
}
|
||||||
@@ -662,6 +719,44 @@ var funcs = template.FuncMap{
|
|||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
},
|
},
|
||||||
|
// euro formats a whole-euro price with thousands separators. The money confirm
|
||||||
|
// in the browser already does this via toLocaleString, and a button reading
|
||||||
|
// "€45000" above a dialog reading "€45,000" looks like two different prices.
|
||||||
|
"euro": func(n int) string {
|
||||||
|
s := strconv.Itoa(n)
|
||||||
|
neg := strings.HasPrefix(s, "-")
|
||||||
|
if neg {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
for i := len(s) - 3; i > 0; i -= 3 {
|
||||||
|
s = s[:i] + "," + s[i:]
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
s = "-" + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
// untilUnix is timeAgo's mirror: how long is LEFT, for a deadline the reader
|
||||||
|
// can still act on. Rounded down deliberately — a window with 47 hours in it
|
||||||
|
// says "1 day left", which is the safe way to be wrong about a deadline.
|
||||||
|
"untilUnix": func(unix int64) string {
|
||||||
|
if unix <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := time.Until(time.Unix(unix, 0))
|
||||||
|
switch {
|
||||||
|
case d <= 0:
|
||||||
|
return "closed"
|
||||||
|
case d < time.Hour:
|
||||||
|
return "less than an hour left"
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh left", int(d.Hours()))
|
||||||
|
case d < 48*time.Hour:
|
||||||
|
return "1 day left"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d days left", int(d.Hours())/24)
|
||||||
|
}
|
||||||
|
},
|
||||||
"timeAgo": func(t time.Time) string {
|
"timeAgo": func(t time.Time) string {
|
||||||
d := time.Since(t)
|
d := time.Since(t)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The action queue's web seam — the first verbs the web can play, as opposed to
|
||||||
|
// the equip queue's dressing-up.
|
||||||
|
//
|
||||||
|
// Two audiences, same shape as equip and mischief. A signed-in owner clicks
|
||||||
|
// "Pull out" on their own adventurer page or "Take your bout" on the war room;
|
||||||
|
// gogobee hits the bearer-authed pair, polling pending orders and pushing a
|
||||||
|
// verdict. Pete runs no game rule: it records that somebody asked, and renders
|
||||||
|
// what gogobee answered. The UI says "asked for" and never claims it landed.
|
||||||
|
//
|
||||||
|
// The character is resolved from the SESSION, never from the request. A session
|
||||||
|
// maps to exactly one localpart and a localpart to exactly one adventurer, so
|
||||||
|
// there is nothing for the client to name and therefore nothing to forge — the
|
||||||
|
// equip queue has to take an item id and a slot off the wire and re-resolve them;
|
||||||
|
// this one has no such surface at all.
|
||||||
|
|
||||||
|
// advOrderBurstWindow / advOrderBurstMax blunt a stuck mouse button. The real
|
||||||
|
// gates are gogobee's — one extraction ends the run, one bout per day — and the
|
||||||
|
// pending-order guard below stops the common double-click outright.
|
||||||
|
const (
|
||||||
|
advOrderBurstWindow = time.Hour
|
||||||
|
advOrderBurstMax = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// advOrderReq is the browser's request: the verb, and for the three verbs that
|
||||||
|
// take arguments, which zone / which loadout / how many days. See the file
|
||||||
|
// comment on why nothing here identifies the character.
|
||||||
|
//
|
||||||
|
// None of these fields is trusted. Each is looked up in the owner's OWN offer
|
||||||
|
// list — the one gogobee pushed onto their private self-detail row — and the
|
||||||
|
// order stores what was found there, not what was sent. So a forged zone id
|
||||||
|
// resolves to nothing and is refused before an order exists.
|
||||||
|
type advOrderReq struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
Loadout string `json:"loadout,omitempty"`
|
||||||
|
Days int `json:"days,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdvOrder places a pending action for the signed-in owner. It asserts what
|
||||||
|
// Pete can honestly know — the viewer is signed in, and gogobee has pushed a
|
||||||
|
// self-detail row for them, which is gogobee's own proof that this person has an
|
||||||
|
// adventurer. Everything about whether the action is legal *right now* is
|
||||||
|
// gogobee's, at verdict time; the pre-checks here only produce a better message
|
||||||
|
// than a verdict thirty seconds later would.
|
||||||
|
func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owner := buyerLocalpart(u)
|
||||||
|
if owner == "" {
|
||||||
|
writeAdvOrderError(w, http.StatusConflict, "please sign in again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req advOrderReq
|
||||||
|
if !decodeStateBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch req.Action {
|
||||||
|
case storage.AdvActionExtract, storage.AdvActionSiegeJoin,
|
||||||
|
storage.AdvActionExpedition, storage.AdvActionResume, storage.AdvActionBabysit,
|
||||||
|
storage.AdvActionAbandon, storage.AdvActionLeave, storage.AdvActionBabysitCancel:
|
||||||
|
default:
|
||||||
|
writeAdvOrderError(w, http.StatusBadRequest, "bad action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership. The self-detail row is gogobee's own owner<->adventurer proof, the
|
||||||
|
// same join the who page's private panels and the alert sender use. No row means
|
||||||
|
// this account has no adventurer — or gogobee has stopped pushing, in which case
|
||||||
|
// an order it can't attribute is not one we should queue.
|
||||||
|
token, ok := storage.SelfToken(owner)
|
||||||
|
if !ok {
|
||||||
|
writeAdvOrderError(w, http.StatusForbidden, "no adventurer on the board for this account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// One outstanding order per verb. Two queued extracts would apply in sequence
|
||||||
|
// and the second would answer "no expedition to leave" — a rejection for
|
||||||
|
// something that worked, which is the worst thing this strip could say.
|
||||||
|
if pending, err := storage.HasPendingAdvOrder(u.Sub, req.Action); err != nil {
|
||||||
|
slog.Error("orders: pending lookup", "err", err)
|
||||||
|
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
} else if pending {
|
||||||
|
writeAdvOrderError(w, http.StatusConflict, "already asked — waiting on the game box")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
since := time.Now().Add(-advOrderBurstWindow).Unix()
|
||||||
|
if n, err := storage.CountAdvOrdersSince(u.Sub, since); err != nil {
|
||||||
|
slog.Error("orders: burst count", "err", err)
|
||||||
|
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
} else if n >= advOrderBurstMax {
|
||||||
|
writeAdvOrderError(w, http.StatusTooManyRequests, "slow down, too many requests in a short while")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The roster lookup is for the character name the order carries; the one
|
||||||
|
// surviving pre-check below is courtesy only. Anything read here is Pete's
|
||||||
|
// snapshot copy, up to two minutes behind the game box, so it is never
|
||||||
|
// authoritative and is only allowed the last word where being two minutes late
|
||||||
|
// cannot make it wrong.
|
||||||
|
characterName := ""
|
||||||
|
entry, haveEntry, err := storage.RosterEntryByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("orders: roster lookup", "err", err)
|
||||||
|
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if haveEntry {
|
||||||
|
characterName = entry.Name
|
||||||
|
}
|
||||||
|
// No pre-check on extract, deliberately, and it is the same call abandon and
|
||||||
|
// leave make in resolveAdvOrderParams: the mark's status is up to two minutes
|
||||||
|
// stale here and a Matrix departure can outrun the roster push, so "reads idle"
|
||||||
|
// would refuse a run gogobee would happily have ended. The cost accepted is
|
||||||
|
// that a genuine mistake comes back as rejected_not_running rather than as an
|
||||||
|
// instant refusal, which is the honest answer anyway.
|
||||||
|
if req.Action == storage.AdvActionSiegeJoin {
|
||||||
|
// This one stays, because it is not a personal status: whether a boss is
|
||||||
|
// camped outside town is a town-wide fact on a day-or-longer clock, so a
|
||||||
|
// two-minute-old copy is almost never wrong about it. Note the known/active
|
||||||
|
// split — no snapshot at all must queue the order (a fresh deploy must not
|
||||||
|
// have a dead button); only a snapshot that positively says active=0 refuses.
|
||||||
|
active, known, err := storage.SiegeIsCamped()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("orders: siege lookup", "err", err)
|
||||||
|
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if known && !active {
|
||||||
|
writeAdvOrderError(w, http.StatusConflict, "no Siege is camped outside town")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the verb's arguments against this owner's own offers. Everything
|
||||||
|
// this returns came out of gogobee's push, so the stored order can only ever
|
||||||
|
// name a zone, a loadout and a price the game itself quoted to this player.
|
||||||
|
params, msg := resolveAdvOrderParams(owner, token, req)
|
||||||
|
if msg != "" {
|
||||||
|
writeAdvOrderError(w, http.StatusConflict, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action, params)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("orders: insert order", "err", err)
|
||||||
|
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("orders: action placed", "guid", order.GUID, "owner", owner, "action", req.Action)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAdvOrderParams turns the browser's arguments into the stored ones by
|
||||||
|
// looking each up in the owner's pushed offer list, and returns the reason to
|
||||||
|
// refuse when it cannot. Five of the eight verbs take no arguments and resolve to
|
||||||
|
// nil — but babysit_cancel still comes through here, because the offer row is
|
||||||
|
// the one place Pete can see that there is no sitter to dismiss.
|
||||||
|
//
|
||||||
|
// Note what W5a's "Pete has never heard about it" asymmetry does NOT need to
|
||||||
|
// become here. There is no such state to defer on: the detail row this reads is
|
||||||
|
// the same row SelfToken already found, so by the time we get here it exists.
|
||||||
|
// What a gogobee too old to push offers produces is an EMPTY offer list, and
|
||||||
|
// then the page renders no picker at all — so there is no dead button to protect
|
||||||
|
// against, only forged arguments to refuse.
|
||||||
|
func resolveAdvOrderParams(owner, token string, req advOrderReq) (*storage.AdvOrderParams, string) {
|
||||||
|
switch req.Action {
|
||||||
|
case storage.AdvActionExtract, storage.AdvActionSiegeJoin:
|
||||||
|
return nil, ""
|
||||||
|
case storage.AdvActionAbandon, storage.AdvActionLeave:
|
||||||
|
// No snapshot pre-check for either, deliberately, and it is the same call
|
||||||
|
// W5a made for extract: the board is up to two minutes stale, so the only
|
||||||
|
// thing Pete could test — "the mark reads idle" — would refuse actions the
|
||||||
|
// game would have allowed. Abandon is worse than extract in that respect,
|
||||||
|
// because an *extracted* expedition is still abandonable while its owner
|
||||||
|
// reads as standing in town. gogobee answers rejected_not_running /
|
||||||
|
// rejected_not_leader / rejected_is_leader, and the strip shows it.
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
detail, haveDetail, err := storage.PlayerDetailByOwner(owner, token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("orders: detail lookup", "err", err)
|
||||||
|
return nil, "couldn't read your adventurer just now"
|
||||||
|
}
|
||||||
|
if !haveDetail {
|
||||||
|
// Only reachable if the row went away between SelfToken and here — the
|
||||||
|
// roster push replaces the whole table. Refuse rather than guess.
|
||||||
|
return nil, "couldn't read your adventurer just now"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Action {
|
||||||
|
case storage.AdvActionExpedition:
|
||||||
|
if req.Zone == "" {
|
||||||
|
return nil, "pick somewhere to go first"
|
||||||
|
}
|
||||||
|
if len(detail.Zones) == 0 {
|
||||||
|
// An empty offer list usually means they are already out there, but Pete
|
||||||
|
// cannot tell that from a game box too old to push offers at all, so say
|
||||||
|
// only what was actually seen. Unreachable from the page either way — with
|
||||||
|
// no offers the picker doesn't render — so this is a hand-crafted request.
|
||||||
|
return nil, "nowhere is on offer for you right now"
|
||||||
|
}
|
||||||
|
for _, z := range detail.Zones {
|
||||||
|
if z.ID != req.Zone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, l := range z.Loadouts {
|
||||||
|
if l.Key == req.Loadout {
|
||||||
|
return &storage.AdvOrderParams{Zone: z.ID, Loadout: l.Key}, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "that isn't a loadout for that zone"
|
||||||
|
}
|
||||||
|
return nil, "that zone isn't open to you"
|
||||||
|
|
||||||
|
case storage.AdvActionResume:
|
||||||
|
if detail.Resume == nil {
|
||||||
|
return nil, "there's no expedition waiting for you"
|
||||||
|
}
|
||||||
|
for _, l := range detail.Resume.Loadouts {
|
||||||
|
if l.Key == req.Loadout {
|
||||||
|
return &storage.AdvOrderParams{Loadout: l.Key}, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "that isn't a loadout for that zone"
|
||||||
|
|
||||||
|
case storage.AdvActionBabysit:
|
||||||
|
if req.Days != 7 && req.Days != 30 {
|
||||||
|
return nil, "the sitter works by the week or by the month"
|
||||||
|
}
|
||||||
|
if detail.Babysit != nil && detail.Babysit.Active {
|
||||||
|
return nil, "a sitter is already looking after your camp"
|
||||||
|
}
|
||||||
|
return &storage.AdvOrderParams{Days: req.Days}, ""
|
||||||
|
|
||||||
|
case storage.AdvActionBabysitCancel:
|
||||||
|
// The mirror of the check above, and the one W9 verb where the snapshot
|
||||||
|
// really does contradict the request: a sitter's engagement is a fact about
|
||||||
|
// the character, not about where they are standing, so it does not go stale
|
||||||
|
// the way "on an expedition" does. A missing offer is still not a refusal —
|
||||||
|
// that is a gogobee too old to push one, not a player without a sitter.
|
||||||
|
if detail.Babysit != nil && !detail.Babysit.Active {
|
||||||
|
return nil, "there's no sitter to dismiss"
|
||||||
|
}
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
return nil, "bad action"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdvOrders returns the signed-in owner's own recent actions for the status
|
||||||
|
// strip, newest first. Scoped to their OIDC subject.
|
||||||
|
func (s *Server) handleAdvOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.AdvOrdersByOwner(u.Sub, 10)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("orders: by owner", "err", err)
|
||||||
|
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.AdvOrder{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||||
|
|
||||||
|
// advOrderPollLimit caps one poll, matching the equip and mischief seams.
|
||||||
|
const advOrderPollLimit = 50
|
||||||
|
|
||||||
|
// handleAdvOrdersPending is gogobee's poll: every action still waiting. Like the
|
||||||
|
// seams beside it there is no stale-reoffer window — a gogobee that dies mid-apply
|
||||||
|
// leaves the order pending to be offered again, and its guid ledger makes the
|
||||||
|
// replay a no-op.
|
||||||
|
func (s *Server) handleAdvOrdersPending(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.PendingAdvOrders(advOrderPollLimit)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("orders: pending", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.AdvOrder{}
|
||||||
|
}
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advOrderVerdict is gogobee's answer on an order: the terminal status and a
|
||||||
|
// human note to render.
|
||||||
|
type advOrderVerdict struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdvOrderVerdict files gogobee's verdict against a pending order.
|
||||||
|
// Idempotent: gogobee's poll loop retries, so the same verdict can arrive more
|
||||||
|
// than once and only the first moves the order. An unknown guid is a 400 — under
|
||||||
|
// this seam's contract that parks the row for a human rather than retrying
|
||||||
|
// forever against a row that will never exist.
|
||||||
|
func (s *Server) handleAdvOrderVerdict(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v advOrderVerdict
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.GUID == "" {
|
||||||
|
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := storage.ResolveAdvOrder(v.GUID, v.Status, v.Detail)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchAdvOrder) {
|
||||||
|
slog.Error("orders: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status)
|
||||||
|
http.Error(w, "no such order", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, storage.ErrBadAdvVerdict) {
|
||||||
|
slog.Error("orders: verdict outside the terminal set", "guid", v.GUID, "status", v.Status)
|
||||||
|
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// A storage failure, not a bad request. 400 here would park a perfectly
|
||||||
|
// resolvable order forever on a transient database error; 500 gets it
|
||||||
|
// retried on gogobee's next poll.
|
||||||
|
slog.Error("orders: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("orders: action resolved", "guid", order.GUID, "action", order.Action, "status", order.Status)
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAdvOrderError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// W5: the action queue's web seam. Two contracts, same shape as the equip queue's
|
||||||
|
// tests — the owner half must be unable to act for anybody but itself, and the
|
||||||
|
// gogobee half is a bearer-authed, idempotent pending/verdict pair.
|
||||||
|
|
||||||
|
// seedActions stands up a board and a private detail row owned by `owner`, which
|
||||||
|
// together are gogobee's proof that this account has an adventurer. `status` is
|
||||||
|
// the roster status the mark carries ("expedition" or "idle"), because the
|
||||||
|
// extract pre-check reads it.
|
||||||
|
func seedActions(t *testing.T, owner, status string) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", status, "holymachina")
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: owner, Token: "tok-josie",
|
||||||
|
}}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed detail = %d", w.Code)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeAction(t *testing.T, s *Server, username, action string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := as(t, s, username, "POST", "/api/adventure/order", advOrderReq{Action: action})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdvOrder(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActionOrderNamesNoCharacter is the reason this seam has a smaller attack
|
||||||
|
// surface than the equip queue's: nothing in the request identifies an
|
||||||
|
// adventurer, so there is no id to forge. The order that lands must be attributed
|
||||||
|
// to the session's own localpart and its own token, whatever the body said.
|
||||||
|
func TestActionOrderNamesNoCharacter(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
|
||||||
|
// A body carrying extra fields — a token, a localpart — must change nothing:
|
||||||
|
// the handler reads only Action off it.
|
||||||
|
r := as(t, s, "holymachina", "POST", "/api/adventure/order", map[string]any{
|
||||||
|
"action": "extract", "token": "tok-somebody-else", "owner_localpart": "someone",
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdvOrder(w, r)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("order = %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got storage.AdvOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got.OwnerLocalpart != "holymachina" {
|
||||||
|
t.Fatalf("owner = %q, want the session's localpart", got.OwnerLocalpart)
|
||||||
|
}
|
||||||
|
if got.Token != "tok-josie" {
|
||||||
|
t.Fatalf("token = %q, want the token resolved from the session, not the body", got.Token)
|
||||||
|
}
|
||||||
|
if got.Status != storage.AdvOrderPending {
|
||||||
|
t.Fatalf("status = %q, want pending — Pete never claims an action landed", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActionOrderNeedsAnAdventurer: a signed-in visitor with no self-detail row
|
||||||
|
// has no adventurer for gogobee to act on. Queuing the order anyway would file
|
||||||
|
// something gogobee can only answer with a rejection.
|
||||||
|
func TestActionOrderNeedsAnAdventurer(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
if w := placeAction(t, s, "stranger", "extract"); w.Code != 403 {
|
||||||
|
t.Fatalf("order without an adventurer = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnlyOneOutstandingOrderPerVerb. Two queued extracts apply in sequence and
|
||||||
|
// the second answers "you weren't on an expedition" — a rejection for something
|
||||||
|
// that worked, which is the worst thing the strip could say. The guard is per
|
||||||
|
// verb, so a pending extract must not block a Siege bout.
|
||||||
|
func TestOnlyOneOutstandingOrderPerVerb(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
postSiege(t, s, "tok", liveSiege(time.Now().Unix(), 800))
|
||||||
|
|
||||||
|
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
|
||||||
|
t.Fatalf("first extract = %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w := placeAction(t, s, "holymachina", "extract")
|
||||||
|
if w.Code != 409 {
|
||||||
|
t.Fatalf("second extract = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 {
|
||||||
|
t.Fatalf("bout blocked by a pending extract = %d (%s); the guard is per verb", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnlyTownWideFactsArePreChecked. Pete's copy of the board is up to two
|
||||||
|
// minutes behind the game box, so what it may refuse locally turns on whether
|
||||||
|
// being two minutes late could make the answer wrong. A personal status can:
|
||||||
|
// somebody who set out over Matrix still reads as idle here, and refusing their
|
||||||
|
// extract would deny a run gogobee would have ended. A boss camped outside town
|
||||||
|
// cannot: that is town-wide and runs on a day-or-longer clock.
|
||||||
|
func TestOnlyTownWideFactsArePreChecked(t *testing.T) {
|
||||||
|
// Idle mark: extract goes through anyway, and rejected_not_running is the
|
||||||
|
// answer if the mark really was standing in town.
|
||||||
|
s := seedActions(t, "holymachina", "idle")
|
||||||
|
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
|
||||||
|
t.Fatalf("extract while idle = %d, want it queued (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Siege pushed at all: unknown, not "inactive". Pete has never heard from
|
||||||
|
// gogobee about a boss, and refusing on that would make the button dead on a
|
||||||
|
// fresh deploy. It must go through and let gogobee answer.
|
||||||
|
if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 {
|
||||||
|
t.Fatalf("bout with no siege snapshot at all = %d, want it queued", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A snapshot that positively says no boss is camped: refuse.
|
||||||
|
s2 := seedActions(t, "holymachina", "idle")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
postSiege(t, s2, "tok", siegePush{SnapshotAt: now, Siege: storage.Siege{Active: false}})
|
||||||
|
if w := placeAction(t, s2, "holymachina", "siege_join"); w.Code != 409 {
|
||||||
|
t.Fatalf("bout with no boss camped = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionOrderRejectsAnUnknownVerb(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
if w := placeAction(t, s, "holymachina", "sell_house"); w.Code != 400 {
|
||||||
|
t.Fatalf("unknown action = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActionOrdersAreScopedToTheirOwner: the strip is read back by OIDC subject.
|
||||||
|
// `as` signs every session as sub-1, so this drives the storage layer directly to
|
||||||
|
// prove the scoping rather than pretending two sessions exist.
|
||||||
|
func TestActionOrdersAreScopedToTheirOwner(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
|
||||||
|
t.Fatalf("place = %d", w.Code)
|
||||||
|
}
|
||||||
|
if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract, nil); err != nil {
|
||||||
|
t.Fatalf("insert other: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := as(t, s, "holymachina", "GET", "/api/adventure/orders", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdvOrders(w, r)
|
||||||
|
var got []storage.AdvOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].OwnerLocalpart != "holymachina" {
|
||||||
|
t.Fatalf("orders = %+v, want only the signed-in owner's", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActionVerdictIsIdempotent: gogobee's poll loop retries, so the same verdict
|
||||||
|
// arrives more than once and only the first may move the order. A second verdict
|
||||||
|
// overwriting the first would let a re-offer's "no expedition to leave" replace
|
||||||
|
// the "done" that was true.
|
||||||
|
func TestActionVerdictIsIdempotent(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
w := placeAction(t, s, "holymachina", "extract")
|
||||||
|
var order storage.AdvOrder
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &order)
|
||||||
|
|
||||||
|
first := postVerdict(t, s, "tok", advOrderVerdict{
|
||||||
|
GUID: order.GUID, Status: storage.AdvOrderApplied, Detail: "Out on day 3.",
|
||||||
|
})
|
||||||
|
if first.Code != 200 {
|
||||||
|
t.Fatalf("verdict = %d (%s)", first.Code, first.Body.String())
|
||||||
|
}
|
||||||
|
second := postVerdict(t, s, "tok", advOrderVerdict{
|
||||||
|
GUID: order.GUID, Status: storage.AdvRejectedNotRunning, Detail: "no run",
|
||||||
|
})
|
||||||
|
if second.Code != 200 {
|
||||||
|
t.Fatalf("retried verdict = %d, want a quiet 200", second.Code)
|
||||||
|
}
|
||||||
|
got, err := storage.AdvOrderByGUID(order.GUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != storage.AdvOrderApplied || !strings.Contains(got.Detail, "day 3") {
|
||||||
|
t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActionWireNeedsTheBearerToken: the poll and the verdict are gogobee's, and
|
||||||
|
// the pending list names every player who has asked for something.
|
||||||
|
func TestActionWireNeedsTheBearerToken(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
placeAction(t, s, "holymachina", "extract")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/adventure/orders/pending", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdvOrdersPending(w, req)
|
||||||
|
if w.Code != 401 {
|
||||||
|
t.Fatalf("unauthed poll = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/api/adventure/orders/pending", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer tok")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
s.handleAdvOrdersPending(w, req)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("authed poll = %d", w.Code)
|
||||||
|
}
|
||||||
|
var pending []storage.AdvOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].Action != storage.AdvActionExtract {
|
||||||
|
t.Fatalf("pending = %+v, want the one queued extract", pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerdictForAnUnknownOrderIs400: under this seam's contract that parks the
|
||||||
|
// row for a human rather than retrying forever against a row that can never
|
||||||
|
// exist.
|
||||||
|
func TestVerdictForAnUnknownOrderIs400(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
if w := postVerdict(t, s, "tok", advOrderVerdict{GUID: "nope", Status: storage.AdvOrderApplied}); w.Code != 400 {
|
||||||
|
t.Fatalf("verdict for an unknown guid = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postVerdict(t *testing.T, s *Server, token string, v advOrderVerdict) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(v)
|
||||||
|
req := httptest.NewRequest("POST", "/api/adventure/orders/verdict", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdvOrderVerdict(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── W5b: the three verbs that take arguments ─────────────────────────────────
|
||||||
|
|
||||||
|
// seedOffers is seedActions with an offer list on the private detail row — which
|
||||||
|
// is what gogobee pushes, and what every W5b param is resolved against.
|
||||||
|
func seedOffers(t *testing.T, owner, status string, pd storage.PlayerDetail) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", status, owner)
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
pd.Localpart = owner
|
||||||
|
pd.Token = "tok-josie"
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{pd}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed detail = %d", w.Code)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func offeredZones() []storage.ZoneOffer {
|
||||||
|
return []storage.ZoneOffer{{
|
||||||
|
ID: "goblin_warrens", Display: "Goblin Warrens", Tier: 1,
|
||||||
|
Loadouts: []storage.LoadoutOffer{
|
||||||
|
{Key: "lean", Name: "lean", Cost: 40, Days: 3},
|
||||||
|
{Key: "balanced", Name: "balanced", Cost: 80, Days: 5},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeParams(t *testing.T, s *Server, username string, req advOrderReq) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := as(t, s, username, "POST", "/api/adventure/order", req)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdvOrder(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of resolving params against the owner's own offer list: a
|
||||||
|
// forged zone, or a loadout that zone does not sell, must never reach an order
|
||||||
|
// row. gogobee would refuse them anyway — this is the cheap answer, thirty
|
||||||
|
// seconds earlier, and it keeps the queue clean.
|
||||||
|
func TestExpeditionParamsAreResolvedAgainstTheOwnersOffers(t *testing.T) {
|
||||||
|
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
|
||||||
|
|
||||||
|
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionExpedition, Zone: "dragons_lair", Loadout: "lean",
|
||||||
|
}); w.Code != 409 {
|
||||||
|
t.Fatalf("forged zone = %d, want 409 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "enormous",
|
||||||
|
}); w.Code != 409 {
|
||||||
|
t.Fatalf("forged loadout = %d, want 409 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "balanced",
|
||||||
|
})
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got storage.AdvOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got.Params == nil || got.Params.Zone != "goblin_warrens" || got.Params.Loadout != "balanced" {
|
||||||
|
t.Fatalf("params = %+v, want the resolved zone and loadout", got.Params)
|
||||||
|
}
|
||||||
|
// And they survive the round trip to gogobee's poll, which is the only reason
|
||||||
|
// they are stored at all.
|
||||||
|
pending, err := storage.PendingAdvOrders(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].Params == nil || pending[0].Params.Zone != "goblin_warrens" {
|
||||||
|
t.Fatalf("pending params lost in the round trip: %+v", pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty zone list is a refusal, and the message says only what Pete saw. It
|
||||||
|
// usually means the adventurer is already out — gogobee omits the offers
|
||||||
|
// entirely while they are down there — but a game box too old to push offers
|
||||||
|
// sends the same empty list, so the copy claims nothing about which. Refusing
|
||||||
|
// cheaply here beats a verdict thirty seconds later saying the same thing.
|
||||||
|
func TestNoZoneOffersMeansNothingOnOffer(t *testing.T) {
|
||||||
|
s := seedOffers(t, "holymachina", "expedition", storage.PlayerDetail{})
|
||||||
|
w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
|
||||||
|
})
|
||||||
|
if w.Code != 409 {
|
||||||
|
t.Fatalf("departure with no offers = %d, want 409 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sitter sells two durations and nothing else, and is not sold twice.
|
||||||
|
func TestBabysitParamsAreTheTwoDurationsOnly(t *testing.T) {
|
||||||
|
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
|
||||||
|
Babysit: &storage.BabysitOffer{WeekCost: 700, MonthCost: 3000},
|
||||||
|
})
|
||||||
|
for _, days := range []int{0, 3, 365} {
|
||||||
|
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionBabysit, Days: days,
|
||||||
|
}); w.Code != 409 {
|
||||||
|
t.Fatalf("%d-day sitter = %d, want 409", days, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionBabysit, Days: 30,
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("month = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already engaged: the page should not be offering this at all, but a stale
|
||||||
|
// tab can still post it.
|
||||||
|
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
|
||||||
|
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 3000},
|
||||||
|
})
|
||||||
|
if w := placeParams(t, s2, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionBabysit, Days: 7,
|
||||||
|
}); w.Code != 409 {
|
||||||
|
t.Fatalf("second sitter = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume is refused when the snapshot positively says there is nothing waiting,
|
||||||
|
// and accepted with a loadout the offer actually lists.
|
||||||
|
func TestResumeParamsNeedAnOfferedLoadout(t *testing.T) {
|
||||||
|
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{})
|
||||||
|
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionResume, Loadout: "lean",
|
||||||
|
}); w.Code != 409 {
|
||||||
|
t.Fatalf("resume with nothing waiting = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
|
||||||
|
Resume: &storage.ResumeOffer{ZoneID: "goblin_warrens", Display: "Goblin Warrens", Day: 3,
|
||||||
|
Loadouts: []storage.LoadoutOffer{{Key: "lean", Name: "lean", Cost: 40, Days: 3}}},
|
||||||
|
})
|
||||||
|
if w := placeParams(t, s2, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionResume, Loadout: "heavy",
|
||||||
|
}); w.Code != 409 {
|
||||||
|
t.Fatalf("unoffered loadout = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if w := placeParams(t, s2, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionResume, Loadout: "lean",
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("offered loadout = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offer list is the whole gate, so it is worth pinning that an empty one is
|
||||||
|
// a refusal rather than a pass-through: gogobee omits the zones while the
|
||||||
|
// adventurer is out, and a pass-through there would queue a departure that is
|
||||||
|
// certain to come back "you're already on expedition".
|
||||||
|
//
|
||||||
|
// There is deliberately no "Pete has never heard of this player" case to test:
|
||||||
|
// the detail row this resolves against is the same row the ownership check
|
||||||
|
// already found, so it always exists by then. A gogobee too old to push offers
|
||||||
|
// yields an empty list and the page renders no picker at all.
|
||||||
|
func TestParamsResolveOnlyAgainstAPushedOffer(t *testing.T) {
|
||||||
|
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
|
||||||
|
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Resume is not on offer for this player at all, so it is refused even though
|
||||||
|
// the loadout key is a real one from the zone list above.
|
||||||
|
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||||
|
Action: storage.AdvActionResume, Loadout: "lean",
|
||||||
|
}); w.Code != 409 {
|
||||||
|
t.Fatalf("resume with no offer = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// W9: the three verbs that undo something. Two halves are worth pinning.
|
||||||
|
//
|
||||||
|
// The first is offersToUndo, which is the only place on this site where Pete
|
||||||
|
// decides what a player MAY do from facts rather than from a list gogobee handed
|
||||||
|
// it. Getting it wrong in the generous direction puts "Call the whole thing off"
|
||||||
|
// — a button that throws away four people's day — in front of somebody who is not
|
||||||
|
// the leader, so the interesting cases are the ones where it must stay quiet.
|
||||||
|
//
|
||||||
|
// The second is that the two new verdict names round-trip. gogobee 400s on an
|
||||||
|
// unknown verdict and parks the order, so a name that exists on one side and not
|
||||||
|
// the other is a player watching "asked for…" forever.
|
||||||
|
|
||||||
|
func seat(kind, name, token string, level int) partySeat {
|
||||||
|
return partySeat{Kind: kind, Name: name, Token: token, Level: level}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOffersToUndoReadsTheViewersOwnSeat is the core of the phase. A shared
|
||||||
|
// expedition publishes a seat per body, so which button this page offers is
|
||||||
|
// decided by finding the viewer among them — never by "there is a party, so
|
||||||
|
// somebody can abandon it".
|
||||||
|
func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
|
||||||
|
party := []partySeat{
|
||||||
|
seat("leader", "Josie", "tok-josie", 14),
|
||||||
|
seat("member", "Camcast", "tok-cam", 11),
|
||||||
|
seat("companion", "Pete", "", 9),
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
token, status string
|
||||||
|
partyKnown bool
|
||||||
|
party []partySeat
|
||||||
|
self storage.PlayerDetail
|
||||||
|
abandon, leave, cancel bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "leader of a party is offered the abandon",
|
||||||
|
token: "tok-josie", status: "expedition", partyKnown: true, party: party,
|
||||||
|
abandon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "member of a party is offered the exit, never the abandon",
|
||||||
|
token: "tok-cam", status: "expedition", partyKnown: true, party: party,
|
||||||
|
leave: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A solo run publishes no party at all (partySeatViews returns nil below
|
||||||
|
// two seats), so an empty list on a live run means "nobody else", not
|
||||||
|
// "we don't know" — and the one body down there is the leader.
|
||||||
|
name: "solo run is offered the abandon",
|
||||||
|
token: "tok-josie", status: "expedition", partyKnown: true,
|
||||||
|
abandon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The fail-closed case. A party we cannot find ourselves in is a
|
||||||
|
// snapshot we do not understand, and the safe answer is to offer
|
||||||
|
// nothing rather than guess which of the two buttons applies.
|
||||||
|
name: "a party with no seat for the viewer offers nothing",
|
||||||
|
token: "tok-nobody", status: "expedition", partyKnown: true, party: party,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The one that came out of running it. An undecodable public sheet
|
||||||
|
// gives the same empty slice as a solo run, and treating the two alike
|
||||||
|
// offered a party MEMBER the abandon — convincingly, with the rest of
|
||||||
|
// the page looking fine.
|
||||||
|
name: "a run whose sheet did not decode offers nothing",
|
||||||
|
token: "tok-cam", status: "expedition", partyKnown: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The same empty slice again, this time from a gogobee too old to push
|
||||||
|
// seats at all. It decodes fine, so only the sender's own flag tells it
|
||||||
|
// apart from the solo case two rows up.
|
||||||
|
name: "an empty party from a sender that never pushes seats offers nothing",
|
||||||
|
token: "tok-cam", status: "expedition", partyKnown: false, party: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The asymmetry: the seat list is self-evidencing, so it keeps working
|
||||||
|
// against a sender whose capability we cannot confirm. A seat saying
|
||||||
|
// "member" is not a guess, and refusing the exit here would strand
|
||||||
|
// somebody in a party for the length of the rollout.
|
||||||
|
name: "a seated member is offered the exit even without the flag",
|
||||||
|
token: "tok-cam", status: "expedition", partyKnown: false, party: party,
|
||||||
|
leave: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standing in town with nothing open offers nothing",
|
||||||
|
token: "tok-josie", status: "idle", partyKnown: true, party: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The case the roster status cannot see: an extracted expedition is
|
||||||
|
// still its owner's to close, and its owner reads as idle in town with
|
||||||
|
// no party. The resume offer is the only sign the run is still open.
|
||||||
|
name: "an extracted run is abandonable from town",
|
||||||
|
token: "tok-josie", status: "idle",
|
||||||
|
self: storage.PlayerDetail{Resume: &storage.ResumeOffer{ZoneID: "holymachina", Day: 3}},
|
||||||
|
abandon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an engaged sitter can be sent home",
|
||||||
|
token: "tok-josie", status: "idle",
|
||||||
|
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: true}},
|
||||||
|
cancel: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an unengaged sitter cannot",
|
||||||
|
token: "tok-josie", status: "idle",
|
||||||
|
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
abandon, leave, cancel := offersToUndo(tc.token, tc.status, tc.partyKnown, tc.party, tc.self)
|
||||||
|
if abandon != tc.abandon || leave != tc.leave || cancel != tc.cancel {
|
||||||
|
t.Fatalf("offers = abandon:%v leave:%v cancel:%v, want abandon:%v leave:%v cancel:%v",
|
||||||
|
abandon, leave, cancel, tc.abandon, tc.leave, tc.cancel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAbandonAndLeaveAreNeverBothOffered. They are opposite claims about the
|
||||||
|
// same person, and a page showing both would be asking the reader to work out
|
||||||
|
// which one they are. No input may produce the pair.
|
||||||
|
//
|
||||||
|
// This test found a real one: a member seated in somebody else's live run who
|
||||||
|
// ALSO has their own extracted run waiting has both facts true at once, about two
|
||||||
|
// different expeditions. offersToUndo suppresses the abandon in that case; see
|
||||||
|
// the comment on the Resume clause.
|
||||||
|
func TestAbandonAndLeaveAreNeverBothOffered(t *testing.T) {
|
||||||
|
for _, kind := range []string{"leader", "member", "companion", "", "nonsense"} {
|
||||||
|
party := []partySeat{seat("leader", "Josie", "tok-josie", 14), seat(kind, "Me", "tok-me", 8)}
|
||||||
|
for _, status := range []string{"expedition", "idle"} {
|
||||||
|
for _, resume := range []*storage.ResumeOffer{nil, {ZoneID: "z", Day: 2}} {
|
||||||
|
abandon, leave, _ := offersToUndo("tok-me", status, true, party, storage.PlayerDetail{Resume: resume})
|
||||||
|
if abandon && leave {
|
||||||
|
t.Fatalf("kind=%q status=%q resume=%v offered both ways out at once", kind, status, resume != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUndoOrdersAreAccepted: the three verbs must survive the action allow-list
|
||||||
|
// and land as pending orders. A verb Pete does not know is a 400 at the door,
|
||||||
|
// which is a dead button rather than a refusal anybody can read.
|
||||||
|
func TestUndoOrdersAreAccepted(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
for _, action := range []string{storage.AdvActionAbandon, storage.AdvActionLeave} {
|
||||||
|
if w := placeAction(t, s, "holymachina", action); w.Code != 200 {
|
||||||
|
t.Fatalf("%s = %d (%s)", action, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Per verb, so the two do not block each other or anything already queued.
|
||||||
|
pending, err := storage.PendingAdvOrders(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Fatalf("pending = %d orders, want 2", len(pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAbandonIsOfferedToAMarkStandingInTown is W5a's asymmetry restated for the
|
||||||
|
// two expedition verbs, and it is the reason neither has a snapshot pre-check.
|
||||||
|
// The board is up to two minutes stale, and an EXTRACTED run is abandonable
|
||||||
|
// while its owner reads as idle — so refusing on "the mark is in town" would
|
||||||
|
// refuse the case the verb exists for. gogobee answers rejected_not_running if
|
||||||
|
// the run really has gone.
|
||||||
|
func TestAbandonIsOfferedToAMarkStandingInTown(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "idle")
|
||||||
|
if w := placeAction(t, s, "holymachina", storage.AdvActionAbandon); w.Code != 200 {
|
||||||
|
t.Fatalf("abandon from town = %d (%s), want it queued and answered by the game box",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := placeAction(t, s, "holymachina", storage.AdvActionLeave); w.Code != 200 {
|
||||||
|
t.Fatalf("leave from town = %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBabysitCancelRefusesWhenThereIsNoSitter is the one W9 pre-check that IS
|
||||||
|
// allowed to be the last word locally, and the comment in resolveAdvOrderParams
|
||||||
|
// says why: an engagement is a fact about the character rather than about where
|
||||||
|
// they are standing, so it does not go stale the way "on an expedition" does.
|
||||||
|
//
|
||||||
|
// A MISSING offer is still not a refusal — that is a gogobee too old to push one,
|
||||||
|
// not a player without a sitter — and the second half here pins that.
|
||||||
|
func TestBabysitCancelRefusesWhenThereIsNoSitter(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "idle")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: "holymachina", Token: "tok-josie",
|
||||||
|
Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700, MonthCost: 2400},
|
||||||
|
}}}); w.Code != 200 {
|
||||||
|
t.Fatalf("detail push = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 409 {
|
||||||
|
t.Fatalf("cancel with no sitter = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sitter engaged: allowed.
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: "holymachina", Token: "tok-josie",
|
||||||
|
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 2400},
|
||||||
|
}}}); w.Code != 200 {
|
||||||
|
t.Fatalf("detail push = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
|
||||||
|
t.Fatalf("cancel with a sitter = %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// No babysit offer at all: a gogobee that predates the offer push. Queue it
|
||||||
|
// and let the game box answer, rather than making the button dead.
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: "holymachina", Token: "tok-josie",
|
||||||
|
}}}); w.Code != 200 {
|
||||||
|
t.Fatalf("detail push = %d", w.Code)
|
||||||
|
}
|
||||||
|
storage.Get().Exec(`DELETE FROM adventure_orders`)
|
||||||
|
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
|
||||||
|
t.Fatalf("cancel with no offer pushed = %d (%s), want it deferred to gogobee",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewVerdictsRoundTrip: gogobee 400s on a verdict Pete will not take, and
|
||||||
|
// that parks the order — the player watches "asked for…" and nothing ever
|
||||||
|
// answers. So every status the game box can file has to be accepted here.
|
||||||
|
func TestNewVerdictsRoundTrip(t *testing.T) {
|
||||||
|
s := seedActions(t, "holymachina", "expedition")
|
||||||
|
|
||||||
|
for _, tc := range []struct{ action, verdict string }{
|
||||||
|
{storage.AdvActionLeave, storage.AdvRejectedIsLeader},
|
||||||
|
{storage.AdvActionBabysitCancel, storage.AdvRejectedNothingToCancel},
|
||||||
|
{storage.AdvActionAbandon, storage.AdvRejectedNotLeader},
|
||||||
|
} {
|
||||||
|
storage.Get().Exec(`DELETE FROM adventure_orders`)
|
||||||
|
w := placeAction(t, s, "holymachina", tc.action)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("place %s = %d (%s)", tc.action, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
pending, err := storage.PendingAdvOrders(0)
|
||||||
|
if err != nil || len(pending) != 1 {
|
||||||
|
t.Fatalf("pending = %v (%v)", pending, err)
|
||||||
|
}
|
||||||
|
rec := postVerdict(t, s, "tok", advOrderVerdict{
|
||||||
|
GUID: pending[0].GUID, Status: tc.verdict, Detail: "because.",
|
||||||
|
})
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("verdict %s = %d (%s) — an unknown status parks the order forever",
|
||||||
|
tc.verdict, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
got, err := storage.AdvOrderByGUID(pending[0].GUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != tc.verdict {
|
||||||
|
t.Fatalf("stored status = %q, want %q", got.Status, tc.verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adventure alerts: the only channel that reaches a player who isn't looking at
|
||||||
|
// the site or at Matrix.
|
||||||
|
//
|
||||||
|
// This rides entirely on facts gogobee is already sending. Every trigger below
|
||||||
|
// is a dispatch that already lands in adventure_events, so the whole phase is
|
||||||
|
// Pete-side and no new wire, event type or deploy ordering is involved.
|
||||||
|
//
|
||||||
|
// Two things separate it from the news digest it sits beside:
|
||||||
|
//
|
||||||
|
// - Its own clock. A digest is a summary and six hours late is fine; "the
|
||||||
|
// Siege has begun" six hours late is worse than silence, because the bout
|
||||||
|
// the alert is asking for may already be over.
|
||||||
|
// - Its own watermark (last_adv_notified_at). Sharing the digest's column
|
||||||
|
// would let each sender consume the other's backlog.
|
||||||
|
|
||||||
|
// advAlertInterval matches the roster tick that delivers the facts. Checking
|
||||||
|
// faster than they can arrive only burns queries.
|
||||||
|
const advAlertInterval = 2 * time.Minute
|
||||||
|
|
||||||
|
// advAlertScan caps how many dispatches one pass inspects. Generously above the
|
||||||
|
// realm's real rate (a busy day is tens of dispatches, not hundreds), so hitting
|
||||||
|
// it means something is wrong rather than something is busy — see the warning in
|
||||||
|
// sendAdventureAlerts.
|
||||||
|
const advAlertScan = 200
|
||||||
|
|
||||||
|
// advPrefsKey is where the client stores the per-category opt-ins, alongside the
|
||||||
|
// other synced preferences. Its value is a JSON object of {category: true}.
|
||||||
|
const advPrefsKey = "pete.advPush.v1"
|
||||||
|
|
||||||
|
// The alert categories. Every one is opt-in and defaults OFF: a user who turned
|
||||||
|
// on notifications did so for news, and quietly enrolling them in game alerts
|
||||||
|
// they never asked for is how a notification permission gets revoked for good.
|
||||||
|
//
|
||||||
|
// advCatSiege is realm-wide — it needs no ownership and reaches everyone who
|
||||||
|
// asked for it. The other three are owner-scoped and reach exactly one person.
|
||||||
|
const (
|
||||||
|
advCatSiege = "siege"
|
||||||
|
advCatRun = "run"
|
||||||
|
advCatDeparture = "departure"
|
||||||
|
advCatContract = "contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
// advAlert is one notification a pass has decided to send.
|
||||||
|
type advAlert struct {
|
||||||
|
Category string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAdventureAlerts launches the alert loop when push and the adventure
|
||||||
|
// section are both configured. Safe to call unconditionally.
|
||||||
|
func (s *Server) StartAdventureAlerts(ctx context.Context) {
|
||||||
|
if !s.cfg.Push.Enabled || s.auth == nil || !s.adv.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.runAdventureAlerts(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runAdventureAlerts(ctx context.Context) {
|
||||||
|
slog.Info("web: adventure alert sender started", "interval", advAlertInterval)
|
||||||
|
ticker := time.NewTicker(advAlertInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.sendAdventureAlerts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAdventureAlerts runs one pass: read the dispatches nobody has been told
|
||||||
|
// about yet, and for each subscription decide whether any of them is that
|
||||||
|
// person's business.
|
||||||
|
func (s *Server) sendAdventureAlerts() {
|
||||||
|
subs, err := storage.ListPushSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adv-push: list subscriptions failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(subs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
// A row written before adventure alerts existed carries watermark 0, which
|
||||||
|
// read literally means "has never been told about anything" and would page the
|
||||||
|
// subscriber for the entire history of the realm on the first tick after
|
||||||
|
// deploy. Stamp those to now and let them start from the next dispatch. This
|
||||||
|
// is the deploy-safety valve for the whole phase.
|
||||||
|
live := subs[:0]
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.LastAdvNotifiedAt == 0 {
|
||||||
|
if err := storage.TouchAdvPushSubscription(sub.Endpoint, now); err != nil {
|
||||||
|
slog.Error("adv-push: seed watermark failed", "sub", sub.UserSub, "err", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
live = append(live, sub)
|
||||||
|
}
|
||||||
|
if len(live) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// One scan serves every subscriber: read from the oldest watermark in the set
|
||||||
|
// and let each row be filtered per subscription below.
|
||||||
|
oldest := live[0].LastAdvNotifiedAt
|
||||||
|
for _, sub := range live {
|
||||||
|
if sub.LastAdvNotifiedAt < oldest {
|
||||||
|
oldest = sub.LastAdvNotifiedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events, err := storage.AdvEventsSince(oldest, advAlertScan)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adv-push: scan dispatches failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(events) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(events) == advAlertScan {
|
||||||
|
// The scan was capped, so dispatches older than the window exist and are
|
||||||
|
// about to be skipped by the watermark advance below. Alerts are
|
||||||
|
// time-sensitive enough that dropping the tail is right; being quiet about
|
||||||
|
// it is not.
|
||||||
|
slog.Warn("adv-push: scan hit its cap; older dispatches skipped",
|
||||||
|
"cap", advAlertScan, "since", oldest)
|
||||||
|
}
|
||||||
|
// events[0] is the newest in the window (occurred_at DESC) and is where every
|
||||||
|
// watermark lands this pass, sent or not.
|
||||||
|
newest := events[0].OccurredAt
|
||||||
|
|
||||||
|
// Both lookups are per-user, and a user can hold several endpoints (phone,
|
||||||
|
// desktop). Cache within the pass so a two-device user costs one prefs parse
|
||||||
|
// and one ownership join rather than two.
|
||||||
|
catsByUser := make(map[string]map[string]bool)
|
||||||
|
nameByLocalpart := make(map[string]string)
|
||||||
|
|
||||||
|
sent, pruned := 0, 0
|
||||||
|
for _, sub := range live {
|
||||||
|
cats, ok := catsByUser[sub.UserSub]
|
||||||
|
if !ok {
|
||||||
|
cats = advCategoriesFor(sub.UserSub)
|
||||||
|
catsByUser[sub.UserSub] = cats
|
||||||
|
}
|
||||||
|
if len(cats) == 0 {
|
||||||
|
// Nothing enabled. Still advance, so turning a category on later starts
|
||||||
|
// from that moment rather than replaying the backlog it was off for.
|
||||||
|
s.touchAdv(sub.Endpoint, newest)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ownership join is re-read every pass rather than trusted from the
|
||||||
|
// subscription row, and that is deliberate: an opt-out, a removal or a
|
||||||
|
// character change has to close the channel immediately, exactly as
|
||||||
|
// runReportLinkFor re-resolves rather than trusting a stored id.
|
||||||
|
mine := ""
|
||||||
|
if sub.Localpart != "" {
|
||||||
|
if cached, ok := nameByLocalpart[sub.Localpart]; ok {
|
||||||
|
mine = cached
|
||||||
|
} else {
|
||||||
|
if name, ok := storage.AdvCharacterForOwner(sub.Localpart); ok {
|
||||||
|
mine = name
|
||||||
|
}
|
||||||
|
nameByLocalpart[sub.Localpart] = mine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var top *advAlert
|
||||||
|
extra := 0
|
||||||
|
for _, ev := range events {
|
||||||
|
if ev.OccurredAt <= sub.LastAdvNotifiedAt {
|
||||||
|
continue // this endpoint has already been told
|
||||||
|
}
|
||||||
|
alert, ok := advAlertFor(ev, mine)
|
||||||
|
if !ok || !cats[alert.Category] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if top == nil {
|
||||||
|
// events are newest-first, so the first match is the newest match.
|
||||||
|
a := alert
|
||||||
|
top = &a
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extra++
|
||||||
|
}
|
||||||
|
if top == nil {
|
||||||
|
s.touchAdv(sub.Endpoint, newest)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
gone, err := s.sendPush(sub, buildAdvPayload(*top, extra))
|
||||||
|
if gone {
|
||||||
|
if derr := storage.RemovePushSubscription(sub.Endpoint); derr != nil {
|
||||||
|
slog.Error("adv-push: prune gone subscription failed", "err", derr)
|
||||||
|
} else {
|
||||||
|
pruned++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Leave the watermark alone: a transient push-service failure should be
|
||||||
|
// retried on the next tick, not swallowed. The alert is at most
|
||||||
|
// advAlertInterval late, and the events are still in the window.
|
||||||
|
slog.Warn("adv-push: send failed", "sub", sub.UserSub, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.touchAdv(sub.Endpoint, newest)
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
if sent > 0 || pruned > 0 {
|
||||||
|
slog.Info("adv-push: pass complete", "sent", sent, "pruned", pruned, "subscriptions", len(live))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) touchAdv(endpoint string, ts int64) {
|
||||||
|
if err := storage.TouchAdvPushSubscription(endpoint, ts); err != nil {
|
||||||
|
slog.Error("adv-push: advance watermark failed", "endpoint", endpoint, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advAlertFor decides whether one dispatch is worth waking somebody for, and in
|
||||||
|
// what words. mine is the character name belonging to the subscriber, or "" when
|
||||||
|
// Pete cannot establish one — in which case only realm-wide alerts can match.
|
||||||
|
//
|
||||||
|
// Every owner-scoped branch compares against mine and nothing else. There is no
|
||||||
|
// path here where an unresolved owner falls through to a broadcast: a game alert
|
||||||
|
// naming somebody's adventurer, delivered to the wrong phone, is a privacy leak
|
||||||
|
// dressed as a feature.
|
||||||
|
func advAlertFor(ev storage.AdvEvent, mine string) (advAlert, bool) {
|
||||||
|
switch ev.EventType {
|
||||||
|
case "siege_start":
|
||||||
|
boss := ev.Boss
|
||||||
|
if boss == "" {
|
||||||
|
boss = "Something"
|
||||||
|
}
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatSiege,
|
||||||
|
Title: "The Siege has begun",
|
||||||
|
Body: fmt.Sprintf("%s is camped outside the town. Everyone gets one bout a day.", boss),
|
||||||
|
URL: "/adventure/siege",
|
||||||
|
}, true
|
||||||
|
case "siege_win":
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatSiege,
|
||||||
|
Title: "The town holds",
|
||||||
|
Body: siegeOutcomeBody(ev, "went down"),
|
||||||
|
URL: "/adventure/siege",
|
||||||
|
}, true
|
||||||
|
case "siege_loss":
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatSiege,
|
||||||
|
Title: "The Siege is over",
|
||||||
|
Body: siegeOutcomeBody(ev, "walked away still standing"),
|
||||||
|
URL: "/adventure/siege",
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything below is about one person, so an unmatched subject ends it here.
|
||||||
|
if mine == "" || ev.Subject != mine {
|
||||||
|
return advAlert{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ev.EventType {
|
||||||
|
case "death":
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatRun,
|
||||||
|
Title: fmt.Sprintf("%s fell in %s", mine, orPlace(ev.Zone)),
|
||||||
|
Body: "The expedition is over. They'll need picking up.",
|
||||||
|
URL: advRunOrStoryURL(ev),
|
||||||
|
}, true
|
||||||
|
case "zone_clear":
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatRun,
|
||||||
|
Title: fmt.Sprintf("%s cleared %s", mine, orPlace(ev.Zone)),
|
||||||
|
Body: "They're through it and on the way home. Read how it went.",
|
||||||
|
URL: advRunOrStoryURL(ev),
|
||||||
|
}, true
|
||||||
|
case "retreat":
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatRun,
|
||||||
|
Title: fmt.Sprintf("%s backed out of %s", mine, orPlace(ev.Zone)),
|
||||||
|
Body: "Everyone came home breathing. The run's finished either way.",
|
||||||
|
URL: advRunOrStoryURL(ev),
|
||||||
|
}, true
|
||||||
|
case "departure":
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatDeparture,
|
||||||
|
Title: fmt.Sprintf("%s got bored and left", mine),
|
||||||
|
Body: fmt.Sprintf("No orders, no escort. They packed the cheap kit and set off into %s on their own.",
|
||||||
|
orPlace(ev.Zone)),
|
||||||
|
URL: advStoryURL(ev.GUID),
|
||||||
|
}, true
|
||||||
|
case "mischief_contract":
|
||||||
|
body := "Somebody paid to have something sent after them, and isn't saying who. Survive it and the money's theirs."
|
||||||
|
if ev.Opponent != "" {
|
||||||
|
body = fmt.Sprintf("%s paid for it and signed the thing. It's out there looking right now.", ev.Opponent)
|
||||||
|
}
|
||||||
|
return advAlert{
|
||||||
|
Category: advCatContract,
|
||||||
|
Title: fmt.Sprintf("There's a contract out on %s", mine),
|
||||||
|
Body: body,
|
||||||
|
URL: advStoryURL(ev.GUID),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
return advAlert{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// siegeOutcomeBody names the boss when the fact carried one. The verb differs by
|
||||||
|
// outcome, so it is the caller's.
|
||||||
|
func siegeOutcomeBody(ev storage.AdvEvent, verb string) string {
|
||||||
|
if ev.Boss == "" {
|
||||||
|
return fmt.Sprintf("It %s. See how the town did.", verb)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s. See how the town did.", ev.Boss, verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func orPlace(zone string) string {
|
||||||
|
if zone == "" {
|
||||||
|
return "the dark"
|
||||||
|
}
|
||||||
|
return zone
|
||||||
|
}
|
||||||
|
|
||||||
|
// advRunOrStoryURL prefers the run report, which is the richer landing place for
|
||||||
|
// an expedition that has just ended, and falls back to the dispatch permalink.
|
||||||
|
// Runs that predate the liveblog carry no run id and land on the story, which is
|
||||||
|
// what they have always done.
|
||||||
|
func advRunOrStoryURL(ev storage.AdvEvent) string {
|
||||||
|
if ev.RunID != "" {
|
||||||
|
return runReportPath(ev.RunID)
|
||||||
|
}
|
||||||
|
return advStoryURL(ev.GUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advStoryURL is advPermalink's relative half, and it escapes for the same
|
||||||
|
// reason: the guid arrives over a wire, and one that grew a slash would send the
|
||||||
|
// notification somewhere else entirely.
|
||||||
|
func advStoryURL(guid string) string {
|
||||||
|
return "/adventure/" + url.PathEscape(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAdvPayload renders the notification JSON the service worker expects. The
|
||||||
|
// tag is per-category so a Siege alert can't silently replace an unread alert
|
||||||
|
// about somebody's own adventurer on the lock screen.
|
||||||
|
func buildAdvPayload(a advAlert, extra int) []byte {
|
||||||
|
body := a.Body
|
||||||
|
if extra == 1 {
|
||||||
|
body += " Plus 1 other update."
|
||||||
|
} else if extra > 1 {
|
||||||
|
body += fmt.Sprintf(" Plus %d other updates.", extra)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]string{
|
||||||
|
"title": a.Title,
|
||||||
|
"body": body,
|
||||||
|
"url": a.URL,
|
||||||
|
"tag": "pete-adv-" + a.Category,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// advCategoriesFor returns the alert categories a user has switched on. An
|
||||||
|
// absent key, an unparseable blob or a user who has never opened the settings
|
||||||
|
// drawer all yield an empty set, which sends nothing — the safe direction for a
|
||||||
|
// channel that interrupts people.
|
||||||
|
func advCategoriesFor(sub string) map[string]bool {
|
||||||
|
return userPrefBoolSet(sub, advPrefsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userPrefBoolSet reads one {name: bool} map out of a user's stored preferences
|
||||||
|
// blob, keeping only the true entries.
|
||||||
|
//
|
||||||
|
// The blob mirrors localStorage: a JSON object whose values are themselves JSON
|
||||||
|
// *strings*. That double encoding is the client's doing, not ours, so unwrap it
|
||||||
|
// — while tolerating a bare object, since a hand-written or migrated blob may
|
||||||
|
// carry one. Any parse failure yields an empty set, and every caller treats an
|
||||||
|
// empty set as "no", so a corrupt blob costs a feature rather than misfiring it.
|
||||||
|
func userPrefBoolSet(sub, key string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
blob, err := storage.GetUserPrefs(sub)
|
||||||
|
if err != nil || blob == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var prefs map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(blob), &prefs); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
raw, ok := prefs[key]
|
||||||
|
if !ok {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
inner := []byte(raw)
|
||||||
|
var asStr string
|
||||||
|
if err := json.Unmarshal(raw, &asStr); err == nil {
|
||||||
|
inner = []byte(asStr)
|
||||||
|
}
|
||||||
|
var set map[string]bool
|
||||||
|
if err := json.Unmarshal(inner, &set); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for name, on := range set {
|
||||||
|
if on {
|
||||||
|
out[strings.TrimSpace(name)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOwnerScopedAlertsNeverBroadcast is the security regression for this whole
|
||||||
|
// phase, and it is the one to keep if the rest are ever thinned out.
|
||||||
|
//
|
||||||
|
// Three of the four categories name somebody's adventurer. The sender resolves
|
||||||
|
// "which character belongs to this subscriber" from a join that can legitimately
|
||||||
|
// come back empty — a player off the board, an opt-out, a session with no
|
||||||
|
// username. If an empty answer ever fell through to "matches anything", every
|
||||||
|
// subscriber's phone would light up with a stranger's death, naming them.
|
||||||
|
func TestOwnerScopedAlertsNeverBroadcast(t *testing.T) {
|
||||||
|
owned := []storage.AdvEvent{
|
||||||
|
{EventType: "death", Subject: "Josie", Zone: "The Crypt"},
|
||||||
|
{EventType: "zone_clear", Subject: "Josie", Zone: "The Crypt"},
|
||||||
|
{EventType: "retreat", Subject: "Josie", Zone: "The Crypt"},
|
||||||
|
{EventType: "departure", Subject: "Josie", Zone: "The Crypt"},
|
||||||
|
{EventType: "mischief_contract", Subject: "Josie", Stakes: "500 gold"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ev := range owned {
|
||||||
|
// No resolvable owner at all.
|
||||||
|
if _, ok := advAlertFor(ev, ""); ok {
|
||||||
|
t.Errorf("%s matched with no owner resolved; that is a broadcast of a private event", ev.EventType)
|
||||||
|
}
|
||||||
|
// An owner, but somebody else's dispatch.
|
||||||
|
if _, ok := advAlertFor(ev, "Quack"); ok {
|
||||||
|
t.Errorf("%s about Josie matched a subscriber who plays Quack", ev.EventType)
|
||||||
|
}
|
||||||
|
// The actual owner.
|
||||||
|
if _, ok := advAlertFor(ev, "Josie"); !ok {
|
||||||
|
t.Errorf("%s about Josie did not match Josie", ev.EventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeAlertsAreRealmWide is the other half: the Siege is the one communal
|
||||||
|
// mechanic, so it must reach a subscriber whose ownership join came back empty —
|
||||||
|
// including someone who has never made a character at all.
|
||||||
|
func TestSiegeAlertsAreRealmWide(t *testing.T) {
|
||||||
|
for _, kind := range []string{"siege_start", "siege_win", "siege_loss"} {
|
||||||
|
a, ok := advAlertFor(storage.AdvEvent{EventType: kind, Boss: "The Hollow King"}, "")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s did not match a subscriber with no character", kind)
|
||||||
|
}
|
||||||
|
if a.Category != advCatSiege {
|
||||||
|
t.Errorf("%s filed under %q, want %q", kind, a.Category, advCatSiege)
|
||||||
|
}
|
||||||
|
if a.URL != "/adventure/siege" {
|
||||||
|
t.Errorf("%s links to %q, want the war room", kind, a.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUntemplatedDispatchIsSilent pins that adding an event type upstream does
|
||||||
|
// not silently start paging people. W0 made an unknown event_type render as a
|
||||||
|
// neutral card rather than 400 — the right call for a *page*, and the wrong one
|
||||||
|
// for a phone. A dispatch nobody has written alert copy for gets no alert.
|
||||||
|
func TestUntemplatedDispatchIsSilent(t *testing.T) {
|
||||||
|
for _, kind := range []string{"treasure_found", "milestone", "arrival", "companion_hire", "brand_new_thing"} {
|
||||||
|
if _, ok := advAlertFor(storage.AdvEvent{EventType: kind, Subject: "Josie"}, "Josie"); ok {
|
||||||
|
t.Errorf("%s produced an alert; new event types must opt in, not opt out", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEndedRunAlertPrefersTheRunReport pins the landing page. A finished
|
||||||
|
// expedition has a report worth reading; a dispatch that predates the liveblog
|
||||||
|
// has no run id and must still land somewhere real rather than on /adventure/.
|
||||||
|
func TestEndedRunAlertPrefersTheRunReport(t *testing.T) {
|
||||||
|
withRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g1", RunID: "run-7"}
|
||||||
|
a, ok := advAlertFor(withRun, "Josie")
|
||||||
|
if !ok || a.URL != "/adventure/run/run-7" {
|
||||||
|
t.Errorf("url = %q, want the run report", a.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
noRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g2"}
|
||||||
|
a, ok = advAlertFor(noRun, "Josie")
|
||||||
|
if !ok || a.URL != "/adventure/g2" {
|
||||||
|
t.Errorf("url = %q, want the story permalink fallback", a.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAlertCopySurvivesAnEmptyZone guards the copy against the blank-noun defect
|
||||||
|
// that W2b hit twice: a fact is allowed to arrive with fields missing, and the
|
||||||
|
// result must still read as a sentence rather than "Josie fell in ".
|
||||||
|
func TestAlertCopySurvivesAnEmptyZone(t *testing.T) {
|
||||||
|
a, ok := advAlertFor(storage.AdvEvent{EventType: "death", Subject: "Josie"}, "Josie")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("death with no zone produced no alert")
|
||||||
|
}
|
||||||
|
if a.Title != "Josie fell in the dark" {
|
||||||
|
t.Errorf("title = %q; a missing zone must still read as a sentence", a.Title)
|
||||||
|
}
|
||||||
|
// Same for a siege with no boss name on the fact.
|
||||||
|
b, _ := advAlertFor(storage.AdvEvent{EventType: "siege_win"}, "")
|
||||||
|
if b.Body != "It went down. See how the town did." {
|
||||||
|
t.Errorf("body = %q; a nameless boss must still read as a sentence", b.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnsignedContractKeepsItsSecret. The anonymity of an unsigned mischief
|
||||||
|
// contract is the mechanic — the buyer's name is the reward for surviving it. A
|
||||||
|
// notification that leaked it would hand the payoff to the target for free.
|
||||||
|
func TestUnsignedContractKeepsItsSecret(t *testing.T) {
|
||||||
|
signed, _ := advAlertFor(storage.AdvEvent{
|
||||||
|
EventType: "mischief_contract", Subject: "Josie", Opponent: "Quack",
|
||||||
|
}, "Josie")
|
||||||
|
if !strings.Contains(signed.Body, "Quack") {
|
||||||
|
t.Errorf("a signed contract hid its buyer: %q", signed.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
anon, _ := advAlertFor(storage.AdvEvent{EventType: "mischief_contract", Subject: "Josie"}, "Josie")
|
||||||
|
if strings.Contains(anon.Body, "Quack") || !strings.Contains(anon.Body, "isn't saying who") {
|
||||||
|
t.Errorf("an unsigned contract gave something away: %q", anon.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCategoriesDefaultToNothing pins the consent model. A user who turned on
|
||||||
|
// news notifications has not asked to be told about the game, and every way the
|
||||||
|
// preference can be missing or broken must mean "no".
|
||||||
|
func TestCategoriesDefaultToNothing(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
_ = s
|
||||||
|
|
||||||
|
for name, blob := range map[string]string{
|
||||||
|
"no prefs row at all": "",
|
||||||
|
"prefs but no key": `{"pete.weather.loc.v1":"\"London\""}`,
|
||||||
|
"key is not JSON": `{"pete.advPush.v1":"not json at all"}`,
|
||||||
|
"key is a JSON null": `{"pete.advPush.v1":null}`,
|
||||||
|
"all boxes unchecked": `{"pete.advPush.v1":"{\"siege\":false,\"run\":false}"}`,
|
||||||
|
} {
|
||||||
|
if blob != "" {
|
||||||
|
if err := storage.PutUserPrefs("sub-1", blob, "Josie", "j@example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := advCategoriesFor("sub-1"); len(got) != 0 {
|
||||||
|
t.Errorf("%s: enabled %v, want nothing", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCategoriesReadTheDoubleEncodedBlob. The client mirrors localStorage, whose
|
||||||
|
// values are strings, so the stored map arrives as JSON inside a JSON string.
|
||||||
|
// Both that shape and a bare object must parse — the bare form is what a
|
||||||
|
// hand-edited or migrated blob looks like.
|
||||||
|
func TestCategoriesReadTheDoubleEncodedBlob(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
_ = s
|
||||||
|
|
||||||
|
nested, _ := json.Marshal(map[string]string{
|
||||||
|
advPrefsKey: `{"siege":true,"run":true,"departure":false}`,
|
||||||
|
})
|
||||||
|
if err := storage.PutUserPrefs("sub-1", string(nested), "Josie", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := advCategoriesFor("sub-1")
|
||||||
|
if !got[advCatSiege] || !got[advCatRun] {
|
||||||
|
t.Errorf("double-encoded blob parsed to %v, want siege+run", got)
|
||||||
|
}
|
||||||
|
if got[advCatDeparture] {
|
||||||
|
t.Error("an explicitly false category was treated as enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
bare := `{"pete.advPush.v1":{"contract":true}}`
|
||||||
|
if err := storage.PutUserPrefs("sub-2", bare, "Quack", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := advCategoriesFor("sub-2"); !got[advCatContract] {
|
||||||
|
t.Errorf("bare-object blob parsed to %v, want contract", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirstPassNeverReplaysTheBacklog is the deploy safety valve.
|
||||||
|
//
|
||||||
|
// Every subscription that exists when this ships carries watermark 0. Read
|
||||||
|
// literally that means "has never been told anything", and the first tick would
|
||||||
|
// page every subscriber for every dispatch Pete has ever stored — on the same
|
||||||
|
// pass, from a feature they never switched on. The seeding branch stamps those
|
||||||
|
// rows to now and sends nothing, so alerts begin at the next real dispatch.
|
||||||
|
func TestFirstPassNeverReplaysTheBacklog(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
|
||||||
|
// A subscriber with everything switched on and a character on the board —
|
||||||
|
// i.e. the person most exposed to a replay.
|
||||||
|
seedSubscriberWithEverythingOn(t, "sub-1", "josie", "Josie")
|
||||||
|
|
||||||
|
// A realm with history, all of it well before now.
|
||||||
|
old := time.Now().Add(-90 * 24 * time.Hour).Unix()
|
||||||
|
for i, kind := range []string{"death", "zone_clear", "siege_start", "departure"} {
|
||||||
|
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||||||
|
GUID: string(rune('a'+i)) + "-guid", EventType: kind, Subject: "Josie",
|
||||||
|
Boss: "The Hollow King", Zone: "The Crypt", OccurredAt: old + int64(i),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now().Unix()
|
||||||
|
// No push service is reachable from a test, so a send would surface as an
|
||||||
|
// error rather than silence. What this asserts is that we never get that far:
|
||||||
|
// the watermark is seeded and the pass returns having considered nobody.
|
||||||
|
s.sendAdventureAlerts()
|
||||||
|
|
||||||
|
subs, err := storage.ListPushSubscriptions()
|
||||||
|
if err != nil || len(subs) != 1 {
|
||||||
|
t.Fatalf("read back %d subscriptions (err %v), want 1", len(subs), err)
|
||||||
|
}
|
||||||
|
if subs[0].LastAdvNotifiedAt < before {
|
||||||
|
t.Fatalf("watermark = %d, want >= %d: a 0 watermark must be stamped to now, not read as 'tell them everything'",
|
||||||
|
subs[0].LastAdvNotifiedAt, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the pass after it is quiet, because everything in the realm is now
|
||||||
|
// behind the watermark.
|
||||||
|
s.sendAdventureAlerts()
|
||||||
|
subs, _ = storage.ListPushSubscriptions()
|
||||||
|
if subs[0].LastAdvNotifiedAt < before {
|
||||||
|
t.Error("second pass moved the watermark backwards")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCategoriesOffStillAdvanceTheWatermark. Someone with push on but no
|
||||||
|
// adventure categories must not accumulate a backlog: switching a category on
|
||||||
|
// should start from that moment, not replay everything it was off for.
|
||||||
|
func TestCategoriesOffStillAdvanceTheWatermark(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
|
||||||
|
if err := storage.AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Move off the seeding branch so the pass actually evaluates this row.
|
||||||
|
if err := storage.TouchAdvPushSubscription("https://push.example/ep", 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||||||
|
GUID: "g1", EventType: "siege_start", Boss: "The Hollow King", OccurredAt: 5000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendAdventureAlerts()
|
||||||
|
|
||||||
|
subs, _ := storage.ListPushSubscriptions()
|
||||||
|
if len(subs) != 1 || subs[0].LastAdvNotifiedAt != 5000 {
|
||||||
|
t.Fatalf("watermark = %d, want 5000: a subscriber with nothing enabled must still move past what they were not told",
|
||||||
|
subs[0].LastAdvNotifiedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSubscriberWithEverythingOn wires the full happy path: a push endpoint, a
|
||||||
|
// character on the board with an owner, and every alert category enabled.
|
||||||
|
func seedSubscriberWithEverythingOn(t *testing.T, sub, localpart, character string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := storage.AddPushSubscription(sub, localpart, "https://push.example/"+sub, "p", "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
blob, _ := json.Marshal(map[string]string{
|
||||||
|
advPrefsKey: `{"siege":true,"run":true,"departure":true,"contract":true}`,
|
||||||
|
})
|
||||||
|
if err := storage.PutUserPrefs(sub, string(blob), character, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := storage.ReplaceRoster([]storage.RosterEntry{{
|
||||||
|
Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle",
|
||||||
|
}}, time.Now().Unix()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{
|
||||||
|
Localpart: localpart, Token: "tok-" + localpart,
|
||||||
|
}}, time.Now().Unix()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,40 +180,10 @@ func (s *Server) sendPush(sub storage.PushSubscription, payload []byte) (gone bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// disabledSourcesFor returns the set of source names a user has hidden, read
|
// disabledSourcesFor returns the set of source names a user has hidden, read
|
||||||
// from their stored prefs blob. The blob mirrors localStorage: a JSON object
|
// from their stored prefs blob. Any parse failure yields an empty (deny-nothing)
|
||||||
// whose "pete.disabledSources.v1" value is itself a JSON string encoding a
|
// set — see userPrefBoolSet for the blob's shape and why it is double-encoded.
|
||||||
// {sourceName: true} map. Any parse failure yields an empty (deny-nothing) set.
|
|
||||||
func disabledSourcesFor(sub string) map[string]bool {
|
func disabledSourcesFor(sub string) map[string]bool {
|
||||||
out := map[string]bool{}
|
return userPrefBoolSet(sub, "pete.disabledSources.v1")
|
||||||
blob, err := storage.GetUserPrefs(sub)
|
|
||||||
if err != nil || blob == "" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
var prefs map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal([]byte(blob), &prefs); err != nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
raw, ok := prefs["pete.disabledSources.v1"]
|
|
||||||
if !ok {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
// The value is normally a JSON *string* containing JSON; unwrap that first,
|
|
||||||
// but tolerate a bare object too.
|
|
||||||
inner := []byte(raw)
|
|
||||||
var asStr string
|
|
||||||
if err := json.Unmarshal(raw, &asStr); err == nil {
|
|
||||||
inner = []byte(asStr)
|
|
||||||
}
|
|
||||||
var set map[string]bool
|
|
||||||
if err := json.Unmarshal(inner, &set); err != nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for name, on := range set {
|
|
||||||
if on {
|
|
||||||
out[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushClient returns the shared SSRF-guarded, timeout-bounded HTTP client used
|
// pushClient returns the shared SSRF-guarded, timeout-bounded HTTP client used
|
||||||
|
|||||||
+46
-1
@@ -47,7 +47,11 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, `{"error":"invalid endpoint"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid endpoint"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := storage.AddPushSubscription(u.Sub, req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil {
|
// The localpart is captured here because this is the only place the mapping is
|
||||||
|
// available: the alert sender runs on a ticker with no session to read. It may
|
||||||
|
// be empty for a session minted before the game economy existed — that costs
|
||||||
|
// only the owner-scoped alerts, and heals on the next re-subscribe.
|
||||||
|
if err := storage.AddPushSubscription(u.Sub, buyerLocalpart(u), req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil {
|
||||||
slog.Error("push: subscribe failed", "sub", u.Sub, "err", err)
|
slog.Error("push: subscribe failed", "sub", u.Sub, "err", err)
|
||||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -55,6 +59,47 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handlePushHeal fills in the Matrix handle on a subscription stored before the
|
||||||
|
// column existed. W6 shipped owner-scoped adventure alerts keyed on the
|
||||||
|
// localpart, and every row that predates it carries an empty one — so those
|
||||||
|
// subscribers get the realm-wide Siege alerts and silently never get the ones
|
||||||
|
// about their own adventurer. Nothing in the browser re-subscribes on its own
|
||||||
|
// (pwa.js only calls subscribe() on a click), so without this they stay broken
|
||||||
|
// until they happen to toggle notifications off and on again.
|
||||||
|
//
|
||||||
|
// It takes only an endpoint, and it is deliberately not a subscribe: see
|
||||||
|
// HealPushSubscriptionLocalpart on why re-using the upsert here would have
|
||||||
|
// silenced the digest for anybody who reads the site regularly.
|
||||||
|
func (s *Server) handlePushHeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.cfg.Push.Enabled {
|
||||||
|
http.Error(w, `{"error":"push disabled"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
}
|
||||||
|
if !decodeStateBodyN(w, r, &req, maxPushBodyBytes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Endpoint == "" {
|
||||||
|
http.Error(w, `{"error":"incomplete subscription"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 204 whether or not a row moved. The client asks once per endpoint and has
|
||||||
|
// nothing to do with the answer, and reporting a miss would tell a caller
|
||||||
|
// whether somebody else's endpoint is on file.
|
||||||
|
if err := storage.HealPushSubscriptionLocalpart(u.Sub, req.Endpoint, buyerLocalpart(u)); err != nil {
|
||||||
|
slog.Error("push: heal failed", "sub", u.Sub, "err", err)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// handlePushUnsubscribe drops the caller's own stored subscription by endpoint.
|
// handlePushUnsubscribe drops the caller's own stored subscription by endpoint.
|
||||||
// The delete is scoped to the signed-in user so one account can't remove
|
// The delete is scoped to the signed-in user so one account can't remove
|
||||||
// another's subscription by presenting its endpoint string.
|
// another's subscription by presenting its endpoint string.
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The realm pages: the world map, the board, and the hall of firsts.
|
||||||
|
//
|
||||||
|
// Everything Pete has published so far is either the present moment (the roster,
|
||||||
|
// the Siege bar) or one thing that happened (a dispatch, a run report). None of
|
||||||
|
// it says what this place IS. A visitor who reads every dispatch on the site
|
||||||
|
// still cannot answer "how many zones are there", "is the Drowned Star harder
|
||||||
|
// than the Sunken Vault", or "has anybody ever actually beaten it" — and those
|
||||||
|
// are the questions that turn a feed of incidents into a world.
|
||||||
|
//
|
||||||
|
// The three pages are one snapshot because they are one question. Every number
|
||||||
|
// on all three comes off the same scan of the same run history on the game box;
|
||||||
|
// splitting the wire would mean three ways for the same fact to disagree with
|
||||||
|
// itself depending on which page you were standing on.
|
||||||
|
//
|
||||||
|
// The map is deliberately NOT who_map.go's layout engine, which the plan
|
||||||
|
// expected it to be. That engine lays out a *graph* — nodes joined by edges, at
|
||||||
|
// BFS depth from an entrance — and the realm has no edges. Zones are not
|
||||||
|
// connected to each other; you pick one from town and go. Forcing a graph layout
|
||||||
|
// onto a set would have produced a picture that implied a topology the game does
|
||||||
|
// not have. What the realm has instead is an *order*, by difficulty, and that is
|
||||||
|
// what gets drawn: tier bands, hardest last.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// realmStaleAfter — how old the snapshot may get before the pages stop
|
||||||
|
// claiming the occupant dots are live. gogobee recomputes the realm every ten
|
||||||
|
// minutes rather than every two (it is aggregate scans over the whole run
|
||||||
|
// history, and a first clear does not move), so the window is proportionally
|
||||||
|
// wider: several missed pushes, not one unlucky one.
|
||||||
|
realmStaleAfter = 45 * time.Minute
|
||||||
|
|
||||||
|
// Payload bounds. A realm has tens of zones, tens of players, and a first per
|
||||||
|
// zone plus a first per treasure; these only stop a malformed or hostile push
|
||||||
|
// spooling unbounded rows.
|
||||||
|
realmMaxZones = 500
|
||||||
|
realmMaxFirsts = 5000
|
||||||
|
realmMaxStandings = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
// realmPush is the payload gogobee POSTs to /api/ingest/realm.
|
||||||
|
type realmPush struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
storage.Realm
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmZoneView is one zone as the map draws it: gogobee's facts plus the few
|
||||||
|
// presentational calls Pete is allowed to make.
|
||||||
|
type RealmZoneView struct {
|
||||||
|
storage.RealmZone
|
||||||
|
Cleared bool // anybody, ever
|
||||||
|
Unbeaten bool // nobody, ever — the ominous state
|
||||||
|
FirstWhen string // "Mar 4, 2026", empty when unknown
|
||||||
|
Levels string // "levels 5–8", or "level 5" when the band is one wide
|
||||||
|
Busy bool // somebody is in there right now
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmTierView is one difficulty band of the map. The band is the unit the page
|
||||||
|
// draws in, because difficulty order is the only real structure the realm has.
|
||||||
|
type RealmTierView struct {
|
||||||
|
Tier int
|
||||||
|
Label string
|
||||||
|
Blurb string
|
||||||
|
Postgame bool
|
||||||
|
Zones []RealmZoneView
|
||||||
|
Cleared int // zones in this band somebody has beaten
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmView is the map page.
|
||||||
|
type RealmView struct {
|
||||||
|
Known bool // gogobee has pushed at least one snapshot
|
||||||
|
Stale bool
|
||||||
|
Tiers []RealmTierView
|
||||||
|
ZoneCount int
|
||||||
|
ClearedZones int
|
||||||
|
Unbeaten int
|
||||||
|
OutThere int // adventurers on expedition right now, across the whole realm
|
||||||
|
SnapshotAt int64
|
||||||
|
LastSeenAgo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmStandingView is one line of the board.
|
||||||
|
type RealmStandingView struct {
|
||||||
|
storage.RealmStanding
|
||||||
|
Rank int
|
||||||
|
Deaths int // Pete's own count, from the dispatches he filed — see DeathsBySubject
|
||||||
|
}
|
||||||
|
|
||||||
|
// StandingsView is the board page.
|
||||||
|
type StandingsView struct {
|
||||||
|
Known bool
|
||||||
|
Stale bool
|
||||||
|
Rows []RealmStandingView
|
||||||
|
PeteWins int
|
||||||
|
PeteLosses int
|
||||||
|
PeteFought bool // he has a record at all; zero-zero renders as "no bouts yet"
|
||||||
|
SnapshotAt int64
|
||||||
|
LastSeenAgo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmFirstView is one entry in the hall.
|
||||||
|
type RealmFirstView struct {
|
||||||
|
storage.RealmFirst
|
||||||
|
When string
|
||||||
|
Kind string // the raw kind, kept for the CSS hook
|
||||||
|
Label string // "First through" / "First to hold" — reads as a sentence
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstsView is the hall of firsts page, grouped by year so a long ledger reads
|
||||||
|
// as a history rather than as a list.
|
||||||
|
type FirstsView struct {
|
||||||
|
Known bool
|
||||||
|
Stale bool
|
||||||
|
Years []RealmFirstYear
|
||||||
|
Total int
|
||||||
|
Zones int
|
||||||
|
Others int
|
||||||
|
SnapshotAt int64
|
||||||
|
LastSeenAgo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmFirstYear is one year's worth of firsts, newest year first.
|
||||||
|
type RealmFirstYear struct {
|
||||||
|
Year int
|
||||||
|
Firsts []RealmFirstView
|
||||||
|
}
|
||||||
|
|
||||||
|
type realmPage struct {
|
||||||
|
pageData
|
||||||
|
Realm RealmView
|
||||||
|
}
|
||||||
|
|
||||||
|
type standingsPage struct {
|
||||||
|
pageData
|
||||||
|
Standings StandingsView
|
||||||
|
}
|
||||||
|
|
||||||
|
type firstsPage struct {
|
||||||
|
pageData
|
||||||
|
Firsts FirstsView
|
||||||
|
}
|
||||||
|
|
||||||
|
// realmTierLabels names the difficulty bands. gogobee's zone tiers are 1–6 and
|
||||||
|
// the sixth is the postgame; the labels are the game's own words for them.
|
||||||
|
var realmTierLabels = map[int]struct{ label, blurb string }{
|
||||||
|
1: {"Tier I · The Outskirts", "Where everybody starts. Close enough to town to walk back from."},
|
||||||
|
2: {"Tier II · The Reaches", "Further out, and the road stops being a road."},
|
||||||
|
3: {"Tier III · The Deep Country", "Long enough that you camp. Bring supplies you don't think you'll need."},
|
||||||
|
4: {"Tier IV · The Far Places", "Multi-region crossings. People come back from these different."},
|
||||||
|
5: {"Tier V · The Last Doors", "The end of the map as it was drawn. Very few have seen all of these."},
|
||||||
|
6: {"Mythic · The Postgame", "Sealed until you're level 18 and have put down both Tier V bosses. It does not get easier past here."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRealmIngest replaces the realm with gogobee's latest snapshot.
|
||||||
|
func (s *Server) handleRealmIngest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var push realmPush
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&push); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Zones) > realmMaxZones {
|
||||||
|
http.Error(w, "zone list too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Firsts) > realmMaxFirsts {
|
||||||
|
http.Error(w, "firsts ledger too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Standings) > realmMaxStandings {
|
||||||
|
http.Error(w, "standings too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if push.SnapshotAt <= 0 {
|
||||||
|
push.SnapshotAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never trust the channel with a name. A nameless row renders as a blank line
|
||||||
|
// on a public page, so it is rejected rather than drawn — the same rule the
|
||||||
|
// siege muster applies to its defenders.
|
||||||
|
for i, z := range push.Zones {
|
||||||
|
if z.ID == "" || z.Display == "" {
|
||||||
|
http.Error(w, fmt.Sprintf("zone %d: id and display are required", i), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for j, o := range z.Occupants {
|
||||||
|
if o.Name == "" {
|
||||||
|
http.Error(w, fmt.Sprintf("zone %d occupant %d: name is required", i, j), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, st := range push.Standings {
|
||||||
|
if st.Name == "" {
|
||||||
|
http.Error(w, fmt.Sprintf("standing %d: name is required", i), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A first with no display would render as an empty row in the history book.
|
||||||
|
// Unlike a name this one Pete can repair himself — gogobee already falls back
|
||||||
|
// to the raw target for a kind it has no words for, and doing the same here
|
||||||
|
// means a future first kind can never blank a row.
|
||||||
|
for i := range push.Firsts {
|
||||||
|
if push.Firsts[i].Display == "" {
|
||||||
|
push.Firsts[i].Display = push.Firsts[i].Target
|
||||||
|
}
|
||||||
|
if push.Firsts[i].Display == "" {
|
||||||
|
http.Error(w, fmt.Sprintf("first %d: target is required", i), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.ReplaceRealm(push.Realm, push.SnapshotAt); err != nil {
|
||||||
|
slog.Error("realm ingest: replace failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("realm ingest: realm replaced",
|
||||||
|
"zones", len(push.Zones), "firsts", len(push.Firsts), "standings", len(push.Standings))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRealmSnapshot reads the snapshot once and reports staleness. All three
|
||||||
|
// pages go through it so they can never disagree about how old the realm is.
|
||||||
|
func (s *Server) loadRealmSnapshot() (storage.Realm, bool, bool) {
|
||||||
|
snap, known, err := storage.LoadRealm()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("realm: load failed", "err", err)
|
||||||
|
return storage.Realm{}, false, true
|
||||||
|
}
|
||||||
|
stale := !known || snap.SnapshotAt == 0 ||
|
||||||
|
time.Since(time.Unix(snap.SnapshotAt, 0)) > realmStaleAfter
|
||||||
|
return snap, known, stale
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRealmPage serves the world map.
|
||||||
|
func (s *Server) handleRealmPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.track(r, "adventure")
|
||||||
|
base := s.base(r)
|
||||||
|
base.Active = "adventure"
|
||||||
|
s.render(w, "realm", realmPage{pageData: base, Realm: s.realm()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// realm builds the map view.
|
||||||
|
func (s *Server) realm() RealmView {
|
||||||
|
snap, known, stale := s.loadRealmSnapshot()
|
||||||
|
v := RealmView{Known: known, Stale: stale, SnapshotAt: snap.SnapshotAt}
|
||||||
|
if snap.SnapshotAt > 0 {
|
||||||
|
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
byTier := map[int][]RealmZoneView{}
|
||||||
|
for _, z := range snap.Zones {
|
||||||
|
zv := RealmZoneView{
|
||||||
|
RealmZone: z,
|
||||||
|
// Cleared is Clears > 0, NOT "FirstClearBy is set". The two come
|
||||||
|
// apart exactly when the first clearer opted out: the zone has been
|
||||||
|
// beaten and the claim stands, it just has no name on it. Keying the
|
||||||
|
// ominous never-beaten styling off the name would make an
|
||||||
|
// anonymisation look like a fact about the world.
|
||||||
|
Cleared: z.Clears > 0,
|
||||||
|
Busy: len(z.Occupants) > 0,
|
||||||
|
Levels: levelBand(z.LevelMin, z.LevelMax),
|
||||||
|
}
|
||||||
|
zv.Unbeaten = !zv.Cleared
|
||||||
|
if z.FirstClearAt > 0 {
|
||||||
|
zv.FirstWhen = time.Unix(z.FirstClearAt, 0).UTC().Format("Jan 2, 2006")
|
||||||
|
}
|
||||||
|
byTier[z.Tier] = append(byTier[z.Tier], zv)
|
||||||
|
|
||||||
|
v.ZoneCount++
|
||||||
|
if zv.Cleared {
|
||||||
|
v.ClearedZones++
|
||||||
|
} else {
|
||||||
|
v.Unbeaten++
|
||||||
|
}
|
||||||
|
v.OutThere += len(z.Occupants)
|
||||||
|
}
|
||||||
|
|
||||||
|
tiers := make([]int, 0, len(byTier))
|
||||||
|
for t := range byTier {
|
||||||
|
tiers = append(tiers, t)
|
||||||
|
}
|
||||||
|
sort.Ints(tiers)
|
||||||
|
for _, t := range tiers {
|
||||||
|
tv := RealmTierView{Tier: t, Zones: byTier[t], Postgame: t >= 6}
|
||||||
|
if lbl, ok := realmTierLabels[t]; ok {
|
||||||
|
tv.Label, tv.Blurb = lbl.label, lbl.blurb
|
||||||
|
} else {
|
||||||
|
// A tier the labels don't know about still draws, with an honest
|
||||||
|
// generic heading rather than an empty one. Same degrade-don't-drop
|
||||||
|
// rule as an untemplated dispatch.
|
||||||
|
tv.Label = fmt.Sprintf("Tier %d", t)
|
||||||
|
}
|
||||||
|
for _, z := range tv.Zones {
|
||||||
|
if z.Cleared {
|
||||||
|
tv.Cleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.Tiers = append(v.Tiers, tv)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStandingsPage serves the board.
|
||||||
|
func (s *Server) handleStandingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.track(r, "adventure")
|
||||||
|
base := s.base(r)
|
||||||
|
base.Active = "adventure"
|
||||||
|
s.render(w, "standings", standingsPage{pageData: base, Standings: s.standings()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// standings builds the board view.
|
||||||
|
//
|
||||||
|
// The rank is gogobee's push order, not anything computed here: the ordering
|
||||||
|
// ("deepest tier beaten, then how much of the realm you have beaten") is a
|
||||||
|
// statement about what the game values, and the game is the thing entitled to
|
||||||
|
// make it. Pete's job is to draw it and to add the two columns the game box
|
||||||
|
// cannot answer — the death count and Pete's own record.
|
||||||
|
func (s *Server) standings() StandingsView {
|
||||||
|
snap, known, stale := s.loadRealmSnapshot()
|
||||||
|
v := StandingsView{Known: known, Stale: stale, SnapshotAt: snap.SnapshotAt}
|
||||||
|
if snap.SnapshotAt > 0 {
|
||||||
|
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
deaths, err := storage.DeathsBySubject()
|
||||||
|
if err != nil {
|
||||||
|
// A missing death column is a missing column. It is not worth failing the
|
||||||
|
// whole board over, and a zero would be a lie, so the template renders a
|
||||||
|
// dash for anyone not in the map — which is what an absent map produces.
|
||||||
|
slog.Error("standings: death counts", "err", err)
|
||||||
|
deaths = nil
|
||||||
|
}
|
||||||
|
for i, st := range snap.Standings {
|
||||||
|
v.Rows = append(v.Rows, RealmStandingView{
|
||||||
|
RealmStanding: st,
|
||||||
|
Rank: i + 1,
|
||||||
|
Deaths: deaths[st.Name],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if w, l, err := storage.PeteDuelRecord(); err != nil {
|
||||||
|
slog.Error("standings: pete duel record", "err", err)
|
||||||
|
} else {
|
||||||
|
v.PeteWins, v.PeteLosses = w, l
|
||||||
|
v.PeteFought = w+l > 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFirstsPage serves the hall of firsts.
|
||||||
|
func (s *Server) handleFirstsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.track(r, "adventure")
|
||||||
|
base := s.base(r)
|
||||||
|
base.Active = "adventure"
|
||||||
|
s.render(w, "firsts", firstsPage{pageData: base, Firsts: s.firsts()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// firsts builds the hall.
|
||||||
|
//
|
||||||
|
// gogobee pushes the ledger oldest-first, which is the order it happened in. The
|
||||||
|
// page reverses it into newest-year-first, because a history book that opens on
|
||||||
|
// the oldest page is an archive and this is meant to read as "look what has been
|
||||||
|
// happening" — but within a year it stays chronological, so a year reads forward
|
||||||
|
// the way a year did.
|
||||||
|
func (s *Server) firsts() FirstsView {
|
||||||
|
snap, known, stale := s.loadRealmSnapshot()
|
||||||
|
v := FirstsView{Known: known, Stale: stale, SnapshotAt: snap.SnapshotAt}
|
||||||
|
if snap.SnapshotAt > 0 {
|
||||||
|
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
byYear := map[int][]RealmFirstView{}
|
||||||
|
for _, f := range snap.Firsts {
|
||||||
|
fv := RealmFirstView{RealmFirst: f, Kind: f.Kind}
|
||||||
|
switch f.Kind {
|
||||||
|
case "zone":
|
||||||
|
fv.Label = "First through"
|
||||||
|
case "treasure":
|
||||||
|
fv.Label = "First to hold"
|
||||||
|
default:
|
||||||
|
fv.Label = "First"
|
||||||
|
}
|
||||||
|
year := 0
|
||||||
|
if f.AtUnix > 0 {
|
||||||
|
t := time.Unix(f.AtUnix, 0).UTC()
|
||||||
|
fv.When = t.Format("Jan 2, 2006")
|
||||||
|
year = t.Year()
|
||||||
|
}
|
||||||
|
byYear[year] = append(byYear[year], fv)
|
||||||
|
|
||||||
|
v.Total++
|
||||||
|
if f.Kind == "zone" {
|
||||||
|
v.Zones++
|
||||||
|
} else {
|
||||||
|
v.Others++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
years := make([]int, 0, len(byYear))
|
||||||
|
for y := range byYear {
|
||||||
|
years = append(years, y)
|
||||||
|
}
|
||||||
|
// Newest year first. Year 0 is "the ledger has no date for this", which
|
||||||
|
// sorts last — an undated first is real but it is not news.
|
||||||
|
sort.Sort(sort.Reverse(sort.IntSlice(years)))
|
||||||
|
for _, y := range years {
|
||||||
|
v.Years = append(v.Years, RealmFirstYear{Year: y, Firsts: byYear[y]})
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelBand renders a zone's level range as words. A one-wide band ("levels
|
||||||
|
// 5–5") reads as a typo, so it collapses to "level 5"; a band with no numbers at
|
||||||
|
// all renders as nothing rather than as "levels 0–0".
|
||||||
|
func levelBand(min, max int) string {
|
||||||
|
switch {
|
||||||
|
case min <= 0 && max <= 0:
|
||||||
|
return ""
|
||||||
|
case min == max:
|
||||||
|
return fmt.Sprintf("level %d", min)
|
||||||
|
case min <= 0:
|
||||||
|
return fmt.Sprintf("up to level %d", max)
|
||||||
|
case max <= 0:
|
||||||
|
return fmt.Sprintf("level %d and up", min)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("levels %d–%d", min, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func postRealm(t *testing.T, s *Server, token string, push realmPush) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(push)
|
||||||
|
req := httptest.NewRequest("POST", "/api/ingest/realm", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleRealmIngest(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func zone(id, display string, tier, clears, clearers int) storage.RealmZone {
|
||||||
|
return storage.RealmZone{
|
||||||
|
ID: id, Display: display, Tier: tier,
|
||||||
|
LevelMin: tier * 3, LevelMax: tier*3 + 3,
|
||||||
|
Clears: clears, Clearers: clearers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealmReplacesNeverMerges is the realm's core contract and it is the same
|
||||||
|
// one the board and the war room have: gogobee sends the whole thing, Pete's
|
||||||
|
// copy becomes it. Everything on these pages is a *derived* answer recomputed
|
||||||
|
// upstream — a clear count, a first-clearer, who is inside — so a merge would
|
||||||
|
// let a correction upstream leave a wrong number here permanently, and an
|
||||||
|
// occupant who came home would never leave the map.
|
||||||
|
func TestRealmReplacesNeverMerges(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
first := zone("warren", "Goblin Warren", 1, 4, 2)
|
||||||
|
first.Occupants = []storage.RealmOccupant{{Token: "t1", Name: "Josie", Level: 9, Day: 2}}
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
|
||||||
|
Zones: []storage.RealmZone{first, zone("vault", "Sunken Vault", 2, 0, 0)},
|
||||||
|
Standings: []storage.RealmStanding{{Token: "t1", Name: "Josie", Level: 9, Clears: 4}},
|
||||||
|
Firsts: []storage.RealmFirst{{Kind: "zone", Target: "warren", Display: "Goblin Warren", AtUnix: now - 86400}},
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("first push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Josie comes home, the Vault gets beaten, and the second zone drops out of
|
||||||
|
// the push entirely (say it was retired upstream).
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now + 600, Realm: storage.Realm{
|
||||||
|
Zones: []storage.RealmZone{zone("warren", "Goblin Warren", 1, 5, 2)},
|
||||||
|
Standings: []storage.RealmStanding{{Token: "t1", Name: "Josie", Level: 10, Clears: 5}},
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("second push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.realm()
|
||||||
|
if v.ZoneCount != 1 {
|
||||||
|
t.Fatalf("zone count = %d, want 1 — a dropped zone survived the swap", v.ZoneCount)
|
||||||
|
}
|
||||||
|
if v.OutThere != 0 {
|
||||||
|
t.Errorf("out-there = %d, want 0 — an occupant who came home is still on the map", v.OutThere)
|
||||||
|
}
|
||||||
|
if got := v.Tiers[0].Zones[0].Clears; got != 5 {
|
||||||
|
t.Errorf("clears = %d, want 5 — the count didn't follow the snapshot", got)
|
||||||
|
}
|
||||||
|
if fv := s.firsts(); fv.Total != 0 {
|
||||||
|
t.Errorf("firsts total = %d, want 0 — the ledger didn't follow the snapshot", fv.Total)
|
||||||
|
}
|
||||||
|
if sv := s.standings(); len(sv.Rows) != 1 || sv.Rows[0].Level != 10 {
|
||||||
|
t.Errorf("standings didn't follow the snapshot: %+v", sv.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnonymisedFirstClearIsNotAnUnbeatenZone is the one that matters most on
|
||||||
|
// this page.
|
||||||
|
//
|
||||||
|
// gogobee anonymises an opted-out first-clearer rather than deleting the claim:
|
||||||
|
// the zone HAS been beaten and the clear counts still add up, there is just no
|
||||||
|
// name on it. If Pete keyed the ominous never-beaten styling off "is there a
|
||||||
|
// name" instead of off "are there any clears", an opt-out would silently rewrite
|
||||||
|
// the history of the realm — a place somebody conquered would be drawn as a
|
||||||
|
// place nobody has ever come out of.
|
||||||
|
func TestAnonymisedFirstClearIsNotAnUnbeatenZone(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
beaten := zone("vault", "Sunken Vault", 2, 3, 1) // cleared, but no name on it
|
||||||
|
beaten.FirstClearAt = now - 86400
|
||||||
|
untouched := zone("abyss", "Abyss Portal", 5, 0, 0)
|
||||||
|
named := zone("warren", "Goblin Warren", 1, 2, 1)
|
||||||
|
named.FirstClearBy, named.FirstClearToken = "Josie", "t1"
|
||||||
|
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
|
||||||
|
Zones: []storage.RealmZone{named, beaten, untouched},
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := map[string]RealmZoneView{}
|
||||||
|
for _, tier := range s.realm().Tiers {
|
||||||
|
for _, z := range tier.Zones {
|
||||||
|
byID[z.ID] = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if byID["vault"].Unbeaten {
|
||||||
|
t.Error("an anonymised clear drew as never-beaten — an opt-out rewrote the realm's history")
|
||||||
|
}
|
||||||
|
if !byID["vault"].Cleared {
|
||||||
|
t.Error("a zone with clears > 0 did not read as cleared")
|
||||||
|
}
|
||||||
|
if !byID["abyss"].Unbeaten {
|
||||||
|
t.Error("a zone with no clears at all did not read as unbeaten — the ominous state is the point")
|
||||||
|
}
|
||||||
|
if byID["warren"].Unbeaten {
|
||||||
|
t.Error("a named clear drew as never-beaten")
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.realm()
|
||||||
|
if v.ClearedZones != 2 || v.Unbeaten != 1 {
|
||||||
|
t.Errorf("header totals = %d cleared / %d unbeaten, want 2/1", v.ClearedZones, v.Unbeaten)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealmStaleWhenTheWireGoesQuiet. The realm is pushed every ten minutes
|
||||||
|
// rather than every two, so its staleness window is proportionally wider — but
|
||||||
|
// it still has to exist. An occupant list that stopped updating an hour ago must
|
||||||
|
// not keep claiming somebody is standing in a dungeon.
|
||||||
|
func TestRealmStaleWhenTheWireGoesQuiet(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
old := time.Now().Add(-2 * time.Hour).Unix()
|
||||||
|
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: old, Realm: storage.Realm{
|
||||||
|
Zones: []storage.RealmZone{zone("warren", "Goblin Warren", 1, 1, 1)},
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.realm()
|
||||||
|
if !v.Known {
|
||||||
|
t.Fatal("a pushed realm reads as never-pushed")
|
||||||
|
}
|
||||||
|
if !v.Stale {
|
||||||
|
t.Error("a two-hour-old realm claims to be live")
|
||||||
|
}
|
||||||
|
// All three pages share one snapshot read, so they must agree about its age.
|
||||||
|
if !s.standings().Stale || !s.firsts().Stale {
|
||||||
|
t.Error("the three realm pages disagree about how old the realm is")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnpushedRealmIsNotAnEmptyRealm. "gogobee has never pushed" and "gogobee
|
||||||
|
// pushed a realm with nothing in it" are different states and the pages say
|
||||||
|
// different things about them — the first is Pete admitting he has no survey,
|
||||||
|
// the second is a real answer about a quiet realm.
|
||||||
|
func TestUnpushedRealmIsNotAnEmptyRealm(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
|
||||||
|
if v := s.realm(); v.Known {
|
||||||
|
t.Error("an unpushed realm claims to be known")
|
||||||
|
}
|
||||||
|
if v := s.standings(); v.Known {
|
||||||
|
t.Error("unpushed standings claim to be known")
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{}}); w.Code != 200 {
|
||||||
|
t.Fatalf("empty push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
v := s.realm()
|
||||||
|
if !v.Known {
|
||||||
|
t.Error("an empty-but-pushed realm reads as never-pushed")
|
||||||
|
}
|
||||||
|
if v.ZoneCount != 0 {
|
||||||
|
t.Errorf("zone count = %d, want 0", v.ZoneCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealmIngestRejectsNamelessRows. A nameless row renders as a blank line on
|
||||||
|
// a public page. gogobee already refuses to send one (it skips a character with
|
||||||
|
// no name rather than falling back to a Matrix handle), so this is the wire
|
||||||
|
// refusing to be the thing that puts a hole in the page.
|
||||||
|
func TestRealmIngestRejectsNamelessRows(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
nameless := zone("warren", "Goblin Warren", 1, 1, 1)
|
||||||
|
nameless.Occupants = []storage.RealmOccupant{{Token: "t1", Name: ""}}
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
|
||||||
|
Zones: []storage.RealmZone{nameless},
|
||||||
|
}}); w.Code != 400 {
|
||||||
|
t.Errorf("nameless occupant = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
|
||||||
|
Standings: []storage.RealmStanding{{Token: "t1", Name: ""}},
|
||||||
|
}}); w.Code != 400 {
|
||||||
|
t.Errorf("nameless standing = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
|
||||||
|
Zones: []storage.RealmZone{{ID: "", Display: "Nowhere"}},
|
||||||
|
}}); w.Code != 400 {
|
||||||
|
t.Errorf("idless zone = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownFirstKindStillGetsIntoTheHall. The ledger is open-ended: gogobee
|
||||||
|
// claims a realm-first on (kind, target) and nothing stops a third kind shipping
|
||||||
|
// later. A first Pete has no words for is still a thing that happened exactly
|
||||||
|
// once, so it renders with a generic label and its raw target as its name — the
|
||||||
|
// same degrade-don't-drop rule W0 settled on for an untemplated event_type. A
|
||||||
|
// missing display is repaired at ingest rather than rejected.
|
||||||
|
func TestUnknownFirstKindStillGetsIntoTheHall(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
|
||||||
|
Firsts: []storage.RealmFirst{
|
||||||
|
{Kind: "zone", Target: "warren", Display: "Goblin Warren", Holder: "Josie", Token: "t1", AtUnix: now - 86400},
|
||||||
|
{Kind: "hat", Target: "very_big_hat", AtUnix: now - 3600}, // no display: repaired, not rejected
|
||||||
|
},
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.firsts()
|
||||||
|
if v.Total != 2 {
|
||||||
|
t.Fatalf("hall has %d entries, want 2 — an unknown kind was dropped", v.Total)
|
||||||
|
}
|
||||||
|
var hat *RealmFirstView
|
||||||
|
for i := range v.Years {
|
||||||
|
for j := range v.Years[i].Firsts {
|
||||||
|
if v.Years[i].Firsts[j].Kind == "hat" {
|
||||||
|
hat = &v.Years[i].Firsts[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hat == nil {
|
||||||
|
t.Fatal("the unknown-kind first is not in any year")
|
||||||
|
}
|
||||||
|
if hat.Display != "very_big_hat" {
|
||||||
|
t.Errorf("display = %q, want the raw target — a blank row is worse than an ugly one", hat.Display)
|
||||||
|
}
|
||||||
|
if hat.Label == "" {
|
||||||
|
t.Error("an unknown kind got no label at all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirstsAreNewestYearFirstButChronologicalWithinAYear. A history book that
|
||||||
|
// opens on the oldest page is an archive; this is meant to read as "look what
|
||||||
|
// has been happening". Within a year it stays forward-ordered, the way a year
|
||||||
|
// did. An undated entry sorts to the bottom — it is real, but it is not news.
|
||||||
|
func TestFirstsAreNewestYearFirstButChronologicalWithinAYear(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
y2025 := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).Unix()
|
||||||
|
y2026a := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC).Unix()
|
||||||
|
y2026b := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix()
|
||||||
|
|
||||||
|
// Pushed oldest-first, which is how gogobee sends it.
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{
|
||||||
|
Firsts: []storage.RealmFirst{
|
||||||
|
{Kind: "zone", Target: "undated", Display: "Somewhere", AtUnix: 0},
|
||||||
|
{Kind: "zone", Target: "a", Display: "First Place", AtUnix: y2025},
|
||||||
|
{Kind: "zone", Target: "b", Display: "Second Place", AtUnix: y2026a},
|
||||||
|
{Kind: "zone", Target: "c", Display: "Third Place", AtUnix: y2026b},
|
||||||
|
},
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.firsts()
|
||||||
|
if len(v.Years) != 3 {
|
||||||
|
t.Fatalf("got %d year groups, want 3 (2026, 2025, undated)", len(v.Years))
|
||||||
|
}
|
||||||
|
if v.Years[0].Year != 2026 || v.Years[1].Year != 2025 || v.Years[2].Year != 0 {
|
||||||
|
t.Fatalf("year order = %d, %d, %d; want 2026, 2025, 0",
|
||||||
|
v.Years[0].Year, v.Years[1].Year, v.Years[2].Year)
|
||||||
|
}
|
||||||
|
if got := v.Years[0].Firsts; got[0].Display != "Second Place" || got[1].Display != "Third Place" {
|
||||||
|
t.Errorf("within 2026 the order is %q then %q; want chronological", got[0].Display, got[1].Display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStandingsKeepGogobeesRank. The ordering is a statement about what the game
|
||||||
|
// values ("deepest tier beaten, then how much of the realm you have beaten") and
|
||||||
|
// the game is entitled to make it. Pete renumbers nothing — a board that
|
||||||
|
// re-sorted on a column Pete happened to find interesting would disagree with
|
||||||
|
// the game about who is ahead.
|
||||||
|
func TestStandingsKeepGogobeesRank(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{
|
||||||
|
Standings: []storage.RealmStanding{
|
||||||
|
{Token: "t1", Name: "Josie", Level: 14, DeepestTier: 5, Zones: 3, Clears: 9},
|
||||||
|
// Higher level and more clears, but shallower — and gogobee put them
|
||||||
|
// second, so second is where they render.
|
||||||
|
{Token: "t2", Name: "Quack", Level: 20, DeepestTier: 3, Zones: 8, Clears: 40},
|
||||||
|
},
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := s.standings().Rows
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("got %d rows, want 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Name != "Josie" || rows[0].Rank != 1 {
|
||||||
|
t.Errorf("rank 1 = %q (rank field %d), want Josie/1 — Pete re-sorted the game's board",
|
||||||
|
rows[0].Name, rows[0].Rank)
|
||||||
|
}
|
||||||
|
if rows[1].Rank != 2 {
|
||||||
|
t.Errorf("second row has rank %d, want 2", rows[1].Rank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPeteHasNoRecordUntilHeFilesOne. The pete_duel_win/loss templates have
|
||||||
|
// existed in the renderer since before anything emitted them, so the record
|
||||||
|
// reads zero-zero today. Zero-zero has to render as "no bouts yet" and not as a
|
||||||
|
// 0% win rate, which would be a claim about bouts that never happened.
|
||||||
|
func TestPeteHasNoRecordUntilHeFilesOne(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{}}); w.Code != 200 {
|
||||||
|
t.Fatalf("push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
v := s.standings()
|
||||||
|
if v.PeteFought {
|
||||||
|
t.Error("Pete claims a duel record with no duel dispatches filed")
|
||||||
|
}
|
||||||
|
if v.PeteWins != 0 || v.PeteLosses != 0 {
|
||||||
|
t.Errorf("record = %d-%d, want 0-0", v.PeteWins, v.PeteLosses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLevelBandReadsLikeWords. "levels 5–5" reads as a typo and "levels 0–0" as
|
||||||
|
// a bug; neither is a thing to print on a page about a place.
|
||||||
|
func TestLevelBandReadsLikeWords(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
min, max int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{5, 8, "levels 5–8"},
|
||||||
|
{5, 5, "level 5"},
|
||||||
|
{0, 0, ""},
|
||||||
|
{0, 4, "up to level 4"},
|
||||||
|
{18, 0, "level 18 and up"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := levelBand(c.min, c.max); got != c.want {
|
||||||
|
t.Errorf("levelBand(%d, %d) = %q, want %q", c.min, c.max, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealmIngestNeedsTheBearer. Same gate as every other ingest: the realm is
|
||||||
|
// public to read and authenticated to write.
|
||||||
|
func TestRealmIngestNeedsTheBearer(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
if w := postRealm(t, s, "wrong", realmPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
|
||||||
|
t.Errorf("bad bearer = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The expedition liveblog.
|
||||||
|
//
|
||||||
|
// Until now Pete only ever heard how a run *ended*: a zone cleared, a retreat, a
|
||||||
|
// death. The run itself — the fight that nearly went wrong two rooms back, the
|
||||||
|
// trap, the haul — was narrated into one Matrix DM and thrown away. The map on
|
||||||
|
// the adventurer page has always shown *where* somebody is. This shows what
|
||||||
|
// happened there, which is the half that makes it a story instead of a position.
|
||||||
|
//
|
||||||
|
// It arrives on its own channel, deliberately not the dispatch queue: beats are
|
||||||
|
// high-volume and low-stakes, and a chatty run must never be able to spend the
|
||||||
|
// retry budget a death dispatch depends on. They are also the one thing gogobee
|
||||||
|
// pushes that is history rather than state, so they append instead of replacing.
|
||||||
|
//
|
||||||
|
// The log is a *log*. Lines are short, factual and stacked; Pete does not
|
||||||
|
// narrate them. His voice is for the dispatch that gets filed when the run ends
|
||||||
|
// — a running commentary in the same register would drown it out.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// runBeatsMaxBatch bounds one push. gogobee batches on its 2-minute roster
|
||||||
|
// tick and caps itself well below this; the limit is here to stop a
|
||||||
|
// malformed or hostile payload spooling unbounded rows.
|
||||||
|
runBeatsMaxBatch = 1000
|
||||||
|
|
||||||
|
// runLogCap is how many beats the page shows. Read from the END — a log is
|
||||||
|
// read for what just happened, and a party deep into its third region would
|
||||||
|
// otherwise be showing its first morning forever.
|
||||||
|
runLogCap = 60
|
||||||
|
|
||||||
|
// runFinishedGrace is how long a finished run stays on the adventurer page.
|
||||||
|
// The interesting moment is the one right after it ends ("what happened?"),
|
||||||
|
// and that question is asked in minutes, not days. After this the page goes
|
||||||
|
// back to being a sheet.
|
||||||
|
runFinishedGrace = 6 * time.Hour
|
||||||
|
|
||||||
|
// runRetentionDays is how long a finished run's beats are kept at all.
|
||||||
|
runRetentionDays = 14
|
||||||
|
)
|
||||||
|
|
||||||
|
// runBeatsPush is the payload gogobee POSTs to /api/ingest/run.
|
||||||
|
type runBeatsPush struct {
|
||||||
|
Beats []storage.RunBeat `json:"beats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLogLine is one beat rendered for the column.
|
||||||
|
type runLogLine struct {
|
||||||
|
Emoji string
|
||||||
|
Text string
|
||||||
|
Room string // "4/9", or empty for a beat that isn't in a room
|
||||||
|
When string
|
||||||
|
Hurt bool // the party took damage or lost: worth an eye
|
||||||
|
Good bool // a find, a kill, a clear
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunLogView is the liveblog as the page draws it.
|
||||||
|
type RunLogView struct {
|
||||||
|
Has bool
|
||||||
|
Live bool
|
||||||
|
Zone string
|
||||||
|
Outcome string // "" while live
|
||||||
|
Lines []runLogLine
|
||||||
|
Rooms string // "4 / 9"
|
||||||
|
// ReportURL points at the run's permalink, and only once the run is over.
|
||||||
|
// While it is still walking the log on this page IS the report, and offering a
|
||||||
|
// link to a second copy of what somebody is already reading is just a way to
|
||||||
|
// lose them.
|
||||||
|
ReportURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunIngest stores a batch of beats.
|
||||||
|
//
|
||||||
|
// Note what is NOT rejected here: an unknown beat kind. That is the same lesson
|
||||||
|
// the dispatch ingest learned the hard way — a beat Pete has no line for is a
|
||||||
|
// styling problem, not a validity problem, and 400ing it would silently delete a
|
||||||
|
// game event and park the row upstream forever. An unknown kind is stored, and
|
||||||
|
// renders as its own bare noun rather than not at all.
|
||||||
|
func (s *Server) handleRunIngest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var push runBeatsPush
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&push); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Beats) > runBeatsMaxBatch {
|
||||||
|
http.Error(w, "batch too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
kept := make([]storage.RunBeat, 0, len(push.Beats))
|
||||||
|
for i, b := range push.Beats {
|
||||||
|
// run_id and seq ARE the row. Without both there is nothing to be
|
||||||
|
// idempotent on, and a re-send would duplicate the story.
|
||||||
|
if b.RunID == "" || b.Seq <= 0 {
|
||||||
|
http.Error(w, fmt.Sprintf("beat %d: run_id and a positive seq are required", i), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A beat with no clock can't be ordered against the rest of the run and
|
||||||
|
// would break the retention sweep, which keys on when a run ended.
|
||||||
|
if b.OccurredAt <= 0 {
|
||||||
|
b.OccurredAt = now
|
||||||
|
}
|
||||||
|
// Prose only rides the one kind that has any, and only after it clears the
|
||||||
|
// guard. A rejection drops the words and keeps the beat: the row is what
|
||||||
|
// stops gogobee re-authoring the same summary every tick forever, and a
|
||||||
|
// report with no summary is still a report.
|
||||||
|
if b.Kind == "summary" {
|
||||||
|
if !runSummaryGuard(b.Prose, runSummaryName(b)) {
|
||||||
|
slog.Warn("run ingest: prose-guard rejected run summary",
|
||||||
|
"run", b.RunID, "seq", b.Seq, "len", len(b.Prose))
|
||||||
|
b.Prose = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.Prose = ""
|
||||||
|
}
|
||||||
|
kept = append(kept, b)
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.AppendRunBeats(kept); err != nil {
|
||||||
|
slog.Error("run ingest: append failed", "err", err, "beats", len(kept))
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Debug("run ingest: beats stored", "beats", len(kept))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSummaryName is the one character name a run summary is allowed to use.
|
||||||
|
//
|
||||||
|
// The beat carries it (gogobee knows who it is writing about), but a summary
|
||||||
|
// arrives a tick or two after the run ended and could be the first beat of that
|
||||||
|
// run Pete ever sees if an earlier batch was lost — so the stored header is the
|
||||||
|
// fallback. With neither, the guard runs with an empty allow-list, which rejects
|
||||||
|
// any summary naming anyone on the board. That is the right way to fail: a
|
||||||
|
// nameless summary about a nameless run is not worth the exposure.
|
||||||
|
func runSummaryName(b storage.RunBeat) string {
|
||||||
|
if b.Name != "" {
|
||||||
|
return b.Name
|
||||||
|
}
|
||||||
|
if run, ok, err := storage.RunByID(b.RunID); err == nil && ok {
|
||||||
|
return run.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLogFor builds the liveblog for one adventurer, or an empty view when there
|
||||||
|
// is nothing worth showing.
|
||||||
|
func runLogFor(token string) RunLogView {
|
||||||
|
run, ok, err := storage.LatestRunForToken(token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("run log: header lookup failed", "err", err)
|
||||||
|
return RunLogView{}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return RunLogView{}
|
||||||
|
}
|
||||||
|
// A run that finished days ago is not news. It stays in the database — the
|
||||||
|
// dispatch that announced it links to it — but the adventurer page is about
|
||||||
|
// now, and an old log sitting under a live map reads as the live one.
|
||||||
|
if !run.Live() && time.Since(time.Unix(run.EndedAt, 0)) > runFinishedGrace {
|
||||||
|
return RunLogView{}
|
||||||
|
}
|
||||||
|
|
||||||
|
beats, err := storage.RunBeats(run.RunID, runLogCap)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("run log: beats lookup failed", "run", run.RunID, "err", err)
|
||||||
|
return RunLogView{}
|
||||||
|
}
|
||||||
|
if len(beats) == 0 {
|
||||||
|
return RunLogView{}
|
||||||
|
}
|
||||||
|
|
||||||
|
v := RunLogView{
|
||||||
|
Has: true,
|
||||||
|
Live: run.Live(),
|
||||||
|
Zone: run.Zone,
|
||||||
|
Outcome: run.Outcome,
|
||||||
|
}
|
||||||
|
if run.TotalRooms > 0 {
|
||||||
|
last := beats[len(beats)-1]
|
||||||
|
if last.Room > 0 {
|
||||||
|
v.Rooms = fmt.Sprintf("%d / %d", last.Room, run.TotalRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !v.Live {
|
||||||
|
v.ReportURL = runReportPath(run.RunID)
|
||||||
|
}
|
||||||
|
for _, b := range beats {
|
||||||
|
// The zone lives on the header, not on every beat — gogobee sends it once,
|
||||||
|
// on `start`, and the beat table has no column for it. Handing it back here
|
||||||
|
// is what stops the opening line reading "Set out into something", which is
|
||||||
|
// what a straight render of the stored row produces.
|
||||||
|
if b.Zone == "" {
|
||||||
|
b.Zone = run.Zone
|
||||||
|
}
|
||||||
|
if line, ok := renderRunBeat(b); ok {
|
||||||
|
v.Lines = append(v.Lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderRunBeat turns one beat into one line. ok is false for a beat with
|
||||||
|
// nothing to say — a haul of nothing, a room with no identity.
|
||||||
|
//
|
||||||
|
// Everything here is assembled from the beat's own nouns and numbers. gogobee
|
||||||
|
// sends no prose down this channel and Pete invents none: the point of the log
|
||||||
|
// is that it is what happened, in order, and a line that reads better than the
|
||||||
|
// facts support is a line that is lying about a run somebody actually walked.
|
||||||
|
func renderRunBeat(b storage.RunBeat) (runLogLine, bool) {
|
||||||
|
l := runLogLine{When: time.Unix(b.OccurredAt, 0).UTC().Format("15:04")}
|
||||||
|
if b.Room > 0 && b.TotalRooms > 0 {
|
||||||
|
l.Room = fmt.Sprintf("%d/%d", b.Room, b.TotalRooms)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch b.Kind {
|
||||||
|
case "summary":
|
||||||
|
// Prose about the whole run, not a moment in it. It belongs at the top of
|
||||||
|
// the report, and dropped into the middle of a log it would read as a beat
|
||||||
|
// that somehow saw the ending coming.
|
||||||
|
return runLogLine{}, false
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
l.Emoji = "🚪"
|
||||||
|
l.Text = "Set out into " + orUnknown(b.Zone)
|
||||||
|
if b.TotalRooms > 0 {
|
||||||
|
l.Text += fmt.Sprintf(" — %d rooms deep", b.TotalRooms)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "room":
|
||||||
|
l.Emoji = roomEmoji(b.RoomKind)
|
||||||
|
what, named := roomWord(b.RoomKind)
|
||||||
|
if b.Outcome == "doubled back" {
|
||||||
|
// "Doubled back to the next room" is a contradiction — the room behind
|
||||||
|
// you is the last one, not the next. Only a room with a name of its own
|
||||||
|
// is worth pointing at on the way back.
|
||||||
|
if !named {
|
||||||
|
l.Text = "Doubled back a room"
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
l.Text = "Doubled back to the " + what
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
l.Text = "Into the " + what
|
||||||
|
|
||||||
|
case "combat":
|
||||||
|
switch b.Outcome {
|
||||||
|
case "won":
|
||||||
|
l.Emoji = "⚔️"
|
||||||
|
l.Good = true
|
||||||
|
l.Text = orUnknown(b.Target) + " down"
|
||||||
|
if b.Amount > 0 {
|
||||||
|
l.Text += fmt.Sprintf(" — took %d", b.Amount)
|
||||||
|
} else {
|
||||||
|
l.Text += " — untouched"
|
||||||
|
}
|
||||||
|
case "retreat":
|
||||||
|
l.Emoji = "⏳"
|
||||||
|
l.Hurt = true
|
||||||
|
l.Text = "Outlasted by " + orUnknown(b.Target) + " — withdrew"
|
||||||
|
default:
|
||||||
|
l.Emoji = "💀"
|
||||||
|
l.Hurt = true
|
||||||
|
l.Text = "Fell to " + orUnknown(b.Target)
|
||||||
|
}
|
||||||
|
// The crown marks a boss BEATEN. On a boss that killed you it reads as
|
||||||
|
// congratulating the wrong party, so a loss keeps its skull whatever room
|
||||||
|
// it happened in.
|
||||||
|
if b.RoomKind == "boss" && b.Outcome == "won" {
|
||||||
|
l.Emoji = "👑"
|
||||||
|
} else if b.RoomKind == "elite" && b.Outcome == "won" {
|
||||||
|
l.Text = "Elite " + l.Text
|
||||||
|
}
|
||||||
|
if hp := hpTail(b); hp != "" {
|
||||||
|
l.Text += hp
|
||||||
|
}
|
||||||
|
if b.Crits > 0 {
|
||||||
|
l.Text += fmt.Sprintf(" · %s", plural(b.Crits, "critical hit", "critical hits"))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "trap":
|
||||||
|
l.Emoji = "🕳"
|
||||||
|
if b.Amount <= 0 {
|
||||||
|
l.Text = "Trap — stepped over it"
|
||||||
|
l.Good = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
l.Hurt = true
|
||||||
|
l.Text = fmt.Sprintf("Trap sprung — %d damage", b.Amount)
|
||||||
|
if hp := hpTail(b); hp != "" {
|
||||||
|
l.Text += hp
|
||||||
|
}
|
||||||
|
|
||||||
|
case "treasure":
|
||||||
|
l.Emoji = "💎"
|
||||||
|
l.Good = true
|
||||||
|
l.Text = "Found " + orUnknown(b.Target)
|
||||||
|
switch b.Outcome {
|
||||||
|
case "cache":
|
||||||
|
l.Text += " in a cache"
|
||||||
|
case "boss":
|
||||||
|
l.Text += " on the boss"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "haul":
|
||||||
|
if b.Amount <= 0 {
|
||||||
|
return runLogLine{}, false
|
||||||
|
}
|
||||||
|
l.Emoji = "🧺"
|
||||||
|
l.Text = fmt.Sprintf("Gathered %d", b.Amount)
|
||||||
|
if b.Target != "" {
|
||||||
|
l.Text += " — mostly " + b.Target
|
||||||
|
}
|
||||||
|
if b.Count > 1 {
|
||||||
|
l.Text += fmt.Sprintf(" (%d kinds)", b.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "lock":
|
||||||
|
l.Emoji = "🔒"
|
||||||
|
if b.Outcome == "picked" {
|
||||||
|
l.Good = true
|
||||||
|
l.Text = "Picked the lock"
|
||||||
|
if b.Target != "" {
|
||||||
|
l.Text += " — " + b.Target
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
l.Hurt = true
|
||||||
|
l.Text = "Every way on sealed — doubled back"
|
||||||
|
|
||||||
|
case "region":
|
||||||
|
l.Emoji = "🗺"
|
||||||
|
l.Room = "" // a border is between rooms, not in one
|
||||||
|
l.Text = "Crossed into " + orUnknown(b.Target)
|
||||||
|
if b.Region != "" {
|
||||||
|
l.Text = "Left " + b.Region + " for " + orUnknown(b.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "end":
|
||||||
|
switch b.Outcome {
|
||||||
|
case "cleared":
|
||||||
|
l.Emoji = "🏆"
|
||||||
|
l.Good = true
|
||||||
|
l.Text = "Run complete"
|
||||||
|
case "died":
|
||||||
|
l.Emoji = "💀"
|
||||||
|
l.Hurt = true
|
||||||
|
l.Text = "Run ended — didn't make it out"
|
||||||
|
case "retreated":
|
||||||
|
l.Emoji = "🚑"
|
||||||
|
l.Hurt = true
|
||||||
|
l.Text = "Withdrew, wounded but alive"
|
||||||
|
default:
|
||||||
|
l.Emoji = "🚪"
|
||||||
|
l.Text = "Run ended"
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// A kind Pete has no line for. Show the noun rather than nothing — the
|
||||||
|
// same call the dispatch ingest makes for an unknown event type, and for
|
||||||
|
// the same reason: silence here is indistinguishable from a bug.
|
||||||
|
l.Emoji = "•"
|
||||||
|
l.Text = strings.ReplaceAll(b.Kind, "_", " ")
|
||||||
|
if b.Target != "" {
|
||||||
|
l.Text += " — " + b.Target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.Text == "" {
|
||||||
|
return runLogLine{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hpTail is the " (HP 21/34)" suffix, and only when the pair is real. A zero max
|
||||||
|
// means gogobee didn't send one, not that the adventurer has no health.
|
||||||
|
func hpTail(b storage.RunBeat) string {
|
||||||
|
if b.HPMax <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" · %d/%d HP", b.HP, b.HPMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomWord names a room the way somebody walking through it would. "exploration"
|
||||||
|
// is the engine's word for "a room", and echoing it back reads like a database
|
||||||
|
// column; the rooms with an actual identity get named and the rest are just the
|
||||||
|
// next one along.
|
||||||
|
// named is false for a room with no identity of its own, which is most of them.
|
||||||
|
// Callers that need to say something about a *particular* room have to know the
|
||||||
|
// difference — see the doubled-back branch.
|
||||||
|
func roomWord(kind string) (word string, named bool) {
|
||||||
|
switch kind {
|
||||||
|
case "entry":
|
||||||
|
return "entrance", true
|
||||||
|
case "trap":
|
||||||
|
return "trapped room", true
|
||||||
|
case "elite":
|
||||||
|
return "elite's room", true
|
||||||
|
case "boss":
|
||||||
|
return "boss chamber", true
|
||||||
|
case "secret":
|
||||||
|
return "hidden room", true
|
||||||
|
}
|
||||||
|
return "next room", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomEmoji(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case "trap":
|
||||||
|
return "🕳"
|
||||||
|
case "elite":
|
||||||
|
return "🛡"
|
||||||
|
case "boss":
|
||||||
|
return "👑"
|
||||||
|
case "entry":
|
||||||
|
return "🚪"
|
||||||
|
}
|
||||||
|
return "🚶"
|
||||||
|
}
|
||||||
|
|
||||||
|
func orUnknown(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "something"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int, one, many string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 " + one
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n) + " " + many
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The run report — the permalink an expedition leaves behind.
|
||||||
|
//
|
||||||
|
// The liveblog on the adventurer page answers "what is happening"; it is capped,
|
||||||
|
// it scrolls, and six hours after the run ends it is gone, because that page is
|
||||||
|
// about now. This answers the other question, the one asked afterwards and often
|
||||||
|
// by somebody who wasn't watching: what *was* that run. So it is the whole log,
|
||||||
|
// uncapped, with the numbers rolled up and the moment it turned pulled out of
|
||||||
|
// the middle — and it is stable for a fortnight, which is what makes it a thing
|
||||||
|
// worth putting in a dispatch and a thing worth sending to somebody.
|
||||||
|
//
|
||||||
|
// It is deliberately assembled from the same beats the liveblog renders, through
|
||||||
|
// the same renderRunBeat. A report that told a different story from the log it
|
||||||
|
// was built out of would be the more convincing of the two and the less true.
|
||||||
|
//
|
||||||
|
// The one thing here that Pete did not write is the summary: gogobee's LLM reads
|
||||||
|
// the finished run back and says what it was about. That is a judgement, not a
|
||||||
|
// fact, so it is the only prose on the channel and it passes the same guard a
|
||||||
|
// dispatch lede does before it reaches this page.
|
||||||
|
|
||||||
|
// runReportCap bounds the log on the report. Far above the liveblog's 60 — the
|
||||||
|
// point of this page is that nothing is missing — but not unbounded: a stuck
|
||||||
|
// multi-day expedition can beat out thousands of rows, and a page nobody can
|
||||||
|
// scroll is its own kind of missing.
|
||||||
|
const runReportCap = 500
|
||||||
|
|
||||||
|
// runStat is one rolled-up number with its label. Assembled rather than
|
||||||
|
// hardcoded in the template so a run with nothing to say about traps doesn't
|
||||||
|
// render a proud zero.
|
||||||
|
type runStat struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunReportView is the report as the page draws it.
|
||||||
|
type RunReportView struct {
|
||||||
|
pageData
|
||||||
|
RunID string
|
||||||
|
Name string
|
||||||
|
WhoURL string // link back to the adventurer page; "" when they're off the board
|
||||||
|
Level int
|
||||||
|
Zone string
|
||||||
|
Live bool
|
||||||
|
Outcome string // the raw word, for the chip class
|
||||||
|
Verdict string // the human sentence for it
|
||||||
|
Emoji string
|
||||||
|
Summary string
|
||||||
|
When string
|
||||||
|
Elapsed string
|
||||||
|
Rooms string
|
||||||
|
Stats []runStat
|
||||||
|
// Turning is the single beat that decided the run — the biggest thing that
|
||||||
|
// happened to the party's health in one go. Nil on a run where nothing much
|
||||||
|
// did, which is a real outcome and not worth inventing drama for.
|
||||||
|
Turning *runLogLine
|
||||||
|
Lines []runLogLine
|
||||||
|
Truncated bool
|
||||||
|
Permalink string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runReportPath is the report's URL. The run id is generated by gogobee as
|
||||||
|
// 16 hex characters, but it is still escaped: it arrives over a wire, and a link
|
||||||
|
// that routes somewhere else because an id grew a slash is a bug you find in
|
||||||
|
// production.
|
||||||
|
func runReportPath(runID string) string {
|
||||||
|
return "/adventure/run/" + url.PathEscape(runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunReport serves one expedition's report.
|
||||||
|
//
|
||||||
|
// The visibility rule is the adventurer page's, exactly: a run whose token is not
|
||||||
|
// on the current board 404s. Finishing a run does not take anyone off the board —
|
||||||
|
// they stay on it as idle — so this only ever fires for a player who opted out or
|
||||||
|
// was removed, which is precisely the case where a room-by-room account of where
|
||||||
|
// they went must stop being reachable. An unattributed run (its `start` beat never
|
||||||
|
// arrived, so there is no token at all) 404s for the same reason: Pete cannot
|
||||||
|
// establish whose run it is, and "don't know" is not a basis for publishing one.
|
||||||
|
func (s *Server) handleRunReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runID := r.PathValue("run_id")
|
||||||
|
run, ok, err := storage.RunByID(runID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("run report: header lookup failed", "run", runID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok || run.Token == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, onBoard, err := storage.RosterEntryByToken(run.Token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("run report: roster lookup failed", "run", runID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !onBoard {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
beats, err := storage.RunBeats(run.RunID, runReportCap)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("run report: beats lookup failed", "run", runID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(beats) == 0 {
|
||||||
|
// A header with no beats is a run that was pruned out from under its own
|
||||||
|
// dispatch, or one whose beats never landed. Either way there is no report.
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.track(r, "adventure")
|
||||||
|
|
||||||
|
view := buildRunReport(run, beats)
|
||||||
|
base := s.base(r)
|
||||||
|
base.Active = "adventure"
|
||||||
|
base.NoIndex = true // names a player character, like every other adventure page
|
||||||
|
view.pageData = base
|
||||||
|
// The name on the header is the roster's, not the beat's: the `start` beat
|
||||||
|
// froze a name at the moment the party set out, and the board is the current
|
||||||
|
// truth about what to call somebody.
|
||||||
|
if entry.Name != "" {
|
||||||
|
view.Name = entry.Name
|
||||||
|
}
|
||||||
|
view.WhoURL = "/adventure/who/" + url.PathEscape(run.Token)
|
||||||
|
view.Permalink = s.siteURL(runReportPath(run.RunID))
|
||||||
|
s.render(w, "run_report", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRunReport turns a run and its beats into the page. Pure — no storage, no
|
||||||
|
// server — so the whole render is testable against a slice of beats, which is
|
||||||
|
// the only honest way to check that a run reads correctly.
|
||||||
|
func buildRunReport(run storage.Run, beats []storage.RunBeat) RunReportView {
|
||||||
|
v := RunReportView{
|
||||||
|
RunID: run.RunID,
|
||||||
|
Name: run.Name,
|
||||||
|
Level: run.Level,
|
||||||
|
Zone: run.Zone,
|
||||||
|
Live: run.Live(),
|
||||||
|
Outcome: run.Outcome,
|
||||||
|
Summary: run.Summary,
|
||||||
|
}
|
||||||
|
if v.Name == "" {
|
||||||
|
v.Name = "An adventurer"
|
||||||
|
}
|
||||||
|
if v.Zone == "" {
|
||||||
|
v.Zone = "the dungeon"
|
||||||
|
}
|
||||||
|
v.Verdict, v.Emoji = runVerdict(run)
|
||||||
|
|
||||||
|
when := run.EndedAt
|
||||||
|
if when == 0 {
|
||||||
|
when = run.StartedAt
|
||||||
|
}
|
||||||
|
if when > 0 {
|
||||||
|
v.When = time.Unix(when, 0).UTC().Format("Jan 2, 2006 · 15:04")
|
||||||
|
}
|
||||||
|
v.Elapsed = runElapsed(run, beats)
|
||||||
|
|
||||||
|
// How far they got — and only when that is a fact worth stating. A dungeon
|
||||||
|
// graph forks, so a run that cleared it never walks every room, and a header
|
||||||
|
// reading "room 7 / 9" over the word "Cleared it" says they fell two short of
|
||||||
|
// something. On a run that ended badly the same number is the whole story.
|
||||||
|
if run.Outcome != "cleared" {
|
||||||
|
deepest := 0
|
||||||
|
for _, b := range beats {
|
||||||
|
if b.Room > deepest {
|
||||||
|
deepest = b.Room
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case deepest > 0 && run.TotalRooms > 0:
|
||||||
|
v.Rooms = fmt.Sprintf("got as far as room %d of %d", deepest, run.TotalRooms)
|
||||||
|
case deepest > 0:
|
||||||
|
v.Rooms = fmt.Sprintf("got as far as room %d", deepest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var turningAt int64 = -1
|
||||||
|
for _, b := range beats {
|
||||||
|
// The summary is prose about the run, not a moment in it. It has its own
|
||||||
|
// place on the page and would read as a stray paragraph in the middle of a
|
||||||
|
// log if it were allowed to render as a line.
|
||||||
|
if b.Kind == "summary" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Zone == "" {
|
||||||
|
b.Zone = run.Zone
|
||||||
|
}
|
||||||
|
line, ok := renderRunBeat(b)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v.Lines = append(v.Lines, line)
|
||||||
|
// The turning point is the single largest hit the party took in one go.
|
||||||
|
// Ties go to the earlier beat: the moment a run turned is the first time
|
||||||
|
// it did, not the last time it did it again.
|
||||||
|
if hurt := beatHurt(b); hurt > 0 && int64(hurt) > turningAt {
|
||||||
|
turningAt = int64(hurt)
|
||||||
|
pick := line
|
||||||
|
v.Turning = &pick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.Truncated = len(beats) >= runReportCap
|
||||||
|
v.Stats = runStats(beats)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatHurt is how much health one beat cost, and it is the only thing the
|
||||||
|
// turning point is chosen on. Damage the party absorbed is the currency of a
|
||||||
|
// dungeon crawl: a fight won without a scratch is not the moment anything
|
||||||
|
// turned, however big the monster was.
|
||||||
|
func beatHurt(b storage.RunBeat) int {
|
||||||
|
switch b.Kind {
|
||||||
|
case "combat", "trap":
|
||||||
|
return b.Amount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVerdict is the human reading of an outcome, plus the emoji the header wears.
|
||||||
|
func runVerdict(run storage.Run) (verdict, emoji string) {
|
||||||
|
if run.Live() {
|
||||||
|
return "Still under way", "🚶"
|
||||||
|
}
|
||||||
|
switch run.Outcome {
|
||||||
|
case "cleared":
|
||||||
|
return "Cleared it", "🏆"
|
||||||
|
case "died":
|
||||||
|
return "Didn't come home", "💀"
|
||||||
|
case "retreated":
|
||||||
|
return "Walked out wounded", "🚑"
|
||||||
|
case "abandoned":
|
||||||
|
// The generic funnel's word. It covers a region crossing and an idle reap
|
||||||
|
// alike, and neither of those is a failure — saying "abandoned" at somebody
|
||||||
|
// would be Pete editorialising with the least informative word available.
|
||||||
|
return "Ended", "🚪"
|
||||||
|
}
|
||||||
|
return "Ended", "🚪"
|
||||||
|
}
|
||||||
|
|
||||||
|
// runElapsed is how long the party was down there, phrased the way somebody
|
||||||
|
// would say it. Preference order matters: the header clock is authoritative when
|
||||||
|
// it has both ends, and the beats are the fallback for a run whose `start` never
|
||||||
|
// arrived (which is exactly the run whose started_at is a later beat's clock).
|
||||||
|
func runElapsed(run storage.Run, beats []storage.RunBeat) string {
|
||||||
|
from, to := run.StartedAt, run.EndedAt
|
||||||
|
if from == 0 && len(beats) > 0 {
|
||||||
|
from = beats[0].OccurredAt
|
||||||
|
}
|
||||||
|
if to == 0 && len(beats) > 0 {
|
||||||
|
to = beats[len(beats)-1].OccurredAt
|
||||||
|
}
|
||||||
|
if from == 0 || to <= from {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := time.Duration(to-from) * time.Second
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "under a minute"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%d min", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if m == 0 {
|
||||||
|
return plural(h, "hour", "hours")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
}
|
||||||
|
return plural(int(d.Hours()/24), "day", "days")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStats rolls the beats up into the tiles above the log.
|
||||||
|
//
|
||||||
|
// Only non-zero tiles are emitted. A run that sprung no traps should say nothing
|
||||||
|
// about traps rather than display a confident 0 — the tile row is a summary of
|
||||||
|
// what this run *was*, and padding it out with absences makes every run look the
|
||||||
|
// same, which is the exact failure the report exists to fix.
|
||||||
|
func runStats(beats []storage.RunBeat) []runStat {
|
||||||
|
var (
|
||||||
|
fights, wins, damage, crits int
|
||||||
|
treasures, traps, gathered int
|
||||||
|
)
|
||||||
|
for _, b := range beats {
|
||||||
|
switch b.Kind {
|
||||||
|
case "combat":
|
||||||
|
fights++
|
||||||
|
if b.Outcome == "won" {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
damage += b.Amount
|
||||||
|
crits += b.Crits
|
||||||
|
case "trap":
|
||||||
|
if b.Amount > 0 {
|
||||||
|
traps++
|
||||||
|
damage += b.Amount
|
||||||
|
}
|
||||||
|
case "treasure":
|
||||||
|
treasures++
|
||||||
|
case "haul":
|
||||||
|
gathered += b.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []runStat
|
||||||
|
add := func(n int, label, plural string) {
|
||||||
|
if n <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n != 1 && plural != "" {
|
||||||
|
label = plural
|
||||||
|
}
|
||||||
|
out = append(out, runStat{Value: fmt.Sprintf("%d", n), Label: label})
|
||||||
|
}
|
||||||
|
if fights > 0 {
|
||||||
|
// Wins over fights rather than two tiles: on a run that ended badly the
|
||||||
|
// interesting number is the gap between them, and two separate tiles make a
|
||||||
|
// reader do the subtraction.
|
||||||
|
out = append(out, runStat{
|
||||||
|
Value: fmt.Sprintf("%d/%d", wins, fights),
|
||||||
|
Label: "fights won",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
add(damage, "damage taken", "")
|
||||||
|
add(treasures, "treasure found", "treasures found")
|
||||||
|
add(traps, "trap sprung", "traps sprung")
|
||||||
|
add(crits, "critical hit", "critical hits")
|
||||||
|
add(gathered, "supplies gathered", "")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// runReportLinkFor is the "read the run" link for a dispatch, or "" when there
|
||||||
|
// isn't one to offer.
|
||||||
|
//
|
||||||
|
// Three ways to have no link, all of them normal: the fact predates the run
|
||||||
|
// report (or isn't the end of an expedition) and carries no run id; the run has
|
||||||
|
// been swept by the fortnight retention; or its owner has since left the board.
|
||||||
|
// The last one is why this re-checks visibility rather than trusting the stored
|
||||||
|
// id — an opt-out has to close the door on links that were minted before it.
|
||||||
|
func runReportLinkFor(ev *storage.AdvEvent) string {
|
||||||
|
if ev == nil || ev.RunID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
run, ok, err := storage.RunByID(ev.RunID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("run report link: header lookup failed", "run", ev.RunID, "err", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !ok || run.Token == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, onBoard, err := storage.RosterEntryByToken(run.Token); err != nil || !onBoard {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return runReportPath(run.RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRunSummary caps the LLM run summary. Longer than a dispatch lede on purpose
|
||||||
|
// — it is three sentences over a whole expedition rather than one over a single
|
||||||
|
// fact — and still short enough that a runaway generation is rejected rather
|
||||||
|
// than printed.
|
||||||
|
const maxRunSummary = 1200
|
||||||
|
|
||||||
|
// runSummaryGuard decides whether gogobee's run summary is safe to print. It is
|
||||||
|
// the liveblog's half of proseGuard and it exists for the identical reason: the
|
||||||
|
// text is LLM output over player-chosen names, so the only defence that means
|
||||||
|
// anything is checking the RENDERED words rather than the structured fields
|
||||||
|
// beside them.
|
||||||
|
//
|
||||||
|
// The allow-list is the run's own adventurer, which is the only person a run
|
||||||
|
// summary has any business naming. A summary that names a *different* character
|
||||||
|
// on the board is either a hallucination or somebody who found an injection path,
|
||||||
|
// and both are the same answer: drop the prose, keep the report. The report
|
||||||
|
// without a summary is the log and the numbers, which is most of it.
|
||||||
|
func runSummaryGuard(text, name string) bool {
|
||||||
|
if strings.TrimSpace(text) == "" || len(text) > maxRunSummary {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
allow := map[string]bool{}
|
||||||
|
if name != "" {
|
||||||
|
allow[strings.ToLower(name)] = true
|
||||||
|
}
|
||||||
|
lowered := strings.ToLower(text)
|
||||||
|
for known := range storage.KnownCharacterNames() {
|
||||||
|
if allow[known] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsWholeWord(lowered, known) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// onBoard puts the run's owner on the roster. Every report test needs it: the
|
||||||
|
// report's visibility gate is the adventurer page's, and with no board at all
|
||||||
|
// every token reads as opted-out.
|
||||||
|
func onBoard(t *testing.T, s *Server, ingest, token, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if w := postRoster(t, s, ingest, rosterPush{
|
||||||
|
SnapshotAt: time.Now().Unix(),
|
||||||
|
Adventurers: []storage.RosterEntry{entry(token, name, "idle", "")},
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("roster push failed: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aFinishedRun is a small but realistic expedition: two fights, a trap that hurt
|
||||||
|
// more than either of them, a find, and a clean ending.
|
||||||
|
func aFinishedRun(now int64) []storage.RunBeat {
|
||||||
|
return []storage.RunBeat{
|
||||||
|
startBeat(now),
|
||||||
|
{RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 60, Room: 2, TotalRooms: 9,
|
||||||
|
Target: "Bone Chanter", Outcome: "won", Amount: 7, HP: 61, HPMax: 68, Crits: 1},
|
||||||
|
{RunID: "run-1", Seq: 3, Kind: "trap", OccurredAt: now + 120, Room: 3, TotalRooms: 9,
|
||||||
|
RoomKind: "trap", Outcome: "sprung", Amount: 22, HP: 39, HPMax: 68},
|
||||||
|
{RunID: "run-1", Seq: 4, Kind: "treasure", OccurredAt: now + 200, Room: 4, TotalRooms: 9,
|
||||||
|
Target: "Ashlight Pendant", Outcome: "cache"},
|
||||||
|
{RunID: "run-1", Seq: 5, Kind: "combat", OccurredAt: now + 300, Room: 5, TotalRooms: 9,
|
||||||
|
RoomKind: "boss", Target: "Valdris", Outcome: "won", Amount: 12, HP: 27, HPMax: 68},
|
||||||
|
{RunID: "run-1", Seq: 6, Kind: "end", OccurredAt: now + 360, Room: 5, TotalRooms: 9,
|
||||||
|
Outcome: "cleared"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getReport(t *testing.T, s *Server, runID string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", "/adventure/run/"+runID, nil)
|
||||||
|
req.SetPathValue("run_id", runID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleRunReport(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunReportRendersTheWholeRun. The liveblog is capped and expires; the
|
||||||
|
// report is the artefact, so what it has to get right is that everything is
|
||||||
|
// there — every beat, the rollup, and the moment it turned.
|
||||||
|
func TestRunReportRendersTheWholeRun(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
onBoard(t, s, token, "tok-abc", "Josie")
|
||||||
|
postBeats(t, s, token, aFinishedRun(now)...)
|
||||||
|
|
||||||
|
w := getReport(t, s, "run-1")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("report: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Josie in Crypt of Valdris",
|
||||||
|
"Cleared it",
|
||||||
|
"Bone Chanter down",
|
||||||
|
"Trap sprung — 22 damage",
|
||||||
|
"Found Ashlight Pendant",
|
||||||
|
"Valdris down",
|
||||||
|
"Run complete",
|
||||||
|
"2/2", // fights won, as one tile rather than two
|
||||||
|
"41", // damage taken: 7 + 22 + 12
|
||||||
|
"Where it turned", // the trap, being the biggest single hit
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("report is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurningPointIsTheBiggestHit. The plan's word for it is "turning point" and
|
||||||
|
// the temptation is to pick the boss, because a boss is the most *important*
|
||||||
|
// thing in a run. It isn't the thing that turned it: a boss killed without a
|
||||||
|
// scratch turned nothing, and the trap two rooms earlier that took a third of
|
||||||
|
// the party's health is the beat the reader is looking for.
|
||||||
|
func TestTurningPointIsTheBiggestHit(t *testing.T) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
run := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9,
|
||||||
|
StartedAt: now, EndedAt: now + 360, Outcome: "cleared"}
|
||||||
|
v := buildRunReport(run, aFinishedRun(now))
|
||||||
|
if v.Turning == nil {
|
||||||
|
t.Fatal("no turning point on a run with a 22-damage trap in it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(v.Turning.Text, "Trap sprung") {
|
||||||
|
t.Errorf("turning point = %q, want the trap (22) over the boss (12)", v.Turning.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run where nothing landed has no turning point rather than a made-up one.
|
||||||
|
quiet := []storage.RunBeat{
|
||||||
|
{RunID: "r", Seq: 1, Kind: "start", OccurredAt: now, Zone: "Crypt", TotalRooms: 3},
|
||||||
|
{RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now + 10, Target: "Rat", Outcome: "won"},
|
||||||
|
{RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 20, Outcome: "cleared"},
|
||||||
|
}
|
||||||
|
if q := buildRunReport(run, quiet); q.Turning != nil {
|
||||||
|
t.Errorf("invented a turning point on an untouched run: %q", q.Turning.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHowFarTheyGotOnlyMattersWhenTheyFellShort. A dungeon graph forks, so a run
|
||||||
|
// that cleared it never walks every room — "room 7 / 9" printed under the words
|
||||||
|
// "Cleared it" says they came up two short of something they in fact finished.
|
||||||
|
// On a run that ended badly the same number is the whole story.
|
||||||
|
func TestHowFarTheyGotOnlyMattersWhenTheyFellShort(t *testing.T) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
beats := aFinishedRun(now)
|
||||||
|
base := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9,
|
||||||
|
StartedAt: now, EndedAt: now + 360}
|
||||||
|
|
||||||
|
cleared := base
|
||||||
|
cleared.Outcome = "cleared"
|
||||||
|
if v := buildRunReport(cleared, beats); v.Rooms != "" {
|
||||||
|
t.Errorf("a cleared run advertised how far it got: %q", v.Rooms)
|
||||||
|
}
|
||||||
|
|
||||||
|
died := base
|
||||||
|
died.Outcome = "died"
|
||||||
|
if v := buildRunReport(died, beats); v.Rooms != "got as far as room 5 of 9" {
|
||||||
|
t.Errorf("Rooms = %q, want the depth on a run that ended badly", v.Rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunStatsSkipTheZeroes. The tile row is meant to say what THIS run was. A
|
||||||
|
// run that sprung no traps and found no treasure rendering two confident zeroes
|
||||||
|
// makes every run look identical, which is the exact failure the report exists
|
||||||
|
// to fix.
|
||||||
|
func TestRunStatsSkipTheZeroes(t *testing.T) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
stats := runStats([]storage.RunBeat{
|
||||||
|
{Kind: "combat", Outcome: "won", Target: "Rat", Amount: 3, OccurredAt: now},
|
||||||
|
{Kind: "trap", Outcome: "avoided", Amount: 0, OccurredAt: now + 1}, // stepped over it
|
||||||
|
})
|
||||||
|
for _, s := range stats {
|
||||||
|
if strings.Contains(s.Label, "trap") {
|
||||||
|
t.Errorf("a trap that was avoided produced a tile: %+v", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s.Label, "treasure") {
|
||||||
|
t.Errorf("a run with no finds produced a treasure tile: %+v", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s.Label, "critical") {
|
||||||
|
t.Errorf("a run with no crits produced a crit tile: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stats) != 2 { // fights won + damage taken
|
||||||
|
t.Fatalf("want 2 tiles, got %d: %+v", len(stats), stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunReportIsGatedOnTheBoard is the report's half of TestOffTheBoardShipsNoLog.
|
||||||
|
//
|
||||||
|
// The report outlives the liveblog by a fortnight and is linked from a public
|
||||||
|
// dispatch, so it is the surface most likely to still be reachable after somebody
|
||||||
|
// opts out. Coming off the board is what an opt-out looks like from Pete's side,
|
||||||
|
// and from that moment a room-by-room account of where they went has to stop
|
||||||
|
// resolving — including through the link a dispatch minted days earlier.
|
||||||
|
func TestRunReportIsGatedOnTheBoard(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
onBoard(t, s, token, "tok-abc", "Josie")
|
||||||
|
postBeats(t, s, token, aFinishedRun(now)...)
|
||||||
|
|
||||||
|
if w := getReport(t, s, "run-1"); w.Code != 200 {
|
||||||
|
t.Fatalf("report should serve while its owner is on the board: %d", w.Code)
|
||||||
|
}
|
||||||
|
ev := &storage.AdvEvent{GUID: "zone_clear:x:1", EventType: "zone_clear", Subject: "Josie",
|
||||||
|
RunID: "run-1", OccurredAt: now}
|
||||||
|
if err := storage.InsertAdventureEvent(ev); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if link := runReportLinkFor(ev); link != "/adventure/run/run-1" {
|
||||||
|
t.Fatalf("dispatch link = %q, want the report path", link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// They opt out: gogobee stops sending them, so the next board has no such
|
||||||
|
// token. The beats Pete already holds are append-only and can't be recalled —
|
||||||
|
// what has to happen is that they become unreachable.
|
||||||
|
if w := postRoster(t, s, token, rosterPush{
|
||||||
|
SnapshotAt: now + 1,
|
||||||
|
Adventurers: []storage.RosterEntry{entry("someone-else", "Quack", "idle", "")},
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("roster push failed: %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := getReport(t, s, "run-1"); w.Code != 404 {
|
||||||
|
t.Errorf("report still served after its owner left the board: %d", w.Code)
|
||||||
|
}
|
||||||
|
if link := runReportLinkFor(ev); link != "" {
|
||||||
|
t.Errorf("dispatch still offers a link to an opted-out player's run: %q", link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnattributedRunHasNoReport. A run whose `start` beat never arrived still
|
||||||
|
// gets a readable log — that is deliberate, and W2a pinned it. But it has no
|
||||||
|
// token, so Pete cannot establish whose run it is, and "don't know" is not a
|
||||||
|
// basis on which to publish where somebody went.
|
||||||
|
func TestUnattributedRunHasNoReport(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
onBoard(t, s, token, "tok-abc", "Josie")
|
||||||
|
|
||||||
|
postBeats(t, s, token,
|
||||||
|
storage.RunBeat{RunID: "orphan", Seq: 2, Kind: "combat", OccurredAt: now,
|
||||||
|
Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won"},
|
||||||
|
storage.RunBeat{RunID: "orphan", Seq: 3, Kind: "end", OccurredAt: now + 5, Outcome: "cleared"},
|
||||||
|
)
|
||||||
|
if w := getReport(t, s, "orphan"); w.Code != 404 {
|
||||||
|
t.Errorf("served a report for a run with no owner: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunSummaryIsGuardedLikeADispatch. The summary is the only prose on the
|
||||||
|
// beat channel and it is LLM output over player-chosen names, so the field
|
||||||
|
// checks that make a *fact* safe are worth nothing here — the words are the
|
||||||
|
// thing being rendered. A summary that names a different adventurer on the board
|
||||||
|
// is either a hallucination or an injection, and both get the same answer: keep
|
||||||
|
// the report, drop the prose.
|
||||||
|
func TestRunSummaryIsGuardedLikeADispatch(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if w := postRoster(t, s, token, rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||||
|
entry("tok-abc", "Josie", "idle", ""),
|
||||||
|
entry("tok-def", "Quack", "idle", ""),
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("roster push failed: %d", w.Code)
|
||||||
|
}
|
||||||
|
postBeats(t, s, token, aFinishedRun(now)...)
|
||||||
|
|
||||||
|
// Names a bystander who was never on this expedition.
|
||||||
|
if w := postBeats(t, s, token, storage.RunBeat{
|
||||||
|
RunID: "run-1", Seq: 7, Kind: "summary", OccurredAt: now + 400, Name: "Josie",
|
||||||
|
Prose: "Josie and Quack went down into the crypt together and only one came back.",
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("a rejected summary should still be a 200: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
run, _, err := storage.RunByID("run-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if run.Summary != "" {
|
||||||
|
t.Errorf("guard let through a summary naming a bystander: %q", run.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same beat, about the right person only. The seq differs because the
|
||||||
|
// rejected row is still stored — that is what stops gogobee re-authoring it
|
||||||
|
// forever — so a retry has to be a new beat.
|
||||||
|
good := "Josie took a bad trap on the way in and finished the boss on a quarter of her health."
|
||||||
|
if w := postBeats(t, s, token, storage.RunBeat{
|
||||||
|
RunID: "run-1", Seq: 8, Kind: "summary", OccurredAt: now + 401, Name: "Josie", Prose: good,
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("summary rejected: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
run, _, err = storage.RunByID("run-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if run.Summary != good {
|
||||||
|
t.Errorf("summary = %q, want it stored", run.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it renders on the report, above the log rather than inside it.
|
||||||
|
w := getReport(t, s, "run-1")
|
||||||
|
if !strings.Contains(w.Body.String(), good) {
|
||||||
|
t.Error("the summary didn't reach the report page")
|
||||||
|
}
|
||||||
|
v := runLogFor("tok-abc")
|
||||||
|
for _, ln := range v.Lines {
|
||||||
|
if strings.Contains(ln.Text, "bad trap on the way in") {
|
||||||
|
t.Errorf("the summary rendered as a log line: %q", ln.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnlyTheSummaryBeatCarriesProse. The guard at ingest only inspects the kind
|
||||||
|
// it knows about, so any other kind arriving with prose would put unguarded text
|
||||||
|
// onto the header. Both halves have to hold — the beat must be scrubbed, and the
|
||||||
|
// header fold must ignore it even if it weren't.
|
||||||
|
func TestOnlyTheSummaryBeatCarriesProse(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
onBoard(t, s, token, "tok-abc", "Josie")
|
||||||
|
|
||||||
|
postBeats(t, s, token, startBeat(now), storage.RunBeat{
|
||||||
|
RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 10,
|
||||||
|
Target: "Bone Chanter", Outcome: "won",
|
||||||
|
Prose: "and then Quack showed up out of nowhere",
|
||||||
|
})
|
||||||
|
run, _, err := storage.RunByID("run-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if run.Summary != "" {
|
||||||
|
t.Errorf("a combat beat wrote the run summary: %q", run.Summary)
|
||||||
|
}
|
||||||
|
beats, err := storage.RunBeats("run-1", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, b := range beats {
|
||||||
|
if b.Prose != "" {
|
||||||
|
t.Errorf("beat %d (%s) kept prose it isn't allowed to carry: %q", b.Seq, b.Kind, b.Prose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFinishedRunOffersItsReport / a live one doesn't. While a run is still
|
||||||
|
// walking, the column on the adventurer page IS the report; a link to a second
|
||||||
|
// copy of what somebody is already reading is only a way to lose them.
|
||||||
|
func TestFinishedRunOffersItsReport(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
onBoard(t, s, token, "tok-abc", "Josie")
|
||||||
|
|
||||||
|
postBeats(t, s, token, startBeat(now))
|
||||||
|
if v := runLogFor("tok-abc"); v.ReportURL != "" {
|
||||||
|
t.Errorf("a live run offered a report link: %q", v.ReportURL)
|
||||||
|
}
|
||||||
|
postBeats(t, s, token, storage.RunBeat{
|
||||||
|
RunID: "run-1", Seq: 9, Kind: "end", OccurredAt: now + 60, Outcome: "cleared"})
|
||||||
|
if v := runLogFor("tok-abc"); v.ReportURL != "/adventure/run/run-1" {
|
||||||
|
t.Errorf("ReportURL = %q, want the report path once the run is over", v.ReportURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunElapsedFallsBackToTheBeats. started_at comes off the `start` beat, so a
|
||||||
|
// run that lost it has a zero clock on the header and would otherwise report no
|
||||||
|
// duration at all — on precisely the run where the log is the only record there is.
|
||||||
|
func TestRunElapsedFallsBackToTheBeats(t *testing.T) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
beats := []storage.RunBeat{
|
||||||
|
{RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now, Target: "Rat", Outcome: "won"},
|
||||||
|
{RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 5400, Outcome: "cleared"},
|
||||||
|
}
|
||||||
|
// No StartedAt: the beat that would have set it never arrived.
|
||||||
|
got := runElapsed(storage.Run{RunID: "r", EndedAt: now + 5400}, beats)
|
||||||
|
if got != "1h 30m" {
|
||||||
|
t.Errorf("elapsed = %q, want 1h 30m off the beats", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func postBeats(t *testing.T, s *Server, token string, beats ...storage.RunBeat) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(runBeatsPush{Beats: beats})
|
||||||
|
req := httptest.NewRequest("POST", "/api/ingest/run", bytes.NewReader(body))
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleRunIngest(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBeat is the beat that names a run. Everything downstream keys on run_id
|
||||||
|
// alone, so this is the only one that has to carry identity.
|
||||||
|
func startBeat(now int64) storage.RunBeat {
|
||||||
|
return storage.RunBeat{
|
||||||
|
RunID: "run-1", Seq: 1, Kind: "start", OccurredAt: now,
|
||||||
|
Token: "tok-abc", Name: "Josie", Level: 14, Zone: "Crypt of Valdris", TotalRooms: 9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownBeatKindIsStoredAndRendered is the regression for a whole class of
|
||||||
|
// bug, not for one beat kind.
|
||||||
|
//
|
||||||
|
// The dispatch channel learned this the hard way: an unknown event_type used to
|
||||||
|
// 400, which parked the queue row upstream and silently deleted a game event
|
||||||
|
// that had actually happened. The beat channel is a second chance to make the
|
||||||
|
// same mistake, and this is the test that stops it — gogobee must be able to
|
||||||
|
// invent a beat kind on any Tuesday and have it show up as a plain line rather
|
||||||
|
// than as a 400 and a hole in the log.
|
||||||
|
func TestUnknownBeatKindIsStoredAndRendered(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postBeats(t, s, token,
|
||||||
|
startBeat(now),
|
||||||
|
storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "seance", OccurredAt: now + 5,
|
||||||
|
Room: 2, TotalRooms: 9, Target: "a cold draught"},
|
||||||
|
); w.Code != 200 {
|
||||||
|
t.Fatalf("unknown beat kind rejected: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
v := runLogFor("tok-abc")
|
||||||
|
if !v.Has {
|
||||||
|
t.Fatal("no log built for a run that has two beats")
|
||||||
|
}
|
||||||
|
if len(v.Lines) != 2 {
|
||||||
|
t.Fatalf("want 2 lines, got %d: %+v", len(v.Lines), v.Lines)
|
||||||
|
}
|
||||||
|
last := v.Lines[1]
|
||||||
|
if last.Text != "seance — a cold draught" {
|
||||||
|
t.Errorf("unknown kind rendered as %q; it should degrade to its own noun", last.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBeatIngestRequiresIdentity — run_id and seq ARE the row. Without both
|
||||||
|
// there is nothing for the re-send to collapse onto, so this is the one thing
|
||||||
|
// the ingest is strict about.
|
||||||
|
func TestBeatIngestRequiresIdentity(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postBeats(t, s, token, storage.RunBeat{Seq: 1, Kind: "room", OccurredAt: now}); w.Code != 400 {
|
||||||
|
t.Errorf("beat with no run_id: want 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := postBeats(t, s, token,
|
||||||
|
storage.RunBeat{RunID: "run-1", Kind: "room", OccurredAt: now}); w.Code != 400 {
|
||||||
|
t.Errorf("beat with no seq: want 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := postBeats(t, s, "wrong-token", startBeat(now)); w.Code != 401 {
|
||||||
|
t.Errorf("unauthed beat: want 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBeatsAreIdempotentOnRunAndSeq. gogobee re-sends a batch whenever it
|
||||||
|
// delivered it but failed to mark it locally, which is a normal outcome of a
|
||||||
|
// crash between two writes — so a duplicate batch has to be free.
|
||||||
|
func TestBeatsAreIdempotentOnRunAndSeq(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
beats := []storage.RunBeat{
|
||||||
|
startBeat(now),
|
||||||
|
{RunID: "run-1", Seq: 2, Kind: "room", OccurredAt: now + 10, Room: 2, TotalRooms: 9, RoomKind: "exploration"},
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if w := postBeats(t, s, token, beats...); w.Code != 200 {
|
||||||
|
t.Fatalf("push %d: %d %s", i, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stored, err := storage.RunBeats("run-1", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(stored) != 2 {
|
||||||
|
t.Fatalf("three identical pushes produced %d beats, want 2", len(stored))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunHeaderIsDerivedAndSticky. The header is not pushed as its own object —
|
||||||
|
// it is folded out of the beats. The forty beats after `start` carry no name and
|
||||||
|
// no zone, and none of them may erase the one that did.
|
||||||
|
func TestRunHeaderIsDerivedAndSticky(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
postBeats(t, s, token, startBeat(now))
|
||||||
|
postBeats(t, s, token,
|
||||||
|
storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "room", OccurredAt: now + 10, Room: 2, TotalRooms: 9},
|
||||||
|
storage.RunBeat{RunID: "run-1", Seq: 3, Kind: "combat", OccurredAt: now + 20,
|
||||||
|
Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won", Amount: 7, HP: 61, HPMax: 68},
|
||||||
|
)
|
||||||
|
|
||||||
|
run, ok, err := storage.RunByID("run-1")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("run header missing: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if run.Name != "Josie" || run.Zone != "Crypt of Valdris" || run.Level != 14 {
|
||||||
|
t.Errorf("later beats clobbered the start beat's identity: %+v", run)
|
||||||
|
}
|
||||||
|
if !run.Live() {
|
||||||
|
t.Error("run with no end beat should still be live")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now close it, then try to reopen it with a second, less specific end.
|
||||||
|
postBeats(t, s, token,
|
||||||
|
storage.RunBeat{RunID: "run-1", Seq: 4, Kind: "end", OccurredAt: now + 30, Outcome: "died"},
|
||||||
|
storage.RunBeat{RunID: "run-1", Seq: 5, Kind: "end", OccurredAt: now + 31, Outcome: "abandoned"},
|
||||||
|
)
|
||||||
|
run, _, _ = storage.RunByID("run-1")
|
||||||
|
if run.Live() {
|
||||||
|
t.Error("run with an end beat should not be live")
|
||||||
|
}
|
||||||
|
if run.Outcome != "died" {
|
||||||
|
t.Errorf("outcome = %q, want %q — the first, specific close must win", run.Outcome, "died")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunWithNoStartBeatStillHasALog. A start beat can be lost (retention on the
|
||||||
|
// game box, an opt-out flipped mid-run, a batch that never made it). The run
|
||||||
|
// that follows is unattributed, which is a reason not to hang it off an
|
||||||
|
// adventurer page — not a reason to throw the log away.
|
||||||
|
func TestRunWithNoStartBeatStillHasALog(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postBeats(t, s, token,
|
||||||
|
storage.RunBeat{RunID: "orphan", Seq: 7, Kind: "combat", OccurredAt: now,
|
||||||
|
Room: 3, TotalRooms: 9, Target: "Gravewright", Outcome: "won"},
|
||||||
|
); w.Code != 200 {
|
||||||
|
t.Fatalf("orphan beat rejected: %d", w.Code)
|
||||||
|
}
|
||||||
|
run, ok, err := storage.RunByID("orphan")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("orphan run has no header: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if run.Token != "" {
|
||||||
|
t.Errorf("orphan run claimed token %q", run.Token)
|
||||||
|
}
|
||||||
|
// ...and it is unreachable from any adventurer page, which is the point.
|
||||||
|
if v := runLogFor(""); v.Has {
|
||||||
|
t.Error("empty token resolved to a log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFinishedRunAgesOffThePage. The adventurer page is about now. A run that
|
||||||
|
// ended days ago sitting under a live map reads as the live one, which is worse
|
||||||
|
// than showing nothing — the rows stay in the database for the dispatch that
|
||||||
|
// links to them.
|
||||||
|
func TestFinishedRunAgesOffThePage(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
old := time.Now().Add(-24 * time.Hour).Unix()
|
||||||
|
|
||||||
|
postBeats(t, s, token,
|
||||||
|
storage.RunBeat{RunID: "run-old", Seq: 1, Kind: "start", OccurredAt: old,
|
||||||
|
Token: "tok-abc", Name: "Josie", Zone: "Underforge", TotalRooms: 8},
|
||||||
|
storage.RunBeat{RunID: "run-old", Seq: 2, Kind: "end", OccurredAt: old + 600, Outcome: "cleared"},
|
||||||
|
)
|
||||||
|
if v := runLogFor("tok-abc"); v.Has {
|
||||||
|
t.Error("a run that ended a day ago is still on the page")
|
||||||
|
}
|
||||||
|
if beats, _ := storage.RunBeats("run-old", 0); len(beats) != 2 {
|
||||||
|
t.Errorf("aged-off run lost its stored beats: %d", len(beats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLiveRunBeatsAFinishedOne is the border-crossing case, and it is the reason
|
||||||
|
// the page picks a run by liveness before recency.
|
||||||
|
//
|
||||||
|
// A multi-region expedition closes one run and opens the next in the same
|
||||||
|
// breath: the outgoing `end` beat and the incoming `start` beat carry the same
|
||||||
|
// second, and which one has the later updated_at is a coin flip. Losing it means
|
||||||
|
// the page shows the log of a region the party has already walked out of, with a
|
||||||
|
// "cleared" chip on it, while they are three rooms into the next one.
|
||||||
|
func TestLiveRunBeatsAFinishedOne(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
postBeats(t, s, token,
|
||||||
|
storage.RunBeat{RunID: "region-1", Seq: 1, Kind: "start", OccurredAt: now - 60,
|
||||||
|
Token: "tok-abc", Name: "Josie", Zone: "The Slagworks", TotalRooms: 6},
|
||||||
|
// The crossing and the next region's opening land on the same clock tick.
|
||||||
|
storage.RunBeat{RunID: "region-1", Seq: 2, Kind: "end", OccurredAt: now, Outcome: "cleared"},
|
||||||
|
storage.RunBeat{RunID: "region-2", Seq: 1, Kind: "start", OccurredAt: now,
|
||||||
|
Token: "tok-abc", Name: "Josie", Zone: "The Deep Bellows", TotalRooms: 7},
|
||||||
|
)
|
||||||
|
|
||||||
|
run, ok, err := storage.LatestRunForToken("tok-abc")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("no run resolved: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if run.RunID != "region-2" {
|
||||||
|
t.Fatalf("page picked %q (%s); the live run must win over the finished one",
|
||||||
|
run.RunID, run.Outcome)
|
||||||
|
}
|
||||||
|
if v := runLogFor("tok-abc"); !v.Live || v.Zone != "The Deep Bellows" {
|
||||||
|
t.Errorf("log = %q live:%v, want the region they are actually in", v.Zone, v.Live)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunLogShowsTheTail. A log is read for what just happened. A party deep
|
||||||
|
// into a long expedition must not be showing its first morning.
|
||||||
|
func TestRunLogShowsTheTail(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
beats := []storage.RunBeat{startBeat(now)}
|
||||||
|
for i := 2; i <= runLogCap+20; i++ {
|
||||||
|
beats = append(beats, storage.RunBeat{
|
||||||
|
RunID: "run-1", Seq: int64(i), Kind: "room", OccurredAt: now + int64(i),
|
||||||
|
Room: i, TotalRooms: 400, RoomKind: "exploration",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if w := postBeats(t, s, token, beats...); w.Code != 200 {
|
||||||
|
t.Fatalf("push: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
v := runLogFor("tok-abc")
|
||||||
|
if len(v.Lines) != runLogCap {
|
||||||
|
t.Fatalf("want %d lines, got %d", runLogCap, len(v.Lines))
|
||||||
|
}
|
||||||
|
// Oldest-first within the tail, and the tail ends at the newest beat.
|
||||||
|
if got := v.Lines[len(v.Lines)-1].Room; got != "80/400" {
|
||||||
|
t.Errorf("last line room = %q, want the newest beat", got)
|
||||||
|
}
|
||||||
|
if v.Rooms != "80 / 400" {
|
||||||
|
t.Errorf("header room = %q", v.Rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOffTheBoardShipsNoLog. Coming off the board means opted out or removed —
|
||||||
|
// finishing a run leaves an adventurer on it as idle. So the branch that answers
|
||||||
|
// "this token is no longer listed" must not hand back a room-by-room account of
|
||||||
|
// where its owner is; the page 404s in the same situation, and an API that is
|
||||||
|
// more forthcoming than the page it backs is a leak with extra steps.
|
||||||
|
func TestOffTheBoardShipsNoLog(t *testing.T) {
|
||||||
|
const token = "tok"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
postBeats(t, s, token, startBeat(now),
|
||||||
|
storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 10,
|
||||||
|
Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won"})
|
||||||
|
|
||||||
|
// Never pushed a roster, so no token is on the board — the opted-out case.
|
||||||
|
req := httptest.NewRequest("GET", "/api/adventure/who/tok-abc", nil)
|
||||||
|
req.SetPathValue("token", "tok-abc")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdventureWhoAPI(w, req)
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
if got["live"] != false {
|
||||||
|
t.Errorf("live = %v, want false", got["live"])
|
||||||
|
}
|
||||||
|
if _, leaked := got["run_log"]; leaked {
|
||||||
|
t.Errorf("an off-the-board token was handed its run log: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderRunBeatCarriesTheNouns pins the shape of the lines: they are built
|
||||||
|
// out of the beat and nothing else. A line that reads better than the facts
|
||||||
|
// support is a line lying about a run somebody actually walked.
|
||||||
|
func TestRenderRunBeatCarriesTheNouns(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
beat storage.RunBeat
|
||||||
|
want string
|
||||||
|
hurt bool
|
||||||
|
good bool
|
||||||
|
}{
|
||||||
|
{"kill", storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Bone Chanter",
|
||||||
|
Amount: 7, HP: 61, HPMax: 68}, "Bone Chanter down — took 7 · 61/68 HP", false, true},
|
||||||
|
{"clean kill", storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Rat",
|
||||||
|
HP: 68, HPMax: 68}, "Rat down — untouched · 68/68 HP", false, true},
|
||||||
|
{"death", storage.RunBeat{Kind: "combat", Outcome: "down", Target: "The Rotmother",
|
||||||
|
HP: 0, HPMax: 68}, "Fell to The Rotmother · 0/68 HP", true, false},
|
||||||
|
{"timeout", storage.RunBeat{Kind: "combat", Outcome: "retreat", Target: "Aldric"},
|
||||||
|
"Outlasted by Aldric — withdrew", true, false},
|
||||||
|
{"trap", storage.RunBeat{Kind: "trap", Amount: 12, HP: 40, HPMax: 68},
|
||||||
|
"Trap sprung — 12 damage · 40/68 HP", true, false},
|
||||||
|
{"trap avoided", storage.RunBeat{Kind: "trap"}, "Trap — stepped over it", false, true},
|
||||||
|
{"treasure", storage.RunBeat{Kind: "treasure", Target: "Coin Pouch", Outcome: "cache"},
|
||||||
|
"Found Coin Pouch in a cache", false, true},
|
||||||
|
{"haul", storage.RunBeat{Kind: "haul", Amount: 6, Target: "Ironcap", Count: 3},
|
||||||
|
"Gathered 6 — mostly Ironcap (3 kinds)", false, false},
|
||||||
|
{"region", storage.RunBeat{Kind: "region", Region: "The Shallows", Target: "The Deep"},
|
||||||
|
"Left The Shallows for The Deep", false, false},
|
||||||
|
{"cleared", storage.RunBeat{Kind: "end", Outcome: "cleared"}, "Run complete", false, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
l, ok := renderRunBeat(c.beat)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("beat produced no line")
|
||||||
|
}
|
||||||
|
if l.Text != c.want {
|
||||||
|
t.Errorf("text = %q, want %q", l.Text, c.want)
|
||||||
|
}
|
||||||
|
if l.Hurt != c.hurt || l.Good != c.good {
|
||||||
|
t.Errorf("tint = hurt:%v good:%v, want hurt:%v good:%v", l.Hurt, l.Good, c.hurt, c.good)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A haul of nothing is not a beat. gogobee already skips it, but the renderer
|
||||||
|
// is the second line of defence against a column of "Gathered 0".
|
||||||
|
if _, ok := renderRunBeat(storage.RunBeat{Kind: "haul"}); ok {
|
||||||
|
t.Error("empty haul produced a line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHPTailOnlyWhenReal. A zero max means gogobee didn't send a pair, not that
|
||||||
|
// the adventurer has no health — and "0/0 HP" on a winning line reads as a death.
|
||||||
|
func TestHPTailOnlyWhenReal(t *testing.T) {
|
||||||
|
l, _ := renderRunBeat(storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Rat"})
|
||||||
|
if got := l.Text; got != "Rat down — untouched" {
|
||||||
|
t.Errorf("text = %q; a missing HP pair must not be drawn", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-2
@@ -69,6 +69,7 @@ type Server struct {
|
|||||||
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||||
adv config.AdventureConfig // gogobee adventure-news seam
|
adv config.AdventureConfig // gogobee adventure-news seam
|
||||||
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
|
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
|
||||||
|
roomSilent map[string]bool // event types TwinBee announces itself: site yes, Matrix never
|
||||||
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
|
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
|
||||||
|
|
||||||
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
||||||
@@ -102,7 +103,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
shared []string
|
shared []string
|
||||||
pages []string
|
pages []string
|
||||||
}{
|
}{
|
||||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who"}},
|
{"layout", []string{"_card", "_realmnav"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who", "siege", "run_report", "realm", "standings", "firsts"}},
|
||||||
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
||||||
}
|
}
|
||||||
tpls := make(map[string]*template.Template)
|
tpls := make(map[string]*template.Template)
|
||||||
@@ -144,7 +145,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
live = append(live, ch)
|
live = append(live, ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
|
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, roomSilent: adv.RoomSilentSet(), channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
|
||||||
|
|
||||||
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||||
// provider is unreachable at boot we log and serve anonymously rather than
|
// provider is unreachable at boot we log and serve anonymously rather than
|
||||||
@@ -231,6 +232,39 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
||||||
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
||||||
|
|
||||||
|
// The expedition liveblog. Beats arrive bearer-authed and render two ways:
|
||||||
|
// live, inside the adventurer page under the map, on that page's own poll —
|
||||||
|
// and afterwards as the run's own report, which is the artefact a dispatch
|
||||||
|
// links to and a player shares. Three segments, so the report never overlaps
|
||||||
|
// /adventure/{guid}, and its middle segment is a literal, so it never
|
||||||
|
// overlaps /adventure/art/{type} either.
|
||||||
|
mux.HandleFunc("POST /api/ingest/run", s.handleRunIngest)
|
||||||
|
mux.HandleFunc("GET /adventure/run/{run_id}", s.handleRunReport)
|
||||||
|
|
||||||
|
// The Siege war room. Ingest is bearer-authed like the roster; the page and
|
||||||
|
// its poll are public — the same exposure the board already has.
|
||||||
|
//
|
||||||
|
// GET /adventure/siege is a LITERAL two-segment pattern, so it beats
|
||||||
|
// /adventure/{guid} on Go's most-specific-match rule. Nothing is shadowed by
|
||||||
|
// it either: a dispatch guid is "<type>:<hash>:<ts>" and can never be the
|
||||||
|
// bare word "siege".
|
||||||
|
mux.HandleFunc("POST /api/ingest/siege", s.handleSiegeIngest)
|
||||||
|
mux.HandleFunc("GET /api/adventure/siege", s.handleSiegeAPI)
|
||||||
|
mux.HandleFunc("GET /adventure/siege", s.handleSiegePage)
|
||||||
|
|
||||||
|
// The realm: the world map, the board, and the hall of firsts. One
|
||||||
|
// bearer-authed ingest behind all three, and all three public — every number
|
||||||
|
// on them is already public on the board or in a dispatch.
|
||||||
|
//
|
||||||
|
// Same literal-two-segment reasoning as /adventure/siege: "realm",
|
||||||
|
// "standings" and "firsts" beat /adventure/{guid} on Go's most-specific-match
|
||||||
|
// rule, and nothing is shadowed because a dispatch guid is
|
||||||
|
// "<type>:<hash>:<ts>" and can never be a bare word.
|
||||||
|
mux.HandleFunc("POST /api/ingest/realm", s.handleRealmIngest)
|
||||||
|
mux.HandleFunc("GET /adventure/realm", s.handleRealmPage)
|
||||||
|
mux.HandleFunc("GET /adventure/standings", s.handleStandingsPage)
|
||||||
|
mux.HandleFunc("GET /adventure/firsts", s.handleFirstsPage)
|
||||||
|
|
||||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||||
// channel listing, registered in the channels loop above).
|
// channel listing, registered in the channels loop above).
|
||||||
@@ -262,6 +296,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
|
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
|
||||||
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)
|
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)
|
||||||
|
|
||||||
|
// The action queue's game-box wire: gogobee polls the verbs an owner asked for
|
||||||
|
// from the web (pull out of a run, take today's bout) and pushes a verdict.
|
||||||
|
// Bearer-authed for the same reason as every seam above it. Its own poll and
|
||||||
|
// its own table, not more actions on the equip queue — see storage/orders.go.
|
||||||
|
mux.HandleFunc("GET /api/adventure/orders/pending", s.handleAdvOrdersPending)
|
||||||
|
mux.HandleFunc("POST /api/adventure/orders/verdict", s.handleAdvOrderVerdict)
|
||||||
|
|
||||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
@@ -295,10 +336,17 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
// storefront, since without a board there is no detail page to equip from.
|
// storefront, since without a board there is no detail page to equip from.
|
||||||
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
|
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
|
||||||
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
|
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
|
||||||
|
|
||||||
|
// The action queue, owner side. Signed-in only — the session IS the
|
||||||
|
// character, there is nothing in the request to identify one — and gated
|
||||||
|
// on the adventure seam like everything else here.
|
||||||
|
mux.HandleFunc("POST /api/adventure/order", s.handleAdvOrder)
|
||||||
|
mux.HandleFunc("GET /api/adventure/orders", s.handleAdvOrders)
|
||||||
}
|
}
|
||||||
if s.cfg.Push.Enabled {
|
if s.cfg.Push.Enabled {
|
||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||||
|
mux.HandleFunc("POST /api/push/heal", s.handlePushHeal)
|
||||||
}
|
}
|
||||||
if s.tts != nil {
|
if s.tts != nil {
|
||||||
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Siege war room.
|
||||||
|
//
|
||||||
|
// The Siege is the one thing in the realm everybody works on at once: a named
|
||||||
|
// boss camps outside town for 72 hours behind a single shared HP pool, and every
|
||||||
|
// adventurer gets one bout a day against it. Until now that existed only in
|
||||||
|
// Matrix, which means it was invisible to anyone not in the room at the time —
|
||||||
|
// a communal event nobody can see is a communal event that fails.
|
||||||
|
//
|
||||||
|
// It arrives the same way the board does: gogobee pushes the whole thing on the
|
||||||
|
// roster tick and Pete replaces its copy. That is the right shape here for the
|
||||||
|
// same reason it was there — the pool is state, not history. A retried snapshot
|
||||||
|
// would be a lie about how much HP is left, and the next tick carries the truth.
|
||||||
|
//
|
||||||
|
// The page's job is one thing above all others: make the bar visibly move. The
|
||||||
|
// whole point of a shared pool is watching the town chip it down, and a number
|
||||||
|
// that only changes when you reload is not a siege, it is a report about one.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// siegeStaleAfter — how old the snapshot can get before the page stops
|
||||||
|
// claiming the bar is live. Same reasoning and same ticker as the roster, so
|
||||||
|
// the same window: several missed pushes, not one unlucky one.
|
||||||
|
siegeStaleAfter = 12 * time.Minute
|
||||||
|
|
||||||
|
// siegeMaxDefenders / siegeMaxHistory bound a push. A realm has tens of
|
||||||
|
// players and a Siege a month; these only stop a malformed or hostile payload
|
||||||
|
// spooling unbounded rows.
|
||||||
|
siegeMaxDefenders = 500
|
||||||
|
siegeMaxHistory = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// siegePush is the payload gogobee POSTs to /api/ingest/siege.
|
||||||
|
type siegePush struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
storage.Siege
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiegeView is the war room as the page renders it: gogobee's facts plus the
|
||||||
|
// few presentational things Pete is allowed to decide (percentages, wording,
|
||||||
|
// the fought/waiting split).
|
||||||
|
type SiegeView struct {
|
||||||
|
Active bool
|
||||||
|
Stale bool
|
||||||
|
Known bool // gogobee has pushed at least one snapshot
|
||||||
|
BossName string
|
||||||
|
Tier int
|
||||||
|
HPCurrent int
|
||||||
|
HPMax int
|
||||||
|
HPPercent int
|
||||||
|
Damage int // HPMax - HPCurrent, the town's total contribution
|
||||||
|
StartsAt int64
|
||||||
|
EndsAt int64
|
||||||
|
BoutsToday int
|
||||||
|
Fought []storage.SiegeDefender // took today's bout
|
||||||
|
Waiting []storage.SiegeDefender // hasn't yet — the gap the page wants felt
|
||||||
|
Mustered int // defenders who have fought at least once
|
||||||
|
History []SiegePastView
|
||||||
|
SnapshotAt int64
|
||||||
|
LastSeenAgo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiegePastView is one closed-out Siege, with the bar it ended on.
|
||||||
|
type SiegePastView struct {
|
||||||
|
storage.SiegePast
|
||||||
|
Won bool
|
||||||
|
HPPercent int
|
||||||
|
When string
|
||||||
|
}
|
||||||
|
|
||||||
|
type siegePage struct {
|
||||||
|
pageData
|
||||||
|
Siege SiegeView
|
||||||
|
// The viewer's own standing in the muster, when they are signed in and have
|
||||||
|
// an adventurer. This is the only personal thing on an otherwise wholly
|
||||||
|
// public page, and it exists to hang one button off: the war room is where
|
||||||
|
// somebody realises the town needs them, so it is where they should be able
|
||||||
|
// to answer.
|
||||||
|
//
|
||||||
|
// YouFought reads a snapshot up to two minutes old, so it decides what the
|
||||||
|
// page OFFERS and never what the game allows — a bout taken in Matrix inside
|
||||||
|
// that window comes back from gogobee as rejected_already_fought, which is
|
||||||
|
// the honest answer and the one the strip shows.
|
||||||
|
YouOnBoard bool
|
||||||
|
YouFought bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSiegeIngest replaces the war room with gogobee's latest snapshot.
|
||||||
|
func (s *Server) handleSiegeIngest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var push siegePush
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Defenders) > siegeMaxDefenders {
|
||||||
|
http.Error(w, "defender board too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.History) > siegeMaxHistory {
|
||||||
|
http.Error(w, "history too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if push.SnapshotAt <= 0 {
|
||||||
|
push.SnapshotAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
// A snapshot with no timestamp can't age, so it would claim to be live
|
||||||
|
// forever; the roster ingest treats that the same way.
|
||||||
|
push.Siege.SnapshotAt = push.SnapshotAt
|
||||||
|
|
||||||
|
// Never trust the channel with a name. gogobee already anonymises opted-out
|
||||||
|
// defenders (empty token, "an adventurer"), but a nameless row would render
|
||||||
|
// as a blank line on a public page, so it is rejected rather than drawn.
|
||||||
|
for i, d := range push.Defenders {
|
||||||
|
if d.Name == "" {
|
||||||
|
http.Error(w, fmt.Sprintf("defender %d: name is required", i), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An active Siege with no pool is not a Siege — it is a division by zero on
|
||||||
|
// the bar, and the page has no honest way to draw it.
|
||||||
|
if push.Active && push.HPMax <= 0 {
|
||||||
|
http.Error(w, "active siege needs hp_max", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.ReplaceSiege(push.Siege, push.SnapshotAt); err != nil {
|
||||||
|
slog.Error("siege ingest: replace failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("siege ingest: war room replaced",
|
||||||
|
"active", push.Active, "boss", push.BossName,
|
||||||
|
"defenders", len(push.Defenders), "history", len(push.History))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSiegePage serves the war room. Public: the Siege is a town-wide event
|
||||||
|
// and the defender board is the same anonymity model as the live board.
|
||||||
|
func (s *Server) handleSiegePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.track(r, "adventure")
|
||||||
|
|
||||||
|
base := s.base(r)
|
||||||
|
base.Active = "adventure"
|
||||||
|
view := s.siege()
|
||||||
|
page := siegePage{pageData: base, Siege: view}
|
||||||
|
if base.User != nil {
|
||||||
|
if token, ok := storage.SelfToken(buyerLocalpart(base.User)); ok {
|
||||||
|
page.YouOnBoard = true
|
||||||
|
for _, d := range view.Fought {
|
||||||
|
if d.Token == token {
|
||||||
|
page.YouFought = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unlike the who page this one is NOT noindex: it names a boss and a town,
|
||||||
|
// and the defender list is character names that are already public on the
|
||||||
|
// board. There is nothing here that ties a page to a person more than
|
||||||
|
// /adventure already does.
|
||||||
|
s.render(w, "siege", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSiegeAPI serves the war room as JSON for the page's own re-poll. This
|
||||||
|
// is what makes the bar move without a reload, so it is deliberately cheap and
|
||||||
|
// deliberately public — the same exposure as the rendered page, no more.
|
||||||
|
func (s *Server) handleSiegeAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := s.siege()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"active": v.Active,
|
||||||
|
"stale": v.Stale,
|
||||||
|
"known": v.Known,
|
||||||
|
"boss_name": v.BossName,
|
||||||
|
"tier": v.Tier,
|
||||||
|
"hp_current": v.HPCurrent,
|
||||||
|
"hp_max": v.HPMax,
|
||||||
|
"hp_percent": v.HPPercent,
|
||||||
|
"damage": v.Damage,
|
||||||
|
"ends_at": v.EndsAt,
|
||||||
|
"bouts_today": v.BoutsToday,
|
||||||
|
"mustered": v.Mustered,
|
||||||
|
"fought": v.Fought,
|
||||||
|
"waiting": v.Waiting,
|
||||||
|
"snapshot_at": v.SnapshotAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// siege builds the view from the last snapshot.
|
||||||
|
//
|
||||||
|
// A stale war room is still returned, dimmed and labelled, for the same reason
|
||||||
|
// the board is: "here is where the pool stood when we lost contact" beats an
|
||||||
|
// empty page, and it stops the bar from quietly lying about being live.
|
||||||
|
func (s *Server) siege() SiegeView {
|
||||||
|
snap, known, err := storage.LoadSiege()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("siege: load failed", "err", err)
|
||||||
|
return SiegeView{Stale: true}
|
||||||
|
}
|
||||||
|
v := SiegeView{
|
||||||
|
Active: snap.Active,
|
||||||
|
Known: known,
|
||||||
|
BossName: snap.BossName,
|
||||||
|
Tier: snap.Tier,
|
||||||
|
HPCurrent: snap.HPCurrent,
|
||||||
|
HPMax: snap.HPMax,
|
||||||
|
StartsAt: snap.StartsAt,
|
||||||
|
EndsAt: snap.EndsAt,
|
||||||
|
BoutsToday: snap.BoutsToday,
|
||||||
|
SnapshotAt: snap.SnapshotAt,
|
||||||
|
}
|
||||||
|
if !known || snap.SnapshotAt == 0 || time.Since(time.Unix(snap.SnapshotAt, 0)) > siegeStaleAfter {
|
||||||
|
v.Stale = true
|
||||||
|
}
|
||||||
|
if snap.SnapshotAt > 0 {
|
||||||
|
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
|
||||||
|
}
|
||||||
|
if snap.HPMax > 0 {
|
||||||
|
v.HPPercent = clampPercent(snap.HPCurrent * 100 / snap.HPMax)
|
||||||
|
v.Damage = snap.HPMax - snap.HPCurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fought/waiting split is the mechanic made visible: one bout per person
|
||||||
|
// per day means an adventurer standing in the "yet to fight" column is a bout
|
||||||
|
// the town has not spent yet. gogobee sends every alive, non-opted-out
|
||||||
|
// adventurer — not just contributors — precisely so this column exists.
|
||||||
|
for _, d := range snap.Defenders {
|
||||||
|
if d.Fights > 0 {
|
||||||
|
v.Mustered++
|
||||||
|
}
|
||||||
|
if d.FoughtToday {
|
||||||
|
v.Fought = append(v.Fought, d)
|
||||||
|
} else {
|
||||||
|
v.Waiting = append(v.Waiting, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range snap.History {
|
||||||
|
pv := SiegePastView{SiegePast: h, Won: h.Outcome == "defeated"}
|
||||||
|
if h.HPMax > 0 {
|
||||||
|
pv.HPPercent = clampPercent(h.HPRemaining * 100 / h.HPMax)
|
||||||
|
}
|
||||||
|
if h.EndedAt > 0 {
|
||||||
|
pv.When = time.Unix(h.EndedAt, 0).UTC().Format("Jan 2, 2006")
|
||||||
|
}
|
||||||
|
v.History = append(v.History, pv)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampPercent keeps a computed bar width inside 0–100 whatever the snapshot
|
||||||
|
// claimed. gogobee clamps its own pool at zero, but the bar is drawn from
|
||||||
|
// arithmetic on two numbers off the wire and must not be able to overflow its
|
||||||
|
// track on a malformed one.
|
||||||
|
func clampPercent(p int) int {
|
||||||
|
if p < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if p > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func postSiege(t *testing.T, s *Server, token string, push siegePush) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(push)
|
||||||
|
req := httptest.NewRequest("POST", "/api/ingest/siege", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSiegeIngest(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveSiege(now int64, hpCurrent int, defenders ...storage.SiegeDefender) siegePush {
|
||||||
|
return siegePush{SnapshotAt: now, Siege: storage.Siege{
|
||||||
|
Active: true,
|
||||||
|
BossID: 7,
|
||||||
|
BossName: "Gorloth the Sunderer",
|
||||||
|
Tier: 4,
|
||||||
|
HPCurrent: hpCurrent,
|
||||||
|
HPMax: 1000,
|
||||||
|
StartsAt: now - 3600,
|
||||||
|
EndsAt: now + 68*3600,
|
||||||
|
Defenders: defenders,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeReplacesNeverMerges is the war room's core contract, and it is the
|
||||||
|
// same one the board has: gogobee sends the whole thing and Pete's copy becomes
|
||||||
|
// it. A defender who dropped out of a later snapshot has to leave the board, and
|
||||||
|
// a Siege that resolved has to stop showing a live bar. An upsert would leave
|
||||||
|
// both standing forever.
|
||||||
|
func TestSiegeReplacesNeverMerges(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postSiege(t, s, "tok", liveSiege(now, 800,
|
||||||
|
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 2, Damage: 150, FoughtToday: true},
|
||||||
|
storage.SiegeDefender{Token: "t2", Name: "Quack", Fights: 1, Damage: 50},
|
||||||
|
)); w.Code != 200 {
|
||||||
|
t.Fatalf("first push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quack opts out; gogobee stops sending her under a name.
|
||||||
|
if w := postSiege(t, s, "tok", liveSiege(now+120, 700,
|
||||||
|
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 3, Damage: 250, FoughtToday: true},
|
||||||
|
)); w.Code != 200 {
|
||||||
|
t.Fatalf("second push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.siege()
|
||||||
|
if got := len(v.Fought) + len(v.Waiting); got != 1 {
|
||||||
|
t.Fatalf("muster has %d rows, want 1 — a dropped defender survived the swap", got)
|
||||||
|
}
|
||||||
|
if v.HPCurrent != 700 {
|
||||||
|
t.Errorf("hp_current = %d, want 700 — the pool didn't follow the snapshot", v.HPCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a snapshot saying the Siege ended must clear the live bar entirely.
|
||||||
|
if w := postSiege(t, s, "tok", siegePush{SnapshotAt: now + 240, Siege: storage.Siege{Active: false}}); w.Code != 200 {
|
||||||
|
t.Fatalf("resolution push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if v := s.siege(); v.Active {
|
||||||
|
t.Error("war room still reads active after a snapshot said the Siege was over")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeSplitsFoughtFromWaiting is the mechanic made visible. One bout per
|
||||||
|
// person per day means a defender who hasn't swung today is damage the pool has
|
||||||
|
// not seen — the page's whole nudge — so the split has to come off FoughtToday
|
||||||
|
// and not off "has any fights at all". A veteran of nine bouts who hasn't been
|
||||||
|
// out today belongs in the waiting column.
|
||||||
|
func TestSiegeSplitsFoughtFromWaiting(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
postSiege(t, s, "tok", liveSiege(now, 500,
|
||||||
|
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 9, Damage: 400, FoughtToday: false},
|
||||||
|
storage.SiegeDefender{Token: "t2", Name: "Quack", Fights: 1, Damage: 100, FoughtToday: true},
|
||||||
|
storage.SiegeDefender{Token: "t3", Name: "Newbie", Fights: 0, Damage: 0},
|
||||||
|
))
|
||||||
|
|
||||||
|
v := s.siege()
|
||||||
|
if len(v.Fought) != 1 || v.Fought[0].Name != "Quack" {
|
||||||
|
t.Errorf("fought-today = %+v, want just Quack", v.Fought)
|
||||||
|
}
|
||||||
|
if len(v.Waiting) != 2 {
|
||||||
|
t.Fatalf("waiting = %d rows, want 2 (Josie has bouts but not today, Newbie has none)", len(v.Waiting))
|
||||||
|
}
|
||||||
|
if v.Mustered != 2 {
|
||||||
|
t.Errorf("mustered = %d, want 2 — that counts anyone who has ever fought, not today's turnout", v.Mustered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeAnonDefenderKeepsRankLosesLink is the opt-out rule, and it is
|
||||||
|
// deliberately NOT the board's rule. The board omits an opted-out player
|
||||||
|
// outright, because a row showing class + level + zone re-identifies them. Here
|
||||||
|
// the damage is part of what the town did to the boss: dropping it would
|
||||||
|
// understate the shared effort and stop the numbers adding up. So the row stays,
|
||||||
|
// anonymous, with no token — and therefore no link to a page that would name them.
|
||||||
|
func TestSiegeAnonDefenderKeepsRankLosesLink(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
postSiege(t, s, "tok", liveSiege(now, 100,
|
||||||
|
storage.SiegeDefender{Name: "an adventurer", Fights: 5, Damage: 700, FoughtToday: true},
|
||||||
|
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 1, Damage: 200, FoughtToday: true},
|
||||||
|
))
|
||||||
|
|
||||||
|
v := s.siege()
|
||||||
|
if len(v.Fought) != 2 {
|
||||||
|
t.Fatalf("fought = %d rows, want 2 — the anonymous contributor was dropped", len(v.Fought))
|
||||||
|
}
|
||||||
|
// Push order is gogobee's ranking and Pete must preserve it: the anonymous
|
||||||
|
// defender out-damaged Josie and holds the top of the board.
|
||||||
|
if v.Fought[0].Name != "an adventurer" {
|
||||||
|
t.Errorf("top of the board is %q, want the anonymous defender — rank was lost", v.Fought[0].Name)
|
||||||
|
}
|
||||||
|
if v.Fought[0].Token != "" {
|
||||||
|
t.Error("anonymous defender carries a token — that is a link back to a page that names them")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeGoesStale: if gogobee stops talking, the bar must stop claiming to be
|
||||||
|
// live. A health bar that confidently shows a pool level from an hour ago is
|
||||||
|
// worse than one that admits it lost the wire, because the whole promise of the
|
||||||
|
// page is that the number is true *right now*.
|
||||||
|
func TestSiegeGoesStale(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
old := time.Now().Add(-30 * time.Minute).Unix()
|
||||||
|
|
||||||
|
postSiege(t, s, "tok", liveSiege(old, 900))
|
||||||
|
v := s.siege()
|
||||||
|
if !v.Stale {
|
||||||
|
t.Error("a 30-minute-old snapshot reads as live")
|
||||||
|
}
|
||||||
|
if v.HPCurrent != 900 {
|
||||||
|
t.Errorf("hp_current = %d, want 900 — a stale war room must still show the last known pool", v.HPCurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeNeverPushedIsNotAnEmptySiege distinguishes the two states that look
|
||||||
|
// alike from the outside: gogobee has never told us about a Siege, versus it has
|
||||||
|
// told us there isn't one. Only the second can honestly say "quiet month".
|
||||||
|
func TestSiegeNeverPushedIsNotAnEmptySiege(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
|
||||||
|
if v := s.siege(); v.Known {
|
||||||
|
t.Error("war room claims to know the Siege state before gogobee ever pushed one")
|
||||||
|
}
|
||||||
|
postSiege(t, s, "tok", siegePush{SnapshotAt: time.Now().Unix(), Siege: storage.Siege{Active: false}})
|
||||||
|
v := s.siege()
|
||||||
|
if !v.Known {
|
||||||
|
t.Error("war room still reads unknown after a snapshot said no Siege is camped")
|
||||||
|
}
|
||||||
|
if v.Active {
|
||||||
|
t.Error("no-siege snapshot rendered as an active Siege")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeRejectsUnrenderableSnapshots. Two things a public page cannot draw:
|
||||||
|
// a defender with no name (a blank row), and an active Siege with no pool (a
|
||||||
|
// divide-by-zero on the bar). Both are 400s — unlike an unknown *event type*,
|
||||||
|
// which W0 deliberately made a 200, because that one is a styling gap where
|
||||||
|
// these are malformed state.
|
||||||
|
func TestSiegeRejectsUnrenderableSnapshots(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
w := postSiege(t, s, "tok", liveSiege(now, 500, storage.SiegeDefender{Token: "t1", Fights: 1}))
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("nameless defender = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := liveSiege(now, 500)
|
||||||
|
bad.HPMax = 0
|
||||||
|
if w := postSiege(t, s, "tok", bad); w.Code != 400 {
|
||||||
|
t.Errorf("active siege with no pool = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/ingest/siege", strings.NewReader("{}"))
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleSiegeIngest(rec, req)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Errorf("bad bearer = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeHistoryRendersEndedBar. The history is what makes the live bar mean
|
||||||
|
// anything, so the numbers behind it have to survive the round trip: a won Siege
|
||||||
|
// ended at zero (an empty track), a lost one shows what was still standing.
|
||||||
|
func TestSiegeHistoryRendersEndedBar(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
push := siegePush{SnapshotAt: now, Siege: storage.Siege{
|
||||||
|
Active: false,
|
||||||
|
History: []storage.SiegePast{
|
||||||
|
{BossID: 2, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "survived",
|
||||||
|
HPRemaining: 300, HPMax: 1200, Defenders: 3, MVP: "Josie", MVPFights: 4, EndedAt: now - 86400},
|
||||||
|
{BossID: 1, BossName: "The Iron Colossus", Tier: 4, Outcome: "defeated",
|
||||||
|
HPRemaining: 0, HPMax: 800, Defenders: 5, MVP: "Quack", MVPFights: 6, EndedAt: now - 172800},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if w := postSiege(t, s, "tok", push); w.Code != 200 {
|
||||||
|
t.Fatalf("history push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.siege()
|
||||||
|
if len(v.History) != 2 {
|
||||||
|
t.Fatalf("history = %d rows, want 2", len(v.History))
|
||||||
|
}
|
||||||
|
// Newest first: the Wyrm ended a day ago, the Colossus two.
|
||||||
|
if v.History[0].BossName != "The Ashen Wyrm" {
|
||||||
|
t.Errorf("history[0] = %q, want the most recent Siege first", v.History[0].BossName)
|
||||||
|
}
|
||||||
|
if v.History[0].Won {
|
||||||
|
t.Error("a survived Siege reads as a win")
|
||||||
|
}
|
||||||
|
if v.History[0].HPPercent != 25 {
|
||||||
|
t.Errorf("survived bar = %d%%, want 25 (300 of 1200 still standing)", v.History[0].HPPercent)
|
||||||
|
}
|
||||||
|
if !v.History[1].Won || v.History[1].HPPercent != 0 {
|
||||||
|
t.Errorf("defeated Siege = won %v at %d%%, want won at 0%%", v.History[1].Won, v.History[1].HPPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeHistoryCollisionDoesNotFreezeTheWarRoom. boss_id is the history's
|
||||||
|
// primary key and it is not settled whether gogobee means the siege instance or
|
||||||
|
// the boss type by it, so two rows can arrive sharing one. Under a bare INSERT
|
||||||
|
// that failed the transaction carrying the live boss and the muster too, and the
|
||||||
|
// war room stopped moving on the last good snapshot with nothing to say why. The
|
||||||
|
// ingest has to degrade to a lost history row instead.
|
||||||
|
func TestSiegeHistoryCollisionDoesNotFreezeTheWarRoom(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
push := liveSiege(now, 650)
|
||||||
|
push.Siege.History = []storage.SiegePast{
|
||||||
|
{BossID: 3, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "survived",
|
||||||
|
HPRemaining: 300, HPMax: 1200, Defenders: 3, EndedAt: now - 86400},
|
||||||
|
{BossID: 3, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "defeated",
|
||||||
|
HPRemaining: 0, HPMax: 1200, Defenders: 6, EndedAt: now - 30*86400},
|
||||||
|
}
|
||||||
|
if w := postSiege(t, s, "tok", push); w.Code != 200 {
|
||||||
|
t.Fatalf("push with a duplicated boss_id = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
v := s.siege()
|
||||||
|
if !v.Active || v.HPCurrent != 650 {
|
||||||
|
t.Fatalf("war room = active %v at %d hp, want the pushed live boss — the collision took the whole push down",
|
||||||
|
v.Active, v.HPCurrent)
|
||||||
|
}
|
||||||
|
if len(v.History) != 1 {
|
||||||
|
t.Errorf("history = %d rows, want 1 (the last of the colliding pair)", len(v.History))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeAPIFeedsTheBar. The bar only moves because this endpoint answers, so
|
||||||
|
// the field names it emits are load-bearing: the page's JS reads hp_percent to
|
||||||
|
// set the width and active to decide whether to keep polling at all.
|
||||||
|
func TestSiegeAPIFeedsTheBar(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
postSiege(t, s, "tok", liveSiege(now, 250,
|
||||||
|
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 1, Damage: 750, FoughtToday: true}))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleSiegeAPI(rec, httptest.NewRequest("GET", "/api/adventure/siege", nil))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("api = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["active"] != true {
|
||||||
|
t.Error("api says the Siege isn't active")
|
||||||
|
}
|
||||||
|
if got["hp_percent"].(float64) != 25 {
|
||||||
|
t.Errorf("hp_percent = %v, want 25 — the bar would draw at the wrong width", got["hp_percent"])
|
||||||
|
}
|
||||||
|
if got["bouts_today"] == nil || got["mustered"].(float64) != 1 {
|
||||||
|
t.Errorf("api dropped the turnout counters: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeTemplateExecutes renders all three states the page has to survive —
|
||||||
|
// a live Siege, a quiet realm with history, and a realm that has never had one.
|
||||||
|
// Template execution errors are silent in production (render logs and returns a
|
||||||
|
// half-written body), so a parse-clean template that blows up on a nil field is
|
||||||
|
// exactly the class of bug that reaches a visitor before it reaches a log.
|
||||||
|
func TestSiegeTemplateExecutes(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
render := func(v SiegeView) string {
|
||||||
|
t.Helper()
|
||||||
|
var b strings.Builder
|
||||||
|
if err := s.tpls["siege"].ExecuteTemplate(&b, "layout",
|
||||||
|
siegePage{pageData: pageData{SiteTitle: "Pete", Channels: channels}, Siege: v}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
postSiege(t, s, "tok", liveSiege(now, 250,
|
||||||
|
storage.SiegeDefender{Token: "t1", Name: "Josie", Level: 12, Fights: 3, Damage: 600, FoughtToday: true},
|
||||||
|
storage.SiegeDefender{Name: "an adventurer", Fights: 1, Damage: 150},
|
||||||
|
storage.SiegeDefender{Token: "t3", Name: "Camcast", Level: 4},
|
||||||
|
))
|
||||||
|
live := render(s.siege())
|
||||||
|
if !strings.Contains(live, "Gorloth the Sunderer") {
|
||||||
|
t.Error("live page doesn't name the boss")
|
||||||
|
}
|
||||||
|
if !strings.Contains(live, `style="width: 25%"`) {
|
||||||
|
t.Error("live page didn't draw the bar at the pool's width")
|
||||||
|
}
|
||||||
|
if !strings.Contains(live, `/adventure/who/t1`) {
|
||||||
|
t.Error("live page doesn't link a named defender to their page")
|
||||||
|
}
|
||||||
|
if strings.Contains(live, `/adventure/who/"`) {
|
||||||
|
t.Error("live page emitted an empty who link — the anonymous defender got a link anyway")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet realm with history, and a realm that has never seen one.
|
||||||
|
quiet := render(SiegeView{Known: true, History: []SiegePastView{{
|
||||||
|
SiegePast: storage.SiegePast{BossName: "The Iron Colossus", Tier: 4, Outcome: "defeated",
|
||||||
|
HPMax: 800, Defenders: 5, MVP: "Quack", MVPFights: 6}, Won: true, When: "Jun 1, 2026"}}})
|
||||||
|
if !strings.Contains(quiet, "Nothing's camped outside town") || !strings.Contains(quiet, "The Iron Colossus") {
|
||||||
|
t.Error("quiet page lost either the empty state or the history")
|
||||||
|
}
|
||||||
|
if fresh := render(SiegeView{}); !strings.Contains(fresh, "haven't heard from the field") {
|
||||||
|
t.Error("never-pushed page doesn't say it hasn't heard from the field")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1049,27 +1049,33 @@ html[data-phase="night"] {
|
|||||||
40%, 60% { transform: translateX(5px); }
|
40%, 60% { transform: translateX(5px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The phrase. Tiles wrap between words, never inside one. */
|
/* The phrase. Tiles wrap between words; a word only breaks inside itself when
|
||||||
|
it is wider than the felt, which on a phone a long one is. The gap between
|
||||||
|
words stays several times the gap inside one, so a break still reads as a
|
||||||
|
break in the word rather than the end of it. */
|
||||||
.pete-board {
|
.pete-board {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem 1.1rem;
|
gap: 0.35rem clamp(0.6rem, 3vw, 1.1rem);
|
||||||
min-height: 5rem;
|
min-height: 5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.pete-word {
|
.pete-word {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pete-tile {
|
.pete-tile {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
height: 2.9rem;
|
height: clamp(2rem, 8.6vw, 2.9rem);
|
||||||
width: 2.2rem;
|
width: clamp(1.5rem, 6.5vw, 2.2rem);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||||
font-size: 1.4rem;
|
font-size: clamp(1rem, 4.2vw, 1.4rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: rgba(0, 0, 0, 0.22);
|
background: rgba(0, 0, 0, 0.22);
|
||||||
@@ -1153,19 +1159,23 @@ html[data-phase="night"] {
|
|||||||
.pete-keys { display: grid; gap: 0.35rem; }
|
.pete-keys { display: grid; gap: 0.35rem; }
|
||||||
.pete-key-row {
|
.pete-key-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; }
|
.pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; }
|
||||||
.pete-key-row[data-digits="1"] .pete-key {
|
.pete-key-row[data-digits="1"] .pete-key {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
min-width: 1.8rem;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
/* Ten keys have to fit the row on a phone, so a key is laid out at a width the
|
||||||
|
narrowest screen can afford and grows into whatever room the screen actually
|
||||||
|
has. Wrapping is the last resort under that, not the first answer. */
|
||||||
.pete-key {
|
.pete-key {
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
min-width: 2.2rem;
|
min-width: clamp(1.15rem, 5vw, 2.2rem);
|
||||||
flex: 0 1 2.4rem;
|
max-width: 2.4rem;
|
||||||
|
flex: 1 1 clamp(1.15rem, 5vw, 2.4rem);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
||||||
border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent);
|
border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent);
|
||||||
@@ -1498,6 +1508,52 @@ html[data-phase="night"] {
|
|||||||
/* A move that won't go. Said in the one language a board can speak. */
|
/* A move that won't go. Said in the one language a board can speak. */
|
||||||
.pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
|
.pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
|
||||||
|
|
||||||
|
/* Dragging. A press on a card that can be lifted belongs to the card and not to
|
||||||
|
the page, or a drag down a column scrolls the board instead of moving a run. */
|
||||||
|
.pete-card[data-live="1"] { touch-action: none; }
|
||||||
|
[data-solitaire][data-dragging] { cursor: grabbing; user-select: none; }
|
||||||
|
[data-solitaire][data-dragging] .pete-card[data-live="1"]:hover .pete-card-front {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
/* The run you lifted stays on the felt, faded: where it came from, and where it
|
||||||
|
goes back to if you let go over nothing. */
|
||||||
|
[data-solitaire][data-dragging] .pete-card[data-held="1"] {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
[data-solitaire][data-dragging] .pete-card[data-held="1"] .pete-card-front {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The copy under your hand. It doesn't answer to hit tests — the felt beneath it
|
||||||
|
has to be the thing you're pointing at. */
|
||||||
|
.pete-drag {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 60;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 14px 22px rgba(0, 0, 0, 0.45));
|
||||||
|
transform: translate3d(-999px, -999px, 0);
|
||||||
|
}
|
||||||
|
.pete-drag .pete-card { position: relative; }
|
||||||
|
.pete-drag[data-ok="1"] .pete-card-front {
|
||||||
|
box-shadow: 0 0 0 3px rgba(242, 181, 61, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The pile you're actually over, told apart from the ones that would merely
|
||||||
|
take it. */
|
||||||
|
.pete-slot[data-over="1"],
|
||||||
|
.pete-col[data-over="1"] .pete-slot,
|
||||||
|
.pete-col[data-over="1"] .pete-card:last-child .pete-card-front {
|
||||||
|
border-color: rgba(242, 181, 61, 1);
|
||||||
|
box-shadow: 0 0 0 4px rgba(242, 181, 61, 0.75);
|
||||||
|
}
|
||||||
|
.pete-slot[data-over="1"],
|
||||||
|
.pete-col[data-over="1"] .pete-slot {
|
||||||
|
background: rgba(242, 181, 61, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
/* A card arriving on a foundation lands with a flash: it is the only move in
|
/* A card arriving on a foundation lands with a flash: it is the only move in
|
||||||
the game that pays you, so it is the only one that gets a noise. */
|
the game that pays you, so it is the only one that gets a noise. */
|
||||||
.pete-home-flash { animation: pete-home 0.5s ease-out; }
|
.pete-home-flash { animation: pete-home 0.5s ease-out; }
|
||||||
@@ -2711,3 +2767,372 @@ html[data-room] .pete-felt {
|
|||||||
.cmp-delta-up { color: color-mix(in srgb, #3fa66a 65%, var(--ink)); background: color-mix(in srgb, #3fa66a 13%, var(--card)); }
|
.cmp-delta-up { color: color-mix(in srgb, #3fa66a 65%, var(--ink)); background: color-mix(in srgb, #3fa66a 13%, var(--card)); }
|
||||||
.cmp-delta-down { color: color-mix(in srgb, #c0392b 60%, var(--ink)); background: color-mix(in srgb, #c0392b 12%, var(--card)); }
|
.cmp-delta-down { color: color-mix(in srgb, #c0392b 60%, var(--ink)); background: color-mix(in srgb, #c0392b 12%, var(--card)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* The Siege war room. One rule carries the whole page: the health bar has a
|
||||||
|
width transition, so a poll that lands a lower pool *slides* the bar down
|
||||||
|
instead of snapping it. That is the difference between watching the town
|
||||||
|
chip a boss and reading a report about it, and it costs one line.
|
||||||
|
prefers-reduced-motion turns the slide off — the number is still correct,
|
||||||
|
it just arrives instantly.
|
||||||
|
|
||||||
|
The ember palette is deliberate and not the adventure purple: a siege is
|
||||||
|
the one thing on the site that should look like an emergency. It mixes a
|
||||||
|
fixed hue into --card/--ink like the map and compare chips do, so it lands
|
||||||
|
readable in all four phases without a dark: variant. */
|
||||||
|
.siege-track {
|
||||||
|
position: relative;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--ink) 12%, var(--card));
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.siege-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(90deg, #e0562f 0%, #c0392b 60%, #8f1f16 100%);
|
||||||
|
transition: width 1.4s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
}
|
||||||
|
.siege-fill-spent { background: linear-gradient(90deg, #6b7280 0%, #4b5563 100%); }
|
||||||
|
|
||||||
|
/* A living siege breathes. Slow and low-contrast on purpose — it should read
|
||||||
|
as "this is happening now", not as a thing demanding to be clicked. */
|
||||||
|
.siege-live .siege-fill { animation: siege-pulse 3.2s ease-in-out infinite; }
|
||||||
|
@keyframes siege-pulse {
|
||||||
|
0%, 100% { filter: brightness(1); }
|
||||||
|
50% { filter: brightness(1.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.siege-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||||
|
font-size: 11px; font-weight: 600; line-height: 1;
|
||||||
|
border-radius: 9999px; padding: 0.22rem 0.6rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.siege-chip-fought {
|
||||||
|
color: color-mix(in srgb, #3fa66a 65%, var(--ink));
|
||||||
|
background: color-mix(in srgb, #3fa66a 15%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, #3fa66a 36%, transparent);
|
||||||
|
}
|
||||||
|
.siege-chip-waiting {
|
||||||
|
color: var(--warn);
|
||||||
|
background: color-mix(in srgb, var(--warn) 14%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 34%, transparent);
|
||||||
|
}
|
||||||
|
.siege-chip-held {
|
||||||
|
color: color-mix(in srgb, #3fa66a 65%, var(--ink));
|
||||||
|
background: color-mix(in srgb, #3fa66a 15%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, #3fa66a 36%, transparent);
|
||||||
|
}
|
||||||
|
.siege-chip-fell {
|
||||||
|
color: color-mix(in srgb, #c0392b 60%, var(--ink));
|
||||||
|
background: color-mix(in srgb, #c0392b 15%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, #c0392b 36%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The past-siege bar: same track, smaller, and frozen at the pool the Siege
|
||||||
|
ended on. A won Siege ends at zero and draws as an empty track, which is
|
||||||
|
exactly the right picture. */
|
||||||
|
.siege-track-sm { height: 0.5rem; }
|
||||||
|
.siege-track-sm .siege-fill { transition: none; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.siege-fill { transition: none; }
|
||||||
|
.siege-live .siege-fill { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The expedition liveblog: a column of what happened, room by room, under the
|
||||||
|
map that says where. Deliberately plainer than the rest of the page — this
|
||||||
|
is a log, and forty decorated cards would be unreadable at the length a real
|
||||||
|
run reaches. The rail down the left is what makes it read as one journey
|
||||||
|
rather than as a list of unrelated lines. */
|
||||||
|
.runlog {
|
||||||
|
list-style: none; margin: 0; padding: 0 0 0 1.1rem;
|
||||||
|
border-left: 2px solid color-mix(in srgb, var(--ink) 12%, transparent);
|
||||||
|
display: flex; flex-direction: column; gap: 0.55rem;
|
||||||
|
max-height: 26rem; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.runlog-line {
|
||||||
|
display: grid; grid-template-columns: 1.4rem 1fr auto;
|
||||||
|
align-items: baseline; gap: 0.5rem;
|
||||||
|
font-size: 13px; line-height: 1.4;
|
||||||
|
color: color-mix(in srgb, var(--ink) 78%, transparent);
|
||||||
|
}
|
||||||
|
.runlog-emoji { font-size: 14px; }
|
||||||
|
.runlog-text { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.runlog-meta {
|
||||||
|
font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||||
|
color: color-mix(in srgb, var(--ink) 42%, transparent);
|
||||||
|
}
|
||||||
|
/* Two tints and no more. A log where every second line is coloured is a log
|
||||||
|
with no emphasis at all; these mark the beats a reader is scanning for —
|
||||||
|
what hurt, and what was worth having. */
|
||||||
|
.runlog-hurt .runlog-text { color: color-mix(in srgb, #c0392b 62%, var(--ink)); font-weight: 600; }
|
||||||
|
.runlog-good .runlog-text { color: color-mix(in srgb, #3fa66a 60%, var(--ink)); }
|
||||||
|
/* On the report the log is the page, not a panel inside one: it scrolls with
|
||||||
|
the document instead of trapping the whole run in a 26rem window that a
|
||||||
|
reader has to find the edge of before they can move through it. */
|
||||||
|
.runlog-full { max-height: none; overflow-y: visible; }
|
||||||
|
|
||||||
|
/* ── The realm: map, board, hall of firsts ──────────────────────────────
|
||||||
|
These are hand-written component classes, not generated utilities, and that
|
||||||
|
matters: a Tailwind class built from a template value gets purged out of the
|
||||||
|
stylesheet and fails SILENTLY. .standings-tier-{{.DeepestTier}} is exactly
|
||||||
|
that shape, so all six variants are spelled out below rather than composed.
|
||||||
|
If a seventh tier ever ships, it needs a line here.
|
||||||
|
|
||||||
|
The realm's accent is the adventure purple, unlike the Siege's ember — the
|
||||||
|
Siege is an emergency, the realm is the standing shape of the world. */
|
||||||
|
|
||||||
|
/* Tabs across the four standing pages. */
|
||||||
|
.realm-tab {
|
||||||
|
font-weight: 600;
|
||||||
|
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.realm-tab:hover { color: var(--ink); }
|
||||||
|
.realm-tab-on {
|
||||||
|
color: #6d4bd8;
|
||||||
|
border-bottom-color: #6d4bd8;
|
||||||
|
}
|
||||||
|
/* The one place in this block a raw purple is set as a foreground rather than
|
||||||
|
mixed into --ink, so the one place that needs the night override the
|
||||||
|
existing .text-theme-adventure rule already carries. Everything else here
|
||||||
|
goes through color-mix and lands readable in all four phases by itself. */
|
||||||
|
html[data-phase="night"] .realm-tab-on {
|
||||||
|
color: #baa9eb;
|
||||||
|
border-bottom-color: #baa9eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A zone card. The default is a place people go: readable, unremarkable. */
|
||||||
|
.realm-zone {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.realm-zone:hover { transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* A zone NOBODY has ever cleared. This is the one piece of styling on the page
|
||||||
|
that has to carry a fact on its own, without being read: a visitor scanning
|
||||||
|
the map should see where the realm ends before they read a word of it.
|
||||||
|
Desaturated, dashed, recessed — the opposite of the busy state below. */
|
||||||
|
.realm-zone-unbeaten {
|
||||||
|
background: color-mix(in srgb, var(--ink) 5%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, var(--ink) 18%, transparent);
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.realm-unbeaten-line {
|
||||||
|
color: color-mix(in srgb, var(--ink) 52%, transparent);
|
||||||
|
font-style: italic;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Somebody is in there right now. It wins over the unbeaten styling by source
|
||||||
|
order, deliberately: a party currently inside a place nobody has ever beaten
|
||||||
|
is the most interesting card on the page and should read as live, not dead. */
|
||||||
|
.realm-zone-busy {
|
||||||
|
border-color: color-mix(in srgb, #6d4bd8 45%, transparent);
|
||||||
|
border-style: solid;
|
||||||
|
background: color-mix(in srgb, #6d4bd8 5%, var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The postgame band, drawn apart from the rest of the map: it is gated content
|
||||||
|
and the page should look like it changes character there. */
|
||||||
|
.realm-band-postgame {
|
||||||
|
background: color-mix(in srgb, var(--ink) 6%, var(--card));
|
||||||
|
border: 2px dashed color-mix(in srgb, var(--ink) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The board's deepest-tier chip. Six literal classes on purpose — see the note
|
||||||
|
at the top of this block. The ramp runs cool-to-hot so a column of them
|
||||||
|
reads as a gradient of how far people have got. */
|
||||||
|
.standings-tier {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
font-size: 11px; font-weight: 700; line-height: 1;
|
||||||
|
border-radius: 9999px; padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.standings-tier-1 { color: color-mix(in srgb, #6b7280 70%, var(--ink)); background: color-mix(in srgb, #6b7280 12%, var(--card)); border-color: color-mix(in srgb, #6b7280 30%, transparent); }
|
||||||
|
.standings-tier-2 { color: color-mix(in srgb, #3fa66a 65%, var(--ink)); background: color-mix(in srgb, #3fa66a 12%, var(--card)); border-color: color-mix(in srgb, #3fa66a 30%, transparent); }
|
||||||
|
.standings-tier-3 { color: color-mix(in srgb, #2f7fd0 65%, var(--ink)); background: color-mix(in srgb, #2f7fd0 12%, var(--card)); border-color: color-mix(in srgb, #2f7fd0 30%, transparent); }
|
||||||
|
.standings-tier-4 { color: color-mix(in srgb, #8b5cf6 65%, var(--ink)); background: color-mix(in srgb, #8b5cf6 12%, var(--card)); border-color: color-mix(in srgb, #8b5cf6 32%, transparent); }
|
||||||
|
.standings-tier-5 { color: color-mix(in srgb, #e0562f 65%, var(--ink)); background: color-mix(in srgb, #e0562f 13%, var(--card)); border-color: color-mix(in srgb, #e0562f 34%, transparent); }
|
||||||
|
.standings-tier-6 { color: color-mix(in srgb, #c9a227 72%, var(--ink)); background: color-mix(in srgb, #c9a227 15%, var(--card)); border-color: color-mix(in srgb, #c9a227 40%, transparent); font-weight: 800; }
|
||||||
|
|
||||||
|
/* A realm-first count. Gold, because it is the one number on the board that
|
||||||
|
can never go up for anybody else once it has been claimed. */
|
||||||
|
.standings-firsts {
|
||||||
|
color: color-mix(in srgb, #c9a227 72%, var(--ink));
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hall of firsts: a ruled ledger with a spine, so a long list reads as a
|
||||||
|
record rather than as a feed. */
|
||||||
|
.firsts-ledger {
|
||||||
|
border-left: 2px solid color-mix(in srgb, var(--ink) 12%, transparent);
|
||||||
|
padding-left: 1.15rem;
|
||||||
|
}
|
||||||
|
.firsts-entry {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--ink) 7%, transparent);
|
||||||
|
}
|
||||||
|
.firsts-entry:last-child { border-bottom: 0; }
|
||||||
|
.firsts-entry::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1.45rem; top: 1.15rem;
|
||||||
|
width: 0.5rem; height: 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--first-dot, color-mix(in srgb, var(--ink) 25%, var(--card)));
|
||||||
|
}
|
||||||
|
/* The per-kind dot colour goes through a custom property rather than through
|
||||||
|
a `.firsts-entry-zone::before` rule, and that is a purge fix, not a style
|
||||||
|
preference. tailwind.config.js has input.css itself in its content glob, so
|
||||||
|
a hand-written component class survives the purge only when the extractor
|
||||||
|
can lift its literal name out of this file — and a class name glued to
|
||||||
|
`::before` does not extract. It fails SILENTLY, which is how it got noticed
|
||||||
|
here only because the rule was grepped for afterwards. Any new
|
||||||
|
`.realm-*`/`.firsts-*` variant wants a plain-selector declaration for the
|
||||||
|
same reason. */
|
||||||
|
.firsts-entry-zone { --first-dot: #6d4bd8; }
|
||||||
|
.firsts-entry-treasure { --first-dot: #c9a227; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.realm-zone:hover { transform: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* W7 small surfaces: the pet XP bar and the party roster, both on the
|
||||||
|
adventurer page. Same purge discipline as the block above — every class here
|
||||||
|
is declared on a plain selector so Tailwind's extractor can lift its literal
|
||||||
|
name out of this file. Nothing here is keyed to a pseudo-element. */
|
||||||
|
|
||||||
|
/* Pet levelling. It has been happening since the XP wiring was fixed and no
|
||||||
|
surface has ever shown it. Deliberately a thin rail rather than a health-bar
|
||||||
|
lookalike: a pet's progress is a nice thing to notice, not a stat to watch. */
|
||||||
|
.pet-xp-track {
|
||||||
|
height: 0.3rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: color-mix(in srgb, var(--ink) 10%, var(--card));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pet-xp-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: color-mix(in srgb, #3fa66a 70%, var(--ink));
|
||||||
|
}
|
||||||
|
/* At the cap there is nothing left to fill, and an empty track would read as
|
||||||
|
the opposite. Fill it whole and let the label say why. */
|
||||||
|
.pet-xp-capped {
|
||||||
|
background: color-mix(in srgb, #c9a227 72%, var(--ink));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The party roster. One row per seat, and the three kinds have to be tellable
|
||||||
|
apart at a glance because they mean different things about the run: the
|
||||||
|
leader owns the clock, a member is another player, the companion is hired. */
|
||||||
|
.party-seat {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--ink) 7%, transparent);
|
||||||
|
}
|
||||||
|
.party-seat:last-child { border-bottom: 0; }
|
||||||
|
.party-seat-role {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--ink) 8%, var(--card));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.party-seat-leader {
|
||||||
|
color: color-mix(in srgb, #6d4bd8 70%, var(--ink));
|
||||||
|
background: color-mix(in srgb, #6d4bd8 12%, var(--card));
|
||||||
|
}
|
||||||
|
/* An opted-out player's seat. It stays on the roster because the party size is
|
||||||
|
load-bearing — the supply burn and the threat level printed on this same page
|
||||||
|
felt that body — but it carries no name and no link. Italic and recessed so
|
||||||
|
it reads as withheld rather than as missing data. */
|
||||||
|
.party-seat-anon {
|
||||||
|
font-style: italic;
|
||||||
|
color: color-mix(in srgb, var(--ink) 45%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* "While you were away" (adventure section, signed-in owner only). Rows are
|
||||||
|
quiet by default; the two kinds of line worth an eye — a realm first, a death
|
||||||
|
— carry a marker. Same plain-selector purge discipline as everything above. */
|
||||||
|
.away-line {
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.away-line:hover {
|
||||||
|
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||||
|
}
|
||||||
|
.away-line-notable {
|
||||||
|
border-left-color: color-mix(in srgb, #c9a227 60%, transparent);
|
||||||
|
background: color-mix(in srgb, #c9a227 6%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* The board row on /adventure and the channel page. It was four flex columns
|
||||||
|
that never collapsed, and at phone width it fell apart: "lv 14 human cleric"
|
||||||
|
wrapped onto three lines and the where-column onto three more, beside the
|
||||||
|
"send trouble" button. Pre-existing — a board pushed with no region and a
|
||||||
|
short zone name wrapped exactly the same way — so this is a layout fix, not
|
||||||
|
a regression fix for anything the adventure plan added.
|
||||||
|
|
||||||
|
Below sm the row is a small grid: the icon and the name on the top line with
|
||||||
|
the button pinned right, and the two descriptive columns stacked underneath
|
||||||
|
the name where they have the whole width to themselves. At sm and up it is
|
||||||
|
the single line it always was.
|
||||||
|
|
||||||
|
It lives here as component classes rather than as utilities in the markup
|
||||||
|
because the SAME row is built twice — server-side in channel.html and again
|
||||||
|
in that page's JS twin, which re-renders the list every poll. Two copies of a
|
||||||
|
utility soup drift; two copies of one class name cannot. */
|
||||||
|
.roster-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
row-gap: 0.1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
/* Fixed width, because the two glyphs are not the same size: the house is
|
||||||
|
wider than the crossed swords, so an auto column indented the stacked lines
|
||||||
|
under an idle adventurer further than those under a live one. Only visible
|
||||||
|
once the row stacks, which is why it took a phone-width shot to see. */
|
||||||
|
.roster-row-icon { grid-column: 1; grid-row: 1; width: 1.35rem; text-align: center; font-size: 1.125rem; line-height: 1.75rem; }
|
||||||
|
.roster-row-name { grid-column: 2; grid-row: 1; min-width: 0; }
|
||||||
|
.roster-row-act { grid-column: 3; grid-row: 1; }
|
||||||
|
.roster-row-meta { grid-column: 2; grid-row: 2; }
|
||||||
|
.roster-row-where { grid-column: 2; grid-row: 3; }
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.roster-row {
|
||||||
|
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||||
|
column-gap: 1rem;
|
||||||
|
}
|
||||||
|
.roster-row-meta { grid-column: 3; grid-row: 1; }
|
||||||
|
/* The where-column keeps the ml-auto behaviour it had as a flex child: it is
|
||||||
|
the only 1fr track, so it takes the slack, and the text sits at its end. */
|
||||||
|
.roster-row-where { grid-column: 4; grid-row: 1; text-align: right; }
|
||||||
|
.roster-row-act { grid-column: 5; grid-row: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,314 @@
|
|||||||
|
// The action queue, owner side — the first buttons on this site that play the
|
||||||
|
// game rather than read it.
|
||||||
|
//
|
||||||
|
// Same honesty rule as the equip queue: clicking records an intent, and the game
|
||||||
|
// box acts on its next poll. So nothing here ever says "done" on its own. It says
|
||||||
|
// "asked for", then shows whatever verdict gogobee filed, including a refusal.
|
||||||
|
//
|
||||||
|
// Shared by the adventurer page (pull out, go back in, set out, hire the sitter)
|
||||||
|
// and the war room (take today's bout), which is why it lives in a file rather
|
||||||
|
// than inline in either.
|
||||||
|
//
|
||||||
|
// Three of the verbs spend euros, so those confirm the cost and the resulting
|
||||||
|
// balance first — the equip panel's rule, and for the same reason: a button that
|
||||||
|
// quietly moves money is a button people stop pressing.
|
||||||
|
(function () {
|
||||||
|
var panels = Array.prototype.slice.call(document.querySelectorAll('.adv-actions'));
|
||||||
|
if (!panels.length) return; // not an owner, or not a page with actions
|
||||||
|
|
||||||
|
var list = document.getElementById('adv-action-orders');
|
||||||
|
var box = document.getElementById('adv-action-orders-box');
|
||||||
|
|
||||||
|
// How each terminal status reads. gogobee's own detail line is preferred when
|
||||||
|
// it sent one — it names the zone, the day, the damage — and these are the
|
||||||
|
// fallback for a verdict that arrived without prose.
|
||||||
|
var STATUS = {
|
||||||
|
pending: 'asked for…',
|
||||||
|
applied: 'done',
|
||||||
|
rejected_not_running: "couldn't, you weren't on an expedition",
|
||||||
|
rejected_not_leader: "couldn't, only the party leader can call it",
|
||||||
|
rejected_no_siege: "couldn't, no Siege is camped outside town",
|
||||||
|
rejected_already_fought: "couldn't, today's bout is already spent",
|
||||||
|
rejected_unavailable: "couldn't right now",
|
||||||
|
rejected_busy: "couldn't, you're already out there",
|
||||||
|
rejected_insufficient_funds: "couldn't cover it",
|
||||||
|
rejected_zone_locked: "couldn't, that zone isn't open to you",
|
||||||
|
rejected_nothing_to_resume: "couldn't, there's nothing waiting for you",
|
||||||
|
rejected_is_leader: "couldn't, you're the one leading it",
|
||||||
|
rejected_nothing_to_cancel: "couldn't, no sitter is engaged"
|
||||||
|
};
|
||||||
|
|
||||||
|
// syncOffers keeps the panel's own copy from outliving the truth. Watching it
|
||||||
|
// run for real is what put this here: after a bout landed, the page went on
|
||||||
|
// saying "your bout is unspent" above a dead button, under a verdict that said
|
||||||
|
// the fight was over.
|
||||||
|
//
|
||||||
|
// Applied hides the offer, because the thing on offer has happened. A REFUSAL
|
||||||
|
// puts the button back, and that asymmetry is the point: a refusal is often
|
||||||
|
// about a stale page, and taking away the retry would leave them nothing to do
|
||||||
|
// about it.
|
||||||
|
// INVALIDATES is what an applied verb makes untrue about the OTHER verbs on
|
||||||
|
// the page. Hiding only the offer that was taken is not enough, and watching it
|
||||||
|
// run is what showed why: after "Call the whole thing off" landed, the panel
|
||||||
|
// went on offering "Pull out of the run" — directly under a verdict saying the
|
||||||
|
// expedition had been abandoned. That is the same lie W5a fixed for the bout,
|
||||||
|
// in a new place.
|
||||||
|
//
|
||||||
|
// The one asymmetry worth keeping: an applied EXTRACT does not hide the
|
||||||
|
// abandon. An extracted run is still the owner's to close — that is exactly
|
||||||
|
// what the abandon verb is for from town — so taking the button away there
|
||||||
|
// would remove the next thing they might legitimately want.
|
||||||
|
var INVALIDATES = {
|
||||||
|
expedition_abandon: ['extract', 'expedition_leave'],
|
||||||
|
expedition_leave: ['extract', 'expedition_abandon'],
|
||||||
|
extract: ['expedition_leave']
|
||||||
|
};
|
||||||
|
|
||||||
|
function hideOffer(action) {
|
||||||
|
var btn = document.querySelector('.adv-action-btn[data-action="' + action + '"]');
|
||||||
|
if (!btn) return;
|
||||||
|
(btn.closest('[data-offer]') || btn).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOffers(orders) {
|
||||||
|
var newest = {};
|
||||||
|
orders.forEach(function (o) { if (!(o.action in newest)) newest[o.action] = o; });
|
||||||
|
Object.keys(newest).forEach(function (action) {
|
||||||
|
var o = newest[action];
|
||||||
|
if (o.status === 'pending') return; // still out; leave the button disabled
|
||||||
|
var btn = document.querySelector('.adv-action-btn[data-action="' + action + '"]');
|
||||||
|
if (!btn) return;
|
||||||
|
var offer = btn.closest('[data-offer]') || btn;
|
||||||
|
if (o.status === 'applied') {
|
||||||
|
offer.classList.add('hidden');
|
||||||
|
(INVALIDATES[action] || []).forEach(hideOffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// All three halves of the restore matter, and each is easy to forget.
|
||||||
|
// Un-hiding the wrapper: re-enabling a button inside a wrapper an earlier
|
||||||
|
// pass hid gives back a control nobody can see. Restoring the LABEL: the
|
||||||
|
// clicked button still says "asked for". And doing it to every button in
|
||||||
|
// the offer, not just the one whose action matched — placeOrder disables
|
||||||
|
// the whole group (three loadouts, or the sitter's two durations), so
|
||||||
|
// restoring one would leave the rest greyed out for good.
|
||||||
|
offer.classList.remove('hidden');
|
||||||
|
var group = offer.querySelectorAll('.adv-action-btn[data-action="' + action + '"]');
|
||||||
|
Array.prototype.forEach.call(group, function (b) {
|
||||||
|
b.disabled = false;
|
||||||
|
b.classList.remove('opacity-50');
|
||||||
|
b.textContent = b.getAttribute('data-label') || b.textContent;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var VERB = {
|
||||||
|
extract: 'Pull out',
|
||||||
|
siege_join: 'Join the defence',
|
||||||
|
expedition_start: 'Set out',
|
||||||
|
expedition_resume: 'Go back in',
|
||||||
|
babysit: 'Hire the sitter',
|
||||||
|
expedition_abandon: 'Call it off',
|
||||||
|
expedition_leave: 'Turn back',
|
||||||
|
babysit_cancel: 'Send the sitter home'
|
||||||
|
};
|
||||||
|
|
||||||
|
// The owner's euro balance as of the render, for the money confirms. Absent on
|
||||||
|
// the war room, whose one verb is free — euroFmt(NaN) never runs there because
|
||||||
|
// no button on that page carries a data-cost.
|
||||||
|
var panelBalance = (function () {
|
||||||
|
var el = document.querySelector('.adv-actions[data-balance]');
|
||||||
|
return el ? parseFloat(el.getAttribute('data-balance') || '0') : 0;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function euroFmt(n) {
|
||||||
|
return (Math.round(n * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zone picker shows one loadout row at a time: prices are per tier, so each
|
||||||
|
// zone gets its own server-rendered row and this only swaps which is visible.
|
||||||
|
// Pete never reprices anything in the browser.
|
||||||
|
(function initZonePicker() {
|
||||||
|
var pick = document.getElementById('adv-zone-pick');
|
||||||
|
if (!pick) return;
|
||||||
|
var groups = Array.prototype.slice.call(document.querySelectorAll('.adv-zone-loadouts'));
|
||||||
|
function show() {
|
||||||
|
groups.forEach(function (g) {
|
||||||
|
g.classList.toggle('hidden', g.getAttribute('data-zone') !== pick.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pick.addEventListener('change', show);
|
||||||
|
show();
|
||||||
|
})();
|
||||||
|
|
||||||
|
var pollTimer = null;
|
||||||
|
|
||||||
|
function render(orders) {
|
||||||
|
if (!list || !box) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
|
||||||
|
box.classList.remove('hidden');
|
||||||
|
var anyPending = false;
|
||||||
|
orders.forEach(function (o) {
|
||||||
|
if (o.status === 'pending') anyPending = true;
|
||||||
|
// Stacked, not the equip strip's justify-between row. That layout is right
|
||||||
|
// for a two-word verdict and wrong here: gogobee answers a bout with a
|
||||||
|
// whole sentence of damage numbers, which squeezed into a right-hand column
|
||||||
|
// and pushed the verb itself onto two lines.
|
||||||
|
var li = document.createElement('li');
|
||||||
|
var verb = document.createElement('div');
|
||||||
|
verb.className = 'font-semibold text-[color:var(--ink)]/70';
|
||||||
|
verb.textContent = VERB[o.action] || o.action;
|
||||||
|
var said = document.createElement('div');
|
||||||
|
said.className = 'mt-0.5 leading-snug ' + (o.status === 'pending'
|
||||||
|
? 'text-[color:var(--ink)]/45'
|
||||||
|
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
|
||||||
|
said.textContent = o.detail || STATUS[o.status] || o.status;
|
||||||
|
li.appendChild(verb); li.appendChild(said);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
syncOffers(orders);
|
||||||
|
// Keep refreshing while anything is unanswered so the verdict lands without a
|
||||||
|
// reload; stop once everything is terminal. A Siege bout runs a whole combat
|
||||||
|
// on the game box, so this can legitimately sit on "asked for" for a while.
|
||||||
|
if (anyPending && !pollTimer) {
|
||||||
|
pollTimer = setInterval(loadOrders, 10000);
|
||||||
|
} else if (!anyPending && pollTimer) {
|
||||||
|
clearInterval(pollTimer); pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrders() {
|
||||||
|
fetch('/api/adventure/orders', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (o) { if (o) render(o); })
|
||||||
|
.catch(function () { /* transient — a later tick will do */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// siblings are every button in the same offer — the three loadouts of a zone,
|
||||||
|
// the sitter's week and month. All of them are disabled together, because the
|
||||||
|
// game allows one outstanding order per verb and a second click would be
|
||||||
|
// refused with "already asked", which reads as a broken button rather than as
|
||||||
|
// the guard it is.
|
||||||
|
function siblings(btn) {
|
||||||
|
var offer = btn.closest('[data-offer]');
|
||||||
|
if (!offer) return [btn];
|
||||||
|
return Array.prototype.slice.call(offer.querySelectorAll('.adv-action-btn'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeOrder(btn) {
|
||||||
|
var group = siblings(btn);
|
||||||
|
group.forEach(function (b) { b.disabled = true; b.classList.add('opacity-50'); });
|
||||||
|
var was = btn.textContent;
|
||||||
|
btn.textContent = 'asking…';
|
||||||
|
var body = { action: btn.getAttribute('data-action') };
|
||||||
|
// Only the verbs that take arguments send any. An attribute that is not on
|
||||||
|
// the button is simply absent from the body, which is what extract and
|
||||||
|
// siege_join mean.
|
||||||
|
if (btn.hasAttribute('data-zone')) body.zone = btn.getAttribute('data-zone');
|
||||||
|
if (btn.hasAttribute('data-loadout')) body.loadout = btn.getAttribute('data-loadout');
|
||||||
|
if (btn.hasAttribute('data-days')) body.days = parseInt(btn.getAttribute('data-days'), 10);
|
||||||
|
fetch('/api/adventure/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
group.forEach(function (b) { b.disabled = false; b.classList.remove('opacity-50'); });
|
||||||
|
btn.textContent = (res.body && res.body.error) || 'try again';
|
||||||
|
setTimeout(function () { btn.textContent = was; }, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.textContent = 'asked for';
|
||||||
|
loadOrders();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
group.forEach(function (b) { b.disabled = false; b.classList.remove('opacity-50'); });
|
||||||
|
btn.textContent = 'try again';
|
||||||
|
setTimeout(function () { btn.textContent = was; }, 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every verb confirms. The two free ones are one-way (an extraction ends the
|
||||||
|
// run for the whole party; a bout is the only one you get today) and the three
|
||||||
|
// W5b ones spend euros, so those also print the cost and the balance it leaves
|
||||||
|
// — the equip panel's money gate, lifted.
|
||||||
|
//
|
||||||
|
// Built in the DOM rather than with confirm(), which would block the event loop
|
||||||
|
// and, on this site's own evidence, wedge an automated browser.
|
||||||
|
function askConfirm(btn) {
|
||||||
|
// Anchor the confirm to the OFFER, not to the panel. With one offer on the
|
||||||
|
// page those were the same element; with four they are not, and appending to
|
||||||
|
// the panel put the "set out?" box at the bottom of the section, under the
|
||||||
|
// sitter, detached from the button that raised it.
|
||||||
|
var panel = btn.closest('[data-offer]') || btn.closest('.adv-actions') || btn.parentElement;
|
||||||
|
var existing = document.querySelector('.adv-action-confirm');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
var boxEl = document.createElement('div');
|
||||||
|
boxEl.className = 'adv-action-confirm mt-3 rounded-xl bg-[color:var(--ink)]/5 p-3 text-sm';
|
||||||
|
var p = document.createElement('p');
|
||||||
|
p.className = 'text-[color:var(--ink)]/70';
|
||||||
|
p.textContent = btn.getAttribute('data-confirm') || 'Are you sure?';
|
||||||
|
var cost = parseFloat(btn.getAttribute('data-cost') || '0');
|
||||||
|
if (cost > 0) {
|
||||||
|
var money = document.createElement('p');
|
||||||
|
money.className = 'mt-1.5 font-semibold text-[color:var(--ink)]/80';
|
||||||
|
if (panelBalance - cost < 0) {
|
||||||
|
// No arrow when it does not cover. The game can carry a small debt, so
|
||||||
|
// the resulting figure is not always nonsense — but printing "€-6,300"
|
||||||
|
// next to "that won't cover it" is one number too many, and the minus
|
||||||
|
// lands on the wrong side of the sign.
|
||||||
|
money.textContent = '€' + euroFmt(cost) + " — you have €" + euroFmt(panelBalance) +
|
||||||
|
". That won't cover it.";
|
||||||
|
money.className += ' text-[color:var(--warn)]';
|
||||||
|
} else {
|
||||||
|
money.textContent = '€' + euroFmt(cost) + ' — balance €' + euroFmt(panelBalance) +
|
||||||
|
' → €' + euroFmt(panelBalance - cost) + '.';
|
||||||
|
}
|
||||||
|
boxEl.appendChild(p);
|
||||||
|
boxEl.appendChild(money);
|
||||||
|
p = null; // already placed; the tail below appends whatever is left
|
||||||
|
}
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'mt-2 flex gap-1.5';
|
||||||
|
var yes = document.createElement('button');
|
||||||
|
yes.type = 'button';
|
||||||
|
// A destructive verb gets the red the mischief storefront already uses for
|
||||||
|
// "this is the one that does something to somebody". "Call the whole thing
|
||||||
|
// off" throws away a whole party's day and was raising a confirm identical to
|
||||||
|
// the one for hiring a pet sitter — the two most different decisions on the
|
||||||
|
// page, in the same purple.
|
||||||
|
//
|
||||||
|
// Red rather than the button's own --warn, and that is not a style
|
||||||
|
// preference: --warn is a dark amber in every light theme and a LIGHT amber
|
||||||
|
// in the dark one (it is the only dark card), so white on it is unreadable in
|
||||||
|
// exactly the theme this was first tried in. Seen, not reasoned about.
|
||||||
|
yes.className = btn.getAttribute('data-confirm-tone') === 'warn'
|
||||||
|
? 'rounded-full bg-red-500 text-white px-3 py-1 font-semibold'
|
||||||
|
: 'rounded-full bg-theme-adventure text-white px-3 py-1 font-semibold';
|
||||||
|
yes.textContent = (btn.getAttribute('data-confirm-label') || 'Yes, do it') +
|
||||||
|
(cost > 0 ? ' · €' + euroFmt(cost) : '');
|
||||||
|
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
|
||||||
|
var no = document.createElement('button');
|
||||||
|
no.type = 'button';
|
||||||
|
no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-3 py-1';
|
||||||
|
no.textContent = 'Not yet';
|
||||||
|
no.addEventListener('click', function () { boxEl.remove(); });
|
||||||
|
row.appendChild(yes); row.appendChild(no);
|
||||||
|
if (p) boxEl.appendChild(p);
|
||||||
|
boxEl.appendChild(row);
|
||||||
|
panel.appendChild(boxEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
panels.forEach(function (panel) {
|
||||||
|
panel.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.adv-action-btn');
|
||||||
|
if (!btn || btn.disabled) return;
|
||||||
|
askConfirm(btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadOrders();
|
||||||
|
})();
|
||||||
@@ -10,7 +10,13 @@
|
|||||||
(function () {
|
(function () {
|
||||||
// The localStorage keys we sync. The weather *cache* is deliberately excluded:
|
// The localStorage keys we sync. The weather *cache* is deliberately excluded:
|
||||||
// it's transient and per-device.
|
// it's transient and per-device.
|
||||||
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off"];
|
// pete.advPush.v1 is read by the *server* — the adventure alert sender parses
|
||||||
|
// it out of the stored blob to decide who to notify — where every other key
|
||||||
|
// here is only ever read back by a feature script. If it stops syncing, the
|
||||||
|
// toggles keep working locally and no alert is ever sent, which is the kind of
|
||||||
|
// failure nobody reports.
|
||||||
|
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off",
|
||||||
|
"pete.advPush.v1"];
|
||||||
|
|
||||||
var user = window.PETE_USER || null;
|
var user = window.PETE_USER || null;
|
||||||
var serverPrefs = window.PETE_PREFS || null;
|
var serverPrefs = window.PETE_PREFS || null;
|
||||||
|
|||||||
@@ -50,6 +50,33 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A subscription stored before the server learned to record the Matrix handle
|
||||||
|
// can never match an owner-scoped adventure alert, and nothing re-subscribes on
|
||||||
|
// its own — subscribe() only runs on a click. So an existing subscription gets
|
||||||
|
// its handle topped up once, silently, from the page it is already on.
|
||||||
|
//
|
||||||
|
// Once per endpoint, not once per load: the marker is the endpoint itself, so a
|
||||||
|
// rotated subscription heals again and a browser that has already done it never
|
||||||
|
// asks twice. The server's update is a no-op on an already-healed row, so a lost
|
||||||
|
// marker costs one wasted request and nothing else.
|
||||||
|
var HEAL_KEY = "pete.pushHeal.v1";
|
||||||
|
|
||||||
|
function healLocalpart(sub) {
|
||||||
|
if (!sub || !sub.endpoint) return;
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(HEAL_KEY) === sub.endpoint) return;
|
||||||
|
} catch (e) { /* private mode: heal every load rather than never */ }
|
||||||
|
fetch("/api/push/heal", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) return;
|
||||||
|
try { localStorage.setItem(HEAL_KEY, sub.endpoint); } catch (e) {}
|
||||||
|
}).catch(function () { /* transient — the next load will do */ });
|
||||||
|
}
|
||||||
|
|
||||||
function unsubscribe() {
|
function unsubscribe() {
|
||||||
return currentSub().then(function (sub) {
|
return currentSub().then(function (sub) {
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
@@ -65,6 +92,68 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- adventure alert categories -------------------------------------------
|
||||||
|
// Stored as a JSON string under a synced prefs key, because this is the one
|
||||||
|
// preference the *server* reads back: the alert sender parses the same blob to
|
||||||
|
// decide who to notify. Absent key means nothing enabled, on both sides.
|
||||||
|
var ADV_KEY = "pete.advPush.v1";
|
||||||
|
|
||||||
|
function advRead() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(ADV_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
var v = JSON.parse(raw);
|
||||||
|
return v && typeof v === "object" ? v : {};
|
||||||
|
} catch (e) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function advWrite(set) {
|
||||||
|
try { localStorage.setItem(ADV_KEY, JSON.stringify(set)); } catch (e) {}
|
||||||
|
if (window.PetePrefs) window.PetePrefs.push();
|
||||||
|
}
|
||||||
|
|
||||||
|
// initAdvUI wires the category boxes. `on` is whether a push subscription
|
||||||
|
// currently exists — a category switch with no subscription behind it is wired
|
||||||
|
// to nothing, so the block stays hidden until there is one.
|
||||||
|
function initAdvUI(slot, on) {
|
||||||
|
var box = slot.querySelector("[data-adv-push]");
|
||||||
|
if (!box) return;
|
||||||
|
box.hidden = !on;
|
||||||
|
if (!on) return;
|
||||||
|
|
||||||
|
var note = box.querySelector("[data-adv-push-note]");
|
||||||
|
var inputs = box.querySelectorAll("[data-adv-cat]");
|
||||||
|
var set = advRead();
|
||||||
|
|
||||||
|
function paintNote() {
|
||||||
|
if (!note) return;
|
||||||
|
var n = 0;
|
||||||
|
for (var i = 0; i < inputs.length; i++) if (inputs[i].checked) n++;
|
||||||
|
note.textContent = n === 0
|
||||||
|
? "Nothing selected. You'll only get the news digest."
|
||||||
|
: (n === 1 ? "1 alert type on." : n + " alert types on.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < inputs.length; i++) {
|
||||||
|
(function (el) {
|
||||||
|
el.checked = !!set[el.getAttribute("data-adv-cat")];
|
||||||
|
// This function re-runs every time the subscription state changes; bind
|
||||||
|
// the listener once or a toggle-off-toggle-on writes the pref twice.
|
||||||
|
if (!el.dataset.advBound) {
|
||||||
|
el.dataset.advBound = "1";
|
||||||
|
el.addEventListener("change", function () {
|
||||||
|
var cur = advRead();
|
||||||
|
var key = el.getAttribute("data-adv-cat");
|
||||||
|
if (el.checked) cur[key] = true; else delete cur[key];
|
||||||
|
advWrite(cur);
|
||||||
|
paintNote();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(inputs[i]);
|
||||||
|
}
|
||||||
|
paintNote();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- settings-panel toggle ------------------------------------------------
|
// ---- settings-panel toggle ------------------------------------------------
|
||||||
function initPushUI() {
|
function initPushUI() {
|
||||||
var slot = document.querySelector("[data-push-section]");
|
var slot = document.querySelector("[data-push-section]");
|
||||||
@@ -80,6 +169,7 @@
|
|||||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
btn.textContent = on ? "Notifications on" : "Turn on notifications";
|
btn.textContent = on ? "Notifications on" : "Turn on notifications";
|
||||||
if (note && text != null) note.textContent = text;
|
if (note && text != null) note.textContent = text;
|
||||||
|
initAdvUI(slot, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -90,6 +180,7 @@
|
|||||||
}
|
}
|
||||||
currentSub().then(function (sub) {
|
currentSub().then(function (sub) {
|
||||||
paint(!!sub, sub ? "You'll get a nudge when new stories land." : "Get a nudge when new stories land.");
|
paint(!!sub, sub ? "You'll get a nudge when new stories land." : "Get a nudge when new stories land.");
|
||||||
|
healLocalpart(sub);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -603,6 +603,7 @@
|
|||||||
// something else. Which means you never have to put a card down before choosing
|
// something else. Which means you never have to put a card down before choosing
|
||||||
// a different one.
|
// a different one.
|
||||||
root.querySelector(".pete-felt").addEventListener("click", function (e) {
|
root.querySelector(".pete-felt").addEventListener("click", function (e) {
|
||||||
|
if (swallowClick) { swallowClick = false; return; } // that was the end of a drag
|
||||||
if (busy || !board || board.phase !== "playing") return;
|
if (busy || !board || board.phase !== "playing") return;
|
||||||
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
|
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
|
||||||
|
|
||||||
@@ -633,6 +634,143 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- dragging --------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Tapping is the friendly way to play and it stays exactly as it was. Dragging
|
||||||
|
// is the *other* way, and some hands want it: picking a run up and putting it
|
||||||
|
// down is one gesture rather than two, and it's the one every physical deck of
|
||||||
|
// cards has taught. The two share everything below the surface — a drag picks a
|
||||||
|
// run up with the same pick() and asks the same accepts() — so what lights up
|
||||||
|
// and what a move means never depends on how you did it.
|
||||||
|
//
|
||||||
|
// Nothing happens until the pointer has actually moved. Under the threshold this
|
||||||
|
// is a tap and the click handler above gets it untouched; over it, the click that
|
||||||
|
// browsers fire after a drag is swallowed so a drag never also counts as a tap.
|
||||||
|
var SLOP = 6; // px of travel before a press becomes a drag
|
||||||
|
|
||||||
|
var drag = null; // {pile, idx, x0, y0, dx, dy, ghost, live}
|
||||||
|
var swallowClick = false;
|
||||||
|
|
||||||
|
// ghost is the run in your hand, drawn once and moved with the pointer. It's a
|
||||||
|
// copy: the real cards stay on the felt, faded, so you can see where you lifted
|
||||||
|
// from and where you'd be putting it back.
|
||||||
|
function ghostFor(cards, from) {
|
||||||
|
var felt = getComputedStyle(root.querySelector(".pete-felt"));
|
||||||
|
var g = document.createElement("div");
|
||||||
|
g.className = "pete-drag";
|
||||||
|
["--card-w", "--card-h", "--fan-up", "--fan-down"].forEach(function (v) {
|
||||||
|
g.style.setProperty(v, felt.getPropertyValue(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
var col = document.createElement("div");
|
||||||
|
col.className = "pete-col";
|
||||||
|
cards.forEach(function (c) {
|
||||||
|
var el = CARDS.el(c, { deal: false, tilt: false });
|
||||||
|
col.appendChild(el);
|
||||||
|
});
|
||||||
|
g.appendChild(col);
|
||||||
|
g.style.width = from.width + "px";
|
||||||
|
document.body.appendChild(g);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragTo(x, y) {
|
||||||
|
drag.ghost.style.transform =
|
||||||
|
"translate3d(" + (x - drag.dx) + "px," + (y - drag.dy) + "px,0)";
|
||||||
|
|
||||||
|
// What's under the pointer, and would it take this? The ghost doesn't answer
|
||||||
|
// to hit tests, so this sees the felt underneath it.
|
||||||
|
var under = document.elementFromPoint(x, y);
|
||||||
|
var pileEl = under && under.closest ? under.closest("[data-pile]") : null;
|
||||||
|
var pile = pileEl ? pileEl.dataset.pile : null;
|
||||||
|
if (pileEl && pileEl.classList.contains("pete-card")) {
|
||||||
|
pileEl = pileEl.parentElement && pileEl.parentElement.closest("[data-pile]");
|
||||||
|
}
|
||||||
|
var ok = pile && pile !== drag.pile && accepts(pile, held.cards);
|
||||||
|
|
||||||
|
root.querySelectorAll('[data-over="1"]').forEach(function (el) { delete el.dataset.over; });
|
||||||
|
if (ok && pileEl) pileEl.dataset.over = "1";
|
||||||
|
drag.ghost.dataset.ok = ok ? "1" : "0";
|
||||||
|
drag.target = ok ? pile : null;
|
||||||
|
drag.targetEl = ok ? pileEl : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEnd(commit) {
|
||||||
|
if (!drag) return;
|
||||||
|
var d = drag;
|
||||||
|
drag = null;
|
||||||
|
root.querySelectorAll('[data-over="1"]').forEach(function (el) { delete el.dataset.over; });
|
||||||
|
delete root.dataset.dragging;
|
||||||
|
if (d.ghost) d.ghost.remove();
|
||||||
|
if (!d.live) return; // never crossed the threshold: it was a tap
|
||||||
|
|
||||||
|
swallowClick = true;
|
||||||
|
setTimeout(function () { swallowClick = false; }, 0); // in case no click follows
|
||||||
|
|
||||||
|
if (commit && d.target && held) {
|
||||||
|
var move = { kind: "move", from: d.pile, to: d.target, count: held.count };
|
||||||
|
drop();
|
||||||
|
send(move);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dropped on nothing, or somewhere it doesn't go. The run goes back where it
|
||||||
|
// came from and stays in your hand, so the drop targets are still lit and a
|
||||||
|
// tap can finish what the drag started.
|
||||||
|
if (commit && d.targetEl) nope(d.targetEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelector(".pete-felt").addEventListener("pointerdown", function (e) {
|
||||||
|
if (busy || !board || board.phase !== "playing") return;
|
||||||
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
|
var cardEl = e.target.closest('.pete-card[data-live="1"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
|
||||||
|
var r = cardEl.getBoundingClientRect();
|
||||||
|
drag = {
|
||||||
|
pile: cardEl.dataset.pile,
|
||||||
|
idx: parseInt(cardEl.dataset.idx, 10),
|
||||||
|
x0: e.clientX,
|
||||||
|
y0: e.clientY,
|
||||||
|
dx: e.clientX - r.left,
|
||||||
|
dy: e.clientY - r.top,
|
||||||
|
width: r.width,
|
||||||
|
live: false,
|
||||||
|
target: null,
|
||||||
|
targetEl: null,
|
||||||
|
id: e.pointerId,
|
||||||
|
el: cardEl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelector(".pete-felt").addEventListener("pointermove", function (e) {
|
||||||
|
if (!drag || e.pointerId !== drag.id) return;
|
||||||
|
|
||||||
|
if (!drag.live) {
|
||||||
|
if (Math.abs(e.clientX - drag.x0) < SLOP && Math.abs(e.clientY - drag.y0) < SLOP) return;
|
||||||
|
// It's a drag. Pick the run up — the same pick a tap would have made — and
|
||||||
|
// if it isn't liftable there's nothing to drag and this goes back to being
|
||||||
|
// an ordinary press.
|
||||||
|
pick(drag.pile, drag.idx);
|
||||||
|
if (!held || held.pile !== drag.pile) { drag = null; return; }
|
||||||
|
drag.live = true;
|
||||||
|
drag.ghost = ghostFor(held.cards, drag);
|
||||||
|
root.dataset.dragging = "1";
|
||||||
|
// Capture only now that it's a drag, so the pointer stays ours even off the
|
||||||
|
// felt. Doing it on the press instead would retarget the click that a *tap*
|
||||||
|
// ends with, and tapping would quietly stop working.
|
||||||
|
try { drag.el.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
dragTo(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelector(".pete-felt").addEventListener("pointerup", function (e) {
|
||||||
|
if (drag && e.pointerId === drag.id) dragEnd(true);
|
||||||
|
});
|
||||||
|
root.querySelector(".pete-felt").addEventListener("pointercancel", function (e) {
|
||||||
|
if (drag && e.pointerId === drag.id) dragEnd(false);
|
||||||
|
});
|
||||||
|
|
||||||
// Double-click sends a card home. It's the idiom every solitaire has used for
|
// Double-click sends a card home. It's the idiom every solitaire has used for
|
||||||
// thirty years, and the alternative is asking the player which foundation — a
|
// thirty years, and the alternative is asking the player which foundation — a
|
||||||
// question with exactly one right answer.
|
// question with exactly one right answer.
|
||||||
|
|||||||
@@ -390,6 +390,10 @@
|
|||||||
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
|
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
|
||||||
if (dealBtn) dealBtn.disabled = busy;
|
if (dealBtn) dealBtn.disabled = busy;
|
||||||
if (leaveBtn) leaveBtn.disabled = busy;
|
if (leaveBtn) leaveBtn.disabled = busy;
|
||||||
|
// With no table under you the sit panel is what's on screen, and Sit down is
|
||||||
|
// the button that has to come back out of busy. Getting up hands your stack
|
||||||
|
// back, so it is also affordable again the moment the request lands.
|
||||||
|
if (!game) syncSit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPhase(v) {
|
function setPhase(v) {
|
||||||
@@ -1045,13 +1049,19 @@
|
|||||||
|
|
||||||
pickRules("normal");
|
pickRules("normal");
|
||||||
|
|
||||||
|
// Every fresh view of the money re-syncs the sit panel, not just the first one.
|
||||||
|
// Buying chips from the chip bar happens on this page, and a Sit down button
|
||||||
|
// that was greyed out when you had nothing has to notice that you now have
|
||||||
|
// something — otherwise the only way to sit down is to reload.
|
||||||
|
G.onUpdate(function () { if (!game) syncSit(); });
|
||||||
|
|
||||||
var resumed = false;
|
var resumed = false;
|
||||||
G.onUpdate(function () {
|
G.onUpdate(function () {
|
||||||
if (resumed) return;
|
if (resumed) return;
|
||||||
resumed = true;
|
resumed = true;
|
||||||
G.refresh().then(function (v) {
|
G.refresh().then(function (v) {
|
||||||
if (v && v.uno) { paint(v.uno); seated(); }
|
if (v && v.uno) { paint(v.uno); seated(); }
|
||||||
else { paint(null); loadLobby(); syncSit(); }
|
else { paint(null); loadLobby(); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+29
-1
@@ -50,6 +50,16 @@ type statusPage struct {
|
|||||||
Sources []sourceStatus
|
Sources []sourceStatus
|
||||||
DegradedCnt int // sources currently failing
|
DegradedCnt int // sources currently failing
|
||||||
Admin bool // viewer is an admin: show the full diagnostic columns
|
Admin bool // viewer is an admin: show the full diagnostic columns
|
||||||
|
// Untemplated adventure event types seen since boot, busiest first. Admin-only:
|
||||||
|
// it names game internals, and it is a to-do list for Pete's vocabulary rather
|
||||||
|
// than anything a reader wants. Empty in the healthy case.
|
||||||
|
UnknownAdv []unknownAdvType
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownAdvType is one event type gogobee sent that Pete had no template for.
|
||||||
|
type unknownAdvType struct {
|
||||||
|
EventType string
|
||||||
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStatus renders the source-health page. It's public: everyone sees a
|
// handleStatus renders the source-health page. It's public: everyone sees a
|
||||||
@@ -126,7 +136,25 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return rows[i].Name < rows[j].Name
|
return rows[i].Name < rows[j].Name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Untemplated dispatch types, admin-only. These publish on the neutral
|
||||||
|
// fallback rather than being rejected (see handleAdventureIngest), so nothing
|
||||||
|
// is lost by not noticing — but a type sitting here with a rising count means
|
||||||
|
// the section is carrying thin cards Pete could be writing properly.
|
||||||
|
var unknownAdv []unknownAdvType
|
||||||
|
if admin {
|
||||||
|
for t, n := range AdvUnknownTypeCounts() {
|
||||||
|
unknownAdv = append(unknownAdv, unknownAdvType{EventType: t, Count: n})
|
||||||
|
}
|
||||||
|
sort.SliceStable(unknownAdv, func(i, j int) bool {
|
||||||
|
if unknownAdv[i].Count != unknownAdv[j].Count {
|
||||||
|
return unknownAdv[i].Count > unknownAdv[j].Count
|
||||||
|
}
|
||||||
|
return unknownAdv[i].EventType < unknownAdv[j].EventType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
base := s.base(r)
|
base := s.base(r)
|
||||||
base.Active = "status"
|
base.Active = "status"
|
||||||
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin})
|
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin,
|
||||||
|
UnknownAdv: unknownAdv})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
data-ch-title="{{$ch.Title}}" data-ch-emoji="{{$ch.Emoji}}" data-ch-theme="{{$ch.Theme}}"
|
data-ch-title="{{$ch.Title}}" data-ch-emoji="{{$ch.Emoji}}" data-ch-theme="{{$ch.Theme}}"
|
||||||
data-posted="{{if .Story.Posted}}1{{end}}"
|
data-posted="{{if .Story.Posted}}1{{end}}"
|
||||||
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
|
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
|
||||||
|
{{/* An adventure card borrows its event family's colour for the border, and
|
||||||
|
a realm-first gets a heavier ring on top of it. Inline style rather than
|
||||||
|
a class: the palette is a Go table of hex values, and a generated
|
||||||
|
Tailwind class would be purged out of the stylesheet and fail silently. */}}
|
||||||
|
{{if .Story.Accent}}style="border-color:{{.Story.Accent}}{{if .Story.Ceremony}};box-shadow:0 0 0 4px {{.Story.Accent}}33, 0 10px 30px -12px {{.Story.Accent}}{{end}}"{{end}}
|
||||||
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
|
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
|
||||||
<span role="button" tabindex="0" data-bookmark-btn data-story-id="{{.Story.ID}}"
|
<span role="button" tabindex="0" data-bookmark-btn data-story-id="{{.Story.ID}}"
|
||||||
aria-label="Bookmark this story" aria-pressed="false"
|
aria-label="Bookmark this story" aria-pressed="false"
|
||||||
@@ -36,6 +41,12 @@
|
|||||||
{{.Story.Source}}
|
{{.Story.Source}}
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Story.Ceremony}}
|
||||||
|
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 font-bold uppercase tracking-wider text-white"
|
||||||
|
style="background-color:{{.Story.Accent}}" title="First time this has ever happened in the realm">
|
||||||
|
<span aria-hidden="true" class="mr-1">★</span>Realm first
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
|
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
|
||||||
{{if .Story.ReadMins}}<span class="text-[color:var(--ink)]/45">· {{.Story.ReadMins}} min read</span>{{end}}
|
{{if .Story.ReadMins}}<span class="text-[color:var(--ink)]/45">· {{.Story.ReadMins}} min read</span>{{end}}
|
||||||
{{if .Story.Views}}<span class="ml-auto inline-flex items-center gap-1 text-[color:var(--ink)]/45 tabular-nums" title="{{.Story.Views}} reads">
|
{{if .Story.Views}}<span class="ml-auto inline-flex items-center gap-1 text-[color:var(--ink)]/45 tabular-nums" title="{{.Story.Views}} reads">
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{/* The realm nav, shared by the map, the board, the hall of firsts and the war
|
||||||
|
room, so the four read as one place rather than four orphans hanging off the
|
||||||
|
dispatch feed.
|
||||||
|
|
||||||
|
It lives in its own partial rather than in realm.html because each page gets
|
||||||
|
its own parsed template set (see server.go): a {{define}} in one page's file
|
||||||
|
is invisible to the others, and the only way to share a block is to list it
|
||||||
|
as a shared file. Passed the whole pageData, so it can mark the current tab
|
||||||
|
off .Path. */}}
|
||||||
|
{{define "realmnav"}}
|
||||||
|
<nav class="mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
<a href="/adventure" class="inline-flex items-center gap-1.5 font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||||
|
<span aria-hidden="true">←</span> All dispatches
|
||||||
|
</a>
|
||||||
|
<span class="text-[color:var(--ink)]/20" aria-hidden="true">·</span>
|
||||||
|
<a href="/adventure/realm" class="realm-tab{{if eq .Path "/adventure/realm"}} realm-tab-on{{end}}">The map</a>
|
||||||
|
<a href="/adventure/standings" class="realm-tab{{if eq .Path "/adventure/standings"}} realm-tab-on{{end}}">The board</a>
|
||||||
|
<a href="/adventure/firsts" class="realm-tab{{if eq .Path "/adventure/firsts"}} realm-tab-on{{end}}">Hall of firsts</a>
|
||||||
|
<a href="/adventure/siege" class="realm-tab{{if eq .Path "/adventure/siege"}} realm-tab-on{{end}}">The Siege</a>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
@@ -14,6 +14,62 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{if .ShowRoster}}
|
{{if .ShowRoster}}
|
||||||
|
{{/* While you were away. First thing on the page when it renders at all, and it
|
||||||
|
renders for exactly one reader: the signed-in owner of an adventurer something
|
||||||
|
has happened to since their last visit. Above the Siege because everything
|
||||||
|
below this line is the realm's news and this is the reader's own. */}}
|
||||||
|
{{if .Away.Has}}
|
||||||
|
<section class="mb-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 sm:p-6 shadow-pete">
|
||||||
|
<div class="flex items-baseline justify-between gap-3 flex-wrap">
|
||||||
|
<h2 class="font-display text-xl font-bold">While you were away</h2>
|
||||||
|
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45">{{.Away.Name}} · past {{.Away.Since}}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="mt-3 space-y-2">
|
||||||
|
{{range .Away.Lines}}
|
||||||
|
<li class="away-line{{if .Notable}} away-line-notable{{end}}">
|
||||||
|
<a href="{{.Permalink}}" class="flex items-baseline gap-2.5 group">
|
||||||
|
<span class="shrink-0" aria-hidden="true">{{.Emoji}}</span>
|
||||||
|
<span class="flex-1 text-sm">
|
||||||
|
<span class="font-semibold group-hover:text-theme-adventure group-hover:underline">{{.Label}}</span>
|
||||||
|
{{if .Line}}<span class="text-[color:var(--ink)]/60"> — {{.Line}}</span>{{end}}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/40 shrink-0 tabular-nums">{{.When}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{if .Away.Token}}
|
||||||
|
<a href="/adventure/who/{{.Away.Token}}" class="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-theme-adventure hover:opacity-80 transition">
|
||||||
|
{{if .Away.HasMore}}More, and the rest of the trail{{else}}Your adventurer{{end}} <span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* The Siege strip. Above the board on purpose: the board is where everyone is,
|
||||||
|
the Siege is where everyone should be. When one is camped this is a live bar
|
||||||
|
and a door into the war room; when none is, it stays as the quiet doorway to
|
||||||
|
the history, which is the other half of making the next one feel like it
|
||||||
|
counts. */}}
|
||||||
|
{{if .Siege.Active}}
|
||||||
|
<a href="/adventure/siege" class="block mb-6 rounded-3xl bg-theme-adventure text-white p-5 sm:p-6 shadow-pete relative overflow-hidden group {{if not .Siege.Stale}}siege-live{{end}}">
|
||||||
|
<div class="absolute -top-6 -right-4 text-[8rem] opacity-20 select-none" aria-hidden="true">🏰</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] opacity-80">🏰 The Siege · now</p>
|
||||||
|
<h2 class="font-display text-2xl font-bold mt-1 group-hover:underline">{{.Siege.BossName}} is at the gates</h2>
|
||||||
|
<div class="mt-3 siege-track">
|
||||||
|
<div class="siege-fill" style="width: {{.Siege.HPPercent}}%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm opacity-90 tabular-nums">{{.Siege.HPCurrent}} / {{.Siege.HPMax}} HP · {{.Siege.HPPercent}}% standing · {{len .Siege.Waiting}} bout{{if ne (len .Siege.Waiting) 1}}s{{end}} still going spare</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{else if .Siege.History}}
|
||||||
|
<a href="/adventure/siege" class="flex items-center gap-3 mb-6 rounded-2xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-3 shadow-pete hover:border-theme-adventure/40 transition">
|
||||||
|
<span class="text-lg" aria-hidden="true">🏰</span>
|
||||||
|
<span class="text-sm text-[color:var(--ink)]/70">Nothing camped outside town right now — <span class="font-semibold text-theme-adventure">the sieges we've fought</span></span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
|
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
|
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
|
||||||
@@ -25,15 +81,18 @@
|
|||||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||||||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||||||
{{range .Roster}}
|
{{range .Roster}}
|
||||||
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
{{/* The layout is .roster-row, not utilities: this markup has a twin in the
|
||||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
script below that re-renders the same list on every poll, and the two
|
||||||
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
have to agree at every width. See the .roster-row block in input.css. */}}
|
||||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
<li class="roster-row" data-token="{{.Token}}" data-name="{{.Name}}">
|
||||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
<span class="roster-row-icon" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||||
|
<a href="/adventure/who/{{.Token}}" class="roster-row-name font-semibold truncate hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||||||
|
<span class="roster-row-meta text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||||
|
<span class="roster-row-where text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
{{if and $.User .OnRun}}
|
{{if and $.User .OnRun}}
|
||||||
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
|
<button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -102,14 +161,16 @@
|
|||||||
|
|
||||||
function row(a) {
|
function row(a) {
|
||||||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||||||
|
// The server-rendered twin of this row is above, in the {{"{{range .Roster}}"}}
|
||||||
|
// block. Keep the class names identical: the layout is all in .roster-row.
|
||||||
var button = (signedIn && a.OnRun)
|
var button = (signedIn && a.OnRun)
|
||||||
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
|
? '<button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
|
||||||
: '';
|
: '';
|
||||||
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
return '<li class="roster-row" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
'<span class="roster-row-icon" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||||
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
|
'<a href="/adventure/who/' + esc(a.Token) + '" class="roster-row-name font-semibold truncate hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
|
||||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
'<span class="roster-row-meta text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
'<span class="roster-row-where text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||||
esc(a.Where) + idle + '</span>' + button + '</li>';
|
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{{define "title"}}Hall of firsts — {{.SiteTitle}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<article class="mt-2 mb-10 max-w-3xl mx-auto">
|
||||||
|
{{template "realmnav" .}}
|
||||||
|
|
||||||
|
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||||
|
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">📜</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] opacity-80">📜 Hall of firsts</p>
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Everything that has only ever happened once.</h1>
|
||||||
|
<p class="mt-3 opacity-90 max-w-2xl">
|
||||||
|
The first time anyone walked out of a place alive. The first time a thing
|
||||||
|
came out of the ground. Each of these happened exactly once in the history
|
||||||
|
of the realm and can't happen again.
|
||||||
|
</p>
|
||||||
|
{{if .Firsts.Total}}
|
||||||
|
<div class="mt-6 flex flex-wrap gap-x-8 gap-y-3 text-xs uppercase tracking-wider opacity-85">
|
||||||
|
<span>Entries · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Total}}</span></span>
|
||||||
|
<span>Places opened · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Zones}}</span></span>
|
||||||
|
{{if .Firsts.Others}}<span>Things found · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Others}}</span></span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if and .Firsts.Known .Firsts.Stale}}
|
||||||
|
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
|
||||||
|
Ledger as of {{.Firsts.LastSeenAgo}}. Nothing in a history book goes stale exactly, but a new entry might not be here yet.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .Firsts.Years}}
|
||||||
|
{{range .Firsts.Years}}
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="font-display text-2xl font-bold mb-4 tabular-nums">
|
||||||
|
{{if .Year}}{{.Year}}{{else}}Before the records{{end}}
|
||||||
|
</h2>
|
||||||
|
<ol class="firsts-ledger">
|
||||||
|
{{range .Firsts}}
|
||||||
|
<li class="firsts-entry firsts-entry-{{.Kind}}">
|
||||||
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 font-semibold">{{.Label}}</span>
|
||||||
|
<span class="font-display font-bold text-base">{{.Display}}</span>
|
||||||
|
{{if .Tier}}<span class="text-[11px] text-[color:var(--ink)]/35 tabular-nums">T{{.Tier}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-sm text-[color:var(--ink)]/60">
|
||||||
|
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold text-[color:var(--ink)]/80 hover:text-theme-adventure transition">{{.Holder}}</a>
|
||||||
|
{{else if .Holder}}<span class="font-semibold text-[color:var(--ink)]/80">{{.Holder}}</span>
|
||||||
|
{{else}}<span class="italic text-[color:var(--ink)]/40">nobody left who'll admit to it</span>{{end}}
|
||||||
|
{{if .When}}<span class="text-[color:var(--ink)]/35"> · {{.When}}</span>{{end}}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center max-w-xl mx-auto">
|
||||||
|
An entry with no name is one where the record has outlived the record-holder —
|
||||||
|
a thing found and long since given away, or somebody who'd rather I didn't say.
|
||||||
|
The claim still stands.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">
|
||||||
|
{{if .Firsts.Known}}
|
||||||
|
Nothing's happened for the first time yet. Everything is about to be a first.
|
||||||
|
{{else}}
|
||||||
|
Haven't had the ledger through from the field yet.
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
@@ -201,6 +201,35 @@
|
|||||||
Turn on notifications
|
Turn on notifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .AdvEnabled}}
|
||||||
|
<!-- Adventure alerts. Revealed by pwa.js only once a subscription exists,
|
||||||
|
because a category toggle with no subscription behind it is a switch
|
||||||
|
wired to nothing. Every box starts unchecked: turning on news
|
||||||
|
notifications is not consent to be told about the game. -->
|
||||||
|
<div data-adv-push hidden class="mt-2 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
|
||||||
|
<div class="text-sm font-bold">⚔️ Adventure alerts</div>
|
||||||
|
<div class="text-xs text-[color:var(--ink)]/60">Pick what's worth a buzz. Off unless you say so.</div>
|
||||||
|
<div class="mt-2 space-y-1.5">
|
||||||
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
||||||
|
<input type="checkbox" data-adv-cat="siege" class="mt-0.5 accent-[color:var(--accent)]">
|
||||||
|
<span><span class="font-semibold">The Siege</span>: when a world boss camps outside town, and when it's settled.</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
||||||
|
<input type="checkbox" data-adv-cat="run" class="mt-0.5 accent-[color:var(--accent)]">
|
||||||
|
<span><span class="font-semibold">Your expeditions</span>: cleared, backed out, or worse.</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
||||||
|
<input type="checkbox" data-adv-cat="departure" class="mt-0.5 accent-[color:var(--accent)]">
|
||||||
|
<span><span class="font-semibold">Wandering off</span>: your adventurer got bored and left without you.</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
||||||
|
<input type="checkbox" data-adv-cat="contract" class="mt-0.5 accent-[color:var(--accent)]">
|
||||||
|
<span><span class="font-semibold">Contracts on you</span>: somebody paid to have something sent after you.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div data-adv-push-note class="mt-2 text-xs text-[color:var(--ink)]/50"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}{{end}}
|
{{end}}{{end}}
|
||||||
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
|
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{{define "title"}}The realm — {{.SiteTitle}}{{end}}
|
||||||
|
|
||||||
|
{{/* One zone. The whole design problem of this page is in this block: a zone
|
||||||
|
nobody has ever beaten has to LOOK different, not just say so in small
|
||||||
|
text, because "nobody has ever done this" is the single most interesting
|
||||||
|
fact the realm has to offer. */}}
|
||||||
|
{{define "realmzone"}}
|
||||||
|
<li class="realm-zone{{if .Unbeaten}} realm-zone-unbeaten{{end}}{{if .Busy}} realm-zone-busy{{end}}">
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<h3 class="font-display font-bold text-base flex-1 min-w-0 truncate">{{.Display}}</h3>
|
||||||
|
{{if .Levels}}<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 shrink-0">{{.Levels}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Atmosphere}}
|
||||||
|
<p class="mt-1 text-xs text-[color:var(--ink)]/55 leading-relaxed">{{.Atmosphere}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="mt-2.5 text-xs">
|
||||||
|
{{if .Unbeaten}}
|
||||||
|
<p class="realm-unbeaten-line">Nobody has ever come back out of it.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-[color:var(--ink)]/60">
|
||||||
|
{{/* FirstClearBy empty with Clears > 0 is the anonymised case: the zone
|
||||||
|
has been beaten, the clearer opted out. It must not read as unbeaten. */}}
|
||||||
|
First through:
|
||||||
|
{{if .FirstClearToken}}<a href="/adventure/who/{{.FirstClearToken}}" class="font-semibold hover:text-theme-adventure transition">{{.FirstClearBy}}</a>
|
||||||
|
{{else if .FirstClearBy}}<span class="font-semibold">{{.FirstClearBy}}</span>
|
||||||
|
{{else}}<span class="italic text-[color:var(--ink)]/45">somebody who'd rather not say</span>{{end}}
|
||||||
|
{{if .FirstWhen}}<span class="text-[color:var(--ink)]/40"> · {{.FirstWhen}}</span>{{end}}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-[color:var(--ink)]/45 tabular-nums">
|
||||||
|
{{.Clears}} clear{{if ne .Clears 1}}s{{end}} by {{.Clearers}} adventurer{{if ne .Clearers 1}}s{{end}}
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Occupants}}
|
||||||
|
<div class="mt-2.5 pt-2.5 border-t border-[color:var(--ink)]/10">
|
||||||
|
<p class="text-[11px] uppercase tracking-wider text-theme-adventure font-semibold">In there now</p>
|
||||||
|
<p class="mt-1 text-xs">
|
||||||
|
{{range $i, $o := .Occupants}}{{if $i}}<span class="text-[color:var(--ink)]/30">, </span>{{end}}<!--
|
||||||
|
-->{{if $o.Token}}<a href="/adventure/who/{{$o.Token}}" class="font-semibold hover:text-theme-adventure transition">{{$o.Name}}</a>{{else}}<span class="font-semibold">{{$o.Name}}</span>{{end}}<!--
|
||||||
|
-->{{if $o.Day}}<span class="text-[color:var(--ink)]/40"> (day {{$o.Day}})</span>{{end}}{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<article class="mt-2 mb-10 max-w-5xl mx-auto">
|
||||||
|
{{template "realmnav" .}}
|
||||||
|
|
||||||
|
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||||
|
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🗺️</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🗺️ The realm</p>
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Everywhere you can go, and what came back.</h1>
|
||||||
|
<p class="mt-3 opacity-90 max-w-2xl">
|
||||||
|
Every place in the world, in the order it gets harder. Some of these have been
|
||||||
|
walked a hundred times. Some of them have never been walked at all.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .Realm.Known}}
|
||||||
|
<div class="mt-6 flex flex-wrap gap-x-8 gap-y-3 text-xs uppercase tracking-wider opacity-85">
|
||||||
|
<span>Places · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.ZoneCount}}</span></span>
|
||||||
|
<span>Beaten · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.ClearedZones}}</span></span>
|
||||||
|
<span>Never beaten · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.Unbeaten}}</span></span>
|
||||||
|
<span>Out there now · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.OutThere}}</span></span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if and .Realm.Known .Realm.Stale}}
|
||||||
|
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
|
||||||
|
The wire's been quiet — this is the realm as of {{.Realm.LastSeenAgo}}. The places
|
||||||
|
haven't moved, but who's out in them might have.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if not .Realm.Known}}
|
||||||
|
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">
|
||||||
|
I haven't had the survey through from the field yet. When it lands, the whole
|
||||||
|
world lives on this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{range .Realm.Tiers}}
|
||||||
|
<section class="mt-8{{if .Postgame}} realm-band-postgame rounded-3xl p-5 sm:p-6{{end}}">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-1">
|
||||||
|
<h2 class="font-display text-xl sm:text-2xl font-bold">{{.Label}}</h2>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/45 tabular-nums shrink-0">
|
||||||
|
{{.Cleared}} of {{len .Zones}} beaten
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{if .Blurb}}<p class="text-sm text-[color:var(--ink)]/55 mb-4 max-w-2xl">{{.Blurb}}</p>{{end}}
|
||||||
|
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{{range .Zones}}{{template "realmzone" .}}{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Realm.Known}}
|
||||||
|
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center">
|
||||||
|
Say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono">!expedition start</code>
|
||||||
|
to me in Matrix to pick one and go.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{{define "title"}}{{.Name}} in {{.Zone}} — {{.SiteTitle}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<article class="mt-2 mb-10 max-w-3xl mx-auto">
|
||||||
|
<nav class="mb-4 flex items-center gap-4">
|
||||||
|
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||||
|
<span aria-hidden="true">←</span> All dispatches
|
||||||
|
</a>
|
||||||
|
{{if .WhoURL}}
|
||||||
|
<a href="{{.WhoURL}}" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||||
|
{{.Name}}'s sheet <span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||||
|
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.Verdict}}</p>
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Name}} in {{.Zone}}</h1>
|
||||||
|
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">
|
||||||
|
{{if .When}}{{.When}}{{end}}{{if .Level}} · level {{.Level}}{{end}}{{if .Elapsed}} · {{.Elapsed}} down there{{end}}{{if .Rooms}} · {{.Rooms}}{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .Summary}}
|
||||||
|
<!-- The one paragraph on this page nobody in the realm wrote down as it
|
||||||
|
happened: the game's own model reading the finished run back. Guarded at
|
||||||
|
ingest, and absent entirely when the model is off — which is why it sits
|
||||||
|
above the numbers rather than instead of them. -->
|
||||||
|
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||||
|
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Summary}}</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Stats}}
|
||||||
|
<section class="mt-6 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{{range .Stats}}
|
||||||
|
<div class="rounded-2xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-2 py-3 text-center shadow-pete">
|
||||||
|
<div class="font-display text-2xl font-bold leading-none tabular-nums">{{.Value}}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">{{.Label}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Turning}}
|
||||||
|
<!-- The worst single thing that happened, pulled out of the middle of the log
|
||||||
|
where it would otherwise read as one line among forty. -->
|
||||||
|
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-3">Where it turned</h2>
|
||||||
|
<p class="flex items-baseline gap-2.5">
|
||||||
|
<span aria-hidden="true">{{.Turning.Emoji}}</span>
|
||||||
|
<span class="flex-1 font-semibold">{{.Turning.Text}}</span>
|
||||||
|
<span class="runlog-meta">{{if .Turning.Room}}{{.Turning.Room}} · {{end}}{{.Turning.When}}</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<div class="flex items-baseline justify-between mb-4 gap-3">
|
||||||
|
<h2 class="font-display text-xl font-bold">Room by room</h2>
|
||||||
|
{{if .Live}}<span class="text-sm text-[color:var(--ink)]/50">still under way</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<ol class="runlog runlog-full">
|
||||||
|
{{range .Lines}}
|
||||||
|
<li class="runlog-line{{if .Hurt}} runlog-hurt{{end}}{{if .Good}} runlog-good{{end}}">
|
||||||
|
<span class="runlog-emoji" aria-hidden="true">{{.Emoji}}</span>
|
||||||
|
<span class="runlog-text">{{.Text}}</span>
|
||||||
|
<span class="runlog-meta">{{if .Room}}{{.Room}} · {{end}}{{.When}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
{{if .Truncated}}
|
||||||
|
<p class="mt-4 text-xs text-[color:var(--ink)]/45">Only the last {{len .Lines}} moments of this run are shown — it beat out more than the report keeps.</p>
|
||||||
|
{{end}}
|
||||||
|
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
||||||
|
Reporting from the realm, this is Pete.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
{{define "title"}}{{if .Siege.Active}}The Siege — {{.Siege.BossName}}{{else}}The Siege{{end}} — {{.SiteTitle}}{{end}}
|
||||||
|
|
||||||
|
{{/* One row of the defender board. Same shape whether they fought today or are
|
||||||
|
still to go; the column they're in is the message. An opted-out defender
|
||||||
|
carries no token, so they get no link — their damage still counts and still
|
||||||
|
holds its rank, they just aren't named. */}}
|
||||||
|
{{define "defenderrow"}}
|
||||||
|
<li class="flex items-baseline gap-3 py-1.5 border-b border-[color:var(--ink)]/5 last:border-0">
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition truncate">{{.Name}}</a>
|
||||||
|
{{else}}<span class="font-semibold text-[color:var(--ink)]/55 italic truncate">{{.Name}}</span>{{end}}
|
||||||
|
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/40 ml-1.5">lv {{.Level}}</span>{{end}}
|
||||||
|
</span>
|
||||||
|
{{if .Fights}}
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0 tabular-nums">{{.Damage}} dmg · {{.Fights}} bout{{if ne .Fights 1}}s{{end}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/35 shrink-0">not yet in it</span>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="siege"
|
||||||
|
data-active="{{if .Siege.Active}}1{{else}}0{{end}}" data-ends-at="{{.Siege.EndsAt}}">
|
||||||
|
{{/* The war room joined the realm nav when the realm pages landed: it is one
|
||||||
|
of the four standing pages about this place, not a one-off. */}}
|
||||||
|
{{template "realmnav" .}}
|
||||||
|
|
||||||
|
{{if .Siege.Active}}
|
||||||
|
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden {{if not .Siege.Stale}}siege-live{{end}}">
|
||||||
|
<div class="absolute -top-8 -right-4 text-[12rem] opacity-20 select-none" aria-hidden="true">🏰</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🏰 The Siege · Tier {{.Siege.Tier}}</p>
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Siege.BossName}}</h1>
|
||||||
|
<p class="mt-2 opacity-90">Camped outside town. One bout each, every day, until it falls or the window closes.</p>
|
||||||
|
|
||||||
|
<!-- The bar. This is the page. -->
|
||||||
|
<div class="mt-6 siege-track" role="progressbar" aria-label="Siege boss health"
|
||||||
|
aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{.Siege.HPPercent}}" id="siege-bar-track">
|
||||||
|
<div class="siege-fill" id="siege-bar" style="width: {{.Siege.HPPercent}}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-baseline justify-between text-sm">
|
||||||
|
<span class="font-semibold tabular-nums"><span id="siege-hp">{{.Siege.HPCurrent}}</span> / <span id="siege-hpmax">{{.Siege.HPMax}}</span> HP</span>
|
||||||
|
<span class="opacity-80 tabular-nums"><span id="siege-pct">{{.Siege.HPPercent}}</span>% standing</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-xs uppercase tracking-wider opacity-85">
|
||||||
|
<span>Time left · <span class="font-semibold normal-case tracking-normal" id="siege-countdown">—</span></span>
|
||||||
|
<span>Bouts today · <span class="font-semibold tabular-nums" id="siege-bouts">{{.Siege.BoutsToday}}</span></span>
|
||||||
|
<span>Mustered · <span class="font-semibold tabular-nums" id="siege-mustered">{{.Siege.Mustered}}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Siege.Stale}}
|
||||||
|
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
|
||||||
|
We've lost the wire to the field — this is where the pool stood {{.Siege.LastSeenAgo}}. Treat the number as history until it comes back.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- How to actually join in. A signed-in adventurer can do it from here; the
|
||||||
|
Matrix command stays on the page for everyone else, because it is still
|
||||||
|
the only door for a visitor who isn't signed in — and the blow-by-blow of
|
||||||
|
the fight arrives there whichever door you came through. -->
|
||||||
|
<div id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete">
|
||||||
|
{{if .YouOnBoard}}
|
||||||
|
{{if .YouFought}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/75">
|
||||||
|
<span class="font-semibold text-theme-adventure">You've taken your bout today.</span>
|
||||||
|
Come back tomorrow. One fight each, per day, and everyone in the right-hand column below still has theirs.
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
{{/* data-offer marks the half of this panel that stops being true the
|
||||||
|
moment the bout lands. The script hides it on an applied verdict, so
|
||||||
|
the page can't go on saying "unspent" over a fight that just
|
||||||
|
happened. */}}
|
||||||
|
<div data-offer>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/75 mb-3">
|
||||||
|
<span class="font-semibold text-theme-adventure">Your bout is unspent.</span>
|
||||||
|
Damage counts whether you win the fight or not. Turning up is the mechanic, and the blow-by-blow lands in Matrix.
|
||||||
|
</p>
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full bg-theme-adventure text-white px-4 py-1.5 text-sm font-semibold hover:opacity-90 transition"
|
||||||
|
data-action="siege_join"
|
||||||
|
data-label="Take your bout"
|
||||||
|
data-confirm-label="Yes, take my bout"
|
||||||
|
data-confirm="Take your bout against this boss now? It's the only one you get today, and it costs real HP, though you can't die from it. The damage comes off the pool whether you win or lose.">Take your bout</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/75">
|
||||||
|
<span class="font-semibold text-theme-adventure">Taking your bout:</span>
|
||||||
|
say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
|
||||||
|
to me in Matrix. One a day, each. Damage counts whether you win the fight or not; turning up is the mechanic.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div id="adv-action-orders-box" class="mt-4 hidden">
|
||||||
|
<ul id="adv-action-orders" class="space-y-1.5 text-xs"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The muster. Two columns, and the right-hand one is the point: a bout not
|
||||||
|
taken today is damage the pool never sees. -->
|
||||||
|
<section class="mt-6 grid gap-6 sm:grid-cols-2">
|
||||||
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
|
<h2 class="font-display text-xl font-bold">In it today</h2>
|
||||||
|
<span class="siege-chip siege-chip-fought">{{len .Siege.Fought}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .Siege.Fought}}
|
||||||
|
<ul class="text-sm">{{range .Siege.Fought}}{{template "defenderrow" .}}{{end}}</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">Nobody's swung at it yet today. The pool doesn't move on its own.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
|
<h2 class="font-display text-xl font-bold">Bout still going spare</h2>
|
||||||
|
<span class="siege-chip siege-chip-waiting">{{len .Siege.Waiting}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .Siege.Waiting}}
|
||||||
|
<ul class="text-sm">{{range .Siege.Waiting}}{{template "defenderrow" .}}{{end}}</ul>
|
||||||
|
<p class="mt-3 text-xs text-[color:var(--ink)]/45">Each of these is a free hit the town hasn't taken.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">Everyone's been out. Good turnout.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<header class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||||
|
<div class="absolute -top-8 -right-4 text-[12rem] opacity-10 select-none" aria-hidden="true">🏰</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">🏰 The Siege</p>
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Nothing's camped outside town.</h1>
|
||||||
|
<p class="mt-3 text-[color:var(--ink)]/70">
|
||||||
|
{{if .Siege.Known}}
|
||||||
|
Quiet month so far. One comes for the town every month — a named thing with a shared health pool, and everybody gets a swing a day at it.
|
||||||
|
{{else}}
|
||||||
|
I haven't heard from the field about a Siege yet. When one turns up, the bar lives here.
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Siege.History}}
|
||||||
|
<!-- The history is what makes the live bar mean something. A won siege ends
|
||||||
|
at zero and draws as an empty track; a lost one shows exactly how much
|
||||||
|
was left standing when the window shut. -->
|
||||||
|
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-4">Sieges past</h2>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
{{range .Siege.History}}
|
||||||
|
<li>
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="font-semibold flex-1 min-w-0 truncate">{{.BossName}} <span class="text-xs font-normal text-[color:var(--ink)]/40">T{{.Tier}}</span></span>
|
||||||
|
<span class="siege-chip {{if .Won}}siege-chip-held{{else}}siege-chip-fell{{end}} shrink-0">{{if .Won}}town held{{else}}broke through{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 siege-track siege-track-sm">
|
||||||
|
<div class="siege-fill {{if .Won}}siege-fill-spent{{end}}" style="width: {{.HPPercent}}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 flex flex-wrap items-baseline justify-between gap-x-3 text-xs text-[color:var(--ink)]/50">
|
||||||
|
<span>
|
||||||
|
{{if .Won}}Felled by {{.Defenders}} defender{{if ne .Defenders 1}}s{{end}}{{else}}{{.HPRemaining}} HP still standing when the window shut{{end}}{{if .MVP}} · most bouts: <span class="font-semibold text-[color:var(--ink)]/70">{{.MVP}}</span>{{if .MVPFights}} ({{.MVPFights}}){{end}}{{end}}
|
||||||
|
</span>
|
||||||
|
{{if .When}}<span class="shrink-0">{{.When}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The war room is state, like the board — an open tab should stay honest without
|
||||||
|
// a reload. Two moving parts:
|
||||||
|
//
|
||||||
|
// 1. The countdown, which ticks locally every second off the pushed ends_at.
|
||||||
|
// No network for that; the deadline is fixed the moment the Siege spawns.
|
||||||
|
// 2. The pool, re-polled every 15s. The bar's width is set here and CSS does
|
||||||
|
// the animating (a width transition), so a poll that lands a lower pool
|
||||||
|
// *slides* rather than snapping. That slide is the whole reason this page
|
||||||
|
// exists: it is what turns a number into the town chipping something down.
|
||||||
|
(function () {
|
||||||
|
var root = document.getElementById('siege');
|
||||||
|
if (!root) return;
|
||||||
|
var active = root.getAttribute('data-active') === '1';
|
||||||
|
var endsAt = parseInt(root.getAttribute('data-ends-at') || '0', 10);
|
||||||
|
|
||||||
|
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||||||
|
|
||||||
|
function tickCountdown() {
|
||||||
|
var el = document.getElementById('siege-countdown');
|
||||||
|
if (!el) return;
|
||||||
|
if (!endsAt) { el.textContent = '—'; return; }
|
||||||
|
var left = endsAt - Math.floor(Date.now() / 1000);
|
||||||
|
if (left <= 0) { el.textContent = 'window closed'; return; }
|
||||||
|
var h = Math.floor(left / 3600), m = Math.floor((left % 3600) / 60), s = left % 60;
|
||||||
|
el.textContent = h > 0 ? (h + 'h ' + m + 'm') : (m + 'm ' + s + 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active) return; // nothing camped: no countdown, no poll, nothing to move
|
||||||
|
tickCountdown();
|
||||||
|
setInterval(tickCountdown, 1000);
|
||||||
|
|
||||||
|
var pollTimer = null;
|
||||||
|
function refresh() {
|
||||||
|
fetch('/api/adventure/siege', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data) return;
|
||||||
|
if (!data.active) {
|
||||||
|
// It resolved while we were watching. The page we're holding is now a
|
||||||
|
// different page — reload rather than fake an ending, so the result and
|
||||||
|
// the fresh history come from the server that knows them.
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var bar = document.getElementById('siege-bar');
|
||||||
|
if (bar) bar.style.width = data.hp_percent + '%';
|
||||||
|
var track = document.getElementById('siege-bar-track');
|
||||||
|
if (track) track.setAttribute('aria-valuenow', data.hp_percent);
|
||||||
|
txt('siege-hp', data.hp_current);
|
||||||
|
txt('siege-hpmax', data.hp_max);
|
||||||
|
txt('siege-pct', data.hp_percent);
|
||||||
|
txt('siege-bouts', data.bouts_today);
|
||||||
|
txt('siege-mustered', data.mustered);
|
||||||
|
if (data.ends_at) endsAt = data.ends_at;
|
||||||
|
})
|
||||||
|
.catch(function () { /* transient — the next tick will do */ });
|
||||||
|
}
|
||||||
|
pollTimer = setInterval(refresh, 15000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p class="text-xs text-[color:var(--ink)]/45">
|
<p class="text-xs text-[color:var(--ink)]/45">
|
||||||
Click a card, then where it goes. Double-click sends it home.
|
Drag a card where it goes, or click it and then click the spot. Double-click sends it home.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button type="button" data-cash
|
<button type="button" data-cash
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{{define "title"}}The board — {{.SiteTitle}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<article class="mt-2 mb-10 max-w-4xl mx-auto">
|
||||||
|
{{template "realmnav" .}}
|
||||||
|
|
||||||
|
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||||
|
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🏆</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🏆 The board</p>
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Who's actually been getting on with it.</h1>
|
||||||
|
<p class="mt-3 opacity-90 max-w-2xl">
|
||||||
|
Ranked by how deep anyone has got, then by how much of the realm they've
|
||||||
|
put behind them. Lifetime totals — nothing here decays, and nothing here
|
||||||
|
moves while you're not playing.
|
||||||
|
</p>
|
||||||
|
{{if and .Standings.Known .Standings.Stale}}
|
||||||
|
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
|
||||||
|
Last count came in {{.Standings.LastSeenAgo}}. Nothing on this board moves fast, but it isn't live either.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .Standings.Rows}}
|
||||||
|
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/45 border-b-2 border-[color:var(--ink)]/10">
|
||||||
|
<th class="text-left font-semibold px-4 py-3 w-10">#</th>
|
||||||
|
<th class="text-left font-semibold px-2 py-3">Adventurer</th>
|
||||||
|
<th class="text-right font-semibold px-2 py-3" title="The deepest tier they have actually beaten a boss in">Deepest</th>
|
||||||
|
<th class="text-right font-semibold px-2 py-3" title="Distinct zones cleared">Zones</th>
|
||||||
|
<th class="text-right font-semibold px-2 py-3" title="Total successful clears, repeats included">Clears</th>
|
||||||
|
<th class="text-right font-semibold px-2 py-3" title="Things nobody in the realm had ever done before">Firsts</th>
|
||||||
|
<th class="text-right font-semibold px-2 py-3" title="Total damage dealt to every Siege boss, all time">Siege</th>
|
||||||
|
<th class="text-right font-semibold px-4 py-3" title="Deaths I've reported">Deaths</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Standings.Rows}}
|
||||||
|
<tr class="border-b border-[color:var(--ink)]/5 last:border-0 hover:bg-[color:var(--ink)]/[0.03] transition">
|
||||||
|
<td class="px-4 py-3 tabular-nums text-[color:var(--ink)]/35 font-semibold">{{.Rank}}</td>
|
||||||
|
<td class="px-2 py-3 min-w-0">
|
||||||
|
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition">{{.Name}}</a>
|
||||||
|
{{else}}<span class="font-semibold">{{.Name}}</span>{{end}}
|
||||||
|
<span class="block text-xs text-[color:var(--ink)]/40">
|
||||||
|
lv {{.Level}}{{if .ClassRace}} · {{.ClassRace}}{{end}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-3 text-right tabular-nums">
|
||||||
|
{{if .DeepestTier}}<span class="standings-tier standings-tier-{{.DeepestTier}}">T{{.DeepestTier}}</span>
|
||||||
|
{{else}}<span class="text-[color:var(--ink)]/25">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-3 text-right tabular-nums{{if not .Zones}} text-[color:var(--ink)]/25{{end}}">{{if .Zones}}{{.Zones}}{{else}}—{{end}}</td>
|
||||||
|
<td class="px-2 py-3 text-right tabular-nums{{if not .Clears}} text-[color:var(--ink)]/25{{end}}">{{if .Clears}}{{.Clears}}{{else}}—{{end}}</td>
|
||||||
|
<td class="px-2 py-3 text-right tabular-nums">
|
||||||
|
{{if .Firsts}}<span class="standings-firsts">{{.Firsts}}</span>{{else}}<span class="text-[color:var(--ink)]/25">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-3 text-right tabular-nums{{if not .SiegeDamage}} text-[color:var(--ink)]/25{{end}}">
|
||||||
|
{{if .SiegeDamage}}{{.SiegeDamage}}{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums{{if not .Deaths}} text-[color:var(--ink)]/25{{end}}">{{if .Deaths}}{{.Deaths}}{{else}}—{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-3 text-xs text-[color:var(--ink)]/40 px-1">
|
||||||
|
Deepest is the hardest tier they've actually put a boss down in, not the hardest
|
||||||
|
one they've walked into. Deaths is my own count, off the dispatches I've filed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">
|
||||||
|
{{if .Standings.Known}}
|
||||||
|
Nobody on the board yet. Make a character and it fills up.
|
||||||
|
{{else}}
|
||||||
|
Haven't had the count through from the field yet.
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Pete keeps score on himself. He can be hired onto an expedition and he
|
||||||
|
duels; a paper that ranks everybody else and quietly leaves itself off the
|
||||||
|
table is doing something slightly dishonest. */}}
|
||||||
|
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-6 shadow-pete">
|
||||||
|
<h2 class="font-display text-xl font-bold">And my own record, since you asked</h2>
|
||||||
|
{{if .Standings.PeteFought}}
|
||||||
|
<p class="mt-2 text-sm text-[color:var(--ink)]/70">
|
||||||
|
<span class="font-semibold text-theme-adventure tabular-nums text-lg">{{.Standings.PeteWins}}</span> won,
|
||||||
|
<span class="font-semibold tabular-nums text-lg">{{.Standings.PeteLosses}}</span> lost.
|
||||||
|
I file those myself, which you're welcome to hold against me.
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="mt-2 text-sm text-[color:var(--ink)]/60">
|
||||||
|
No bouts yet. I'll report them when there are, wins and the other kind.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
@@ -27,6 +27,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{/* Adventure dispatch types gogobee sent that Pete has no template for. These
|
||||||
|
published on the neutral fallback — nothing was dropped — so this is a
|
||||||
|
vocabulary to-do, not an incident. Admin-only; absent when there are none. */}}
|
||||||
|
{{if and $admin .UnknownAdv}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<div class="rounded-3xl bg-amber-500/10 border-2 border-amber-500/30 p-6">
|
||||||
|
<h2 class="font-display text-xl font-bold">Dispatches with no template</h2>
|
||||||
|
<p class="mt-1 text-sm text-[color:var(--ink)]/70 max-w-2xl">
|
||||||
|
gogobee sent these event types since the last restart and Pete published them on the
|
||||||
|
generic fallback. Nothing was lost — they just read thin until he learns the words.
|
||||||
|
</p>
|
||||||
|
<ul class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{{range .UnknownAdv}}
|
||||||
|
<li class="inline-flex items-center gap-2 rounded-full bg-[color:var(--card)] border border-amber-500/40 px-3 py-1 text-sm">
|
||||||
|
<code class="font-mono">{{.EventType}}</code>
|
||||||
|
<span class="tabular-nums text-[color:var(--ink)]/60">×{{.Count}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
|
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
|
||||||
<table class="w-full {{if $admin}}min-w-[52rem]{{else}}min-w-[28rem]{{end}} text-sm">
|
<table class="w-full {{if $admin}}min-w-[52rem]{{else}}min-w-[28rem]{{end}} text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
|
|
||||||
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||||
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
|
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
|
||||||
|
{{if .RunReportURL}}
|
||||||
|
<!-- The way back to what actually happened. This dispatch is the ending of an
|
||||||
|
expedition; the report is the expedition. -->
|
||||||
|
<a href="{{.RunReportURL}}" class="mt-6 inline-flex items-center gap-2 rounded-2xl bg-theme-adventure text-white px-4 py-2.5 text-sm font-semibold shadow-pete hover:opacity-90 transition">
|
||||||
|
<span aria-hidden="true">📜</span> Read the run, room by room <span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
||||||
Reporting from the realm, this is Pete.
|
Reporting from the realm, this is Pete.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -115,6 +115,33 @@
|
|||||||
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
|
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* The party. Absent on a solo run, so this block answers "is anybody else
|
||||||
|
down there" rather than drawing a chair count. Server-rendered only: a
|
||||||
|
roster is settled before a party leaves town, so unlike the HP and
|
||||||
|
supply numbers above it there is nothing here for the poll to move.
|
||||||
|
An unnamed seat is an opted-out player, kept on purpose — see
|
||||||
|
partySeat.Anonymous for why it is not simply dropped. */}}
|
||||||
|
{{if .Detail.Party}}
|
||||||
|
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50 mb-1">Down there together</h3>
|
||||||
|
<ul>
|
||||||
|
{{range .Detail.Party}}
|
||||||
|
<li class="party-seat">
|
||||||
|
<span class="party-seat-role{{if eq .Kind "leader"}} party-seat-leader{{end}}">{{.Kind}}</span>
|
||||||
|
{{if .Anonymous}}
|
||||||
|
<span class="party-seat-anon flex-1 text-sm">an adventurer who keeps out of the news</span>
|
||||||
|
{{else if .Token}}
|
||||||
|
<a href="/adventure/who/{{.Token}}" class="flex-1 text-sm font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||||||
|
{{else}}
|
||||||
|
<span class="flex-1 text-sm font-semibold">{{.Name}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}</span>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* Public gear list. Hidden only when the owner's "Equipment" panel below will
|
{{/* Public gear list. Hidden only when the owner's "Equipment" panel below will
|
||||||
@@ -167,6 +194,232 @@
|
|||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- The liveblog. The map above says where they are; this says what happened
|
||||||
|
there. Sits outside the HasDetail gate on purpose: the log arrives on its
|
||||||
|
own channel and can outlive the snapshot that drew the map — a run that
|
||||||
|
just ended still has a story after the board has moved the mark back to
|
||||||
|
town. Rebuilt in place by the same poll that moves the HP bar. -->
|
||||||
|
<section id="who-runlog-section" class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete{{if not .RunLog.Has}} hidden{{end}}">
|
||||||
|
<div class="flex items-baseline justify-between mb-4 gap-3">
|
||||||
|
<h2 class="font-display text-xl font-bold">The run</h2>
|
||||||
|
<span id="who-runlog-status" class="text-sm text-[color:var(--ink)]/50">{{if .RunLog.Live}}under way{{else if .RunLog.Outcome}}{{.RunLog.Outcome}}{{else}}finished{{end}}{{if .RunLog.Rooms}} · room {{.RunLog.Rooms}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<ol id="who-runlog" class="runlog">
|
||||||
|
{{range .RunLog.Lines}}
|
||||||
|
<li class="runlog-line{{if .Hurt}} runlog-hurt{{end}}{{if .Good}} runlog-good{{end}}">
|
||||||
|
<span class="runlog-emoji" aria-hidden="true">{{.Emoji}}</span>
|
||||||
|
<span class="runlog-text">{{.Text}}</span>
|
||||||
|
<span class="runlog-meta">{{if .Room}}{{.Room}} · {{end}}{{.When}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
<!-- Only once the run is over: while it's still walking, this column IS the
|
||||||
|
report, and a link to a second copy of it is just a way to lose the
|
||||||
|
reader. Revealed in place when a run ends under an open tab. -->
|
||||||
|
<a id="who-runlog-report" href="{{.RunLog.ReportURL}}"
|
||||||
|
class="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-theme-adventure hover:opacity-80 transition{{if not .RunLog.ReportURL}} hidden{{end}}">
|
||||||
|
<span aria-hidden="true">📜</span> The full report <span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .HasSelf}}
|
||||||
|
<!-- Your call. The panels above are a spectator view of a run going well or
|
||||||
|
badly; this is the one thing the watcher can do about it. Owner-only, and
|
||||||
|
shown whether or not the mark is currently on a run — the board is up to
|
||||||
|
two minutes stale, so hiding the button on a snapshot that says "in town"
|
||||||
|
would be the page refusing an action the game would have allowed. gogobee
|
||||||
|
answers rejected_not_running if it really has ended, and that answer shows
|
||||||
|
up in the strip below. -->
|
||||||
|
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete"
|
||||||
|
data-balance="{{.Self.Balance}}">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-1">Your call</h2>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60 mb-4">
|
||||||
|
Asked for here, done on the game box. It picks these up within a few seconds.
|
||||||
|
</p>
|
||||||
|
{{/* data-offer marks what stops being true once the extraction lands: there
|
||||||
|
is no run left to pull out of. Hidden by the script on an applied
|
||||||
|
verdict, restored on a refusal, which is the one case where the reader
|
||||||
|
still needs the retry.
|
||||||
|
|
||||||
|
W9: withheld from a party MEMBER, and only from them. W5a's rule is that
|
||||||
|
an idle-looking mark still gets this button, because the board is two
|
||||||
|
minutes stale and refusing a live extraction is worse than a no-op — but
|
||||||
|
a member is not a staleness question. Pete knows from the same seat it
|
||||||
|
just read that gogobee will answer rejected_not_leader, and offering a
|
||||||
|
certain refusal directly above "Turn back alone", which is the thing that
|
||||||
|
does work, is the page contradicting itself. CanLeave is only ever true
|
||||||
|
for a seat that says "member". */}}
|
||||||
|
{{if not .CanLeave}}
|
||||||
|
<div data-offer>
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-4 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="extract"
|
||||||
|
data-label="Pull out of the run"
|
||||||
|
data-confirm-label="Yes, pull out"
|
||||||
|
data-confirm="Pull out of the dungeon now? You keep the loot, XP and coins you're carrying, and the run waits where you left it: you have seven days to go back in. If you're leading a party, it ends the day for all of you.">Pull out of the run</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* W9. The two ways out that are not an extraction, each in its own
|
||||||
|
data-offer wrapper rather than sharing the extract one above — an
|
||||||
|
extracted run is still abandonable, so hiding "Call it off" when the
|
||||||
|
extraction lands would take away the very button that case needs.
|
||||||
|
|
||||||
|
They are mutually exclusive by construction (offersToUndo reads the
|
||||||
|
viewer's own party seat: a leader is offered one, a member the other),
|
||||||
|
which is why neither mentions the other. */}}
|
||||||
|
{{if .CanAbandon}}
|
||||||
|
<div data-offer class="mt-3">
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-[color:var(--warn)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="expedition_abandon"
|
||||||
|
data-label="Call the whole thing off"
|
||||||
|
data-confirm-tone="warn"
|
||||||
|
data-confirm-label="Yes, call it off"
|
||||||
|
data-confirm="End this expedition for good? Whatever is left of the supplies is forfeit and there is no way back into the run afterwards. If you have a party with you, it ends for all of them too, and they will be told. This is not the same as pulling out: pulling out keeps the way back open for seven days.">Call the whole thing off</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CanLeave}}
|
||||||
|
<div data-offer class="mt-3">
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="expedition_leave"
|
||||||
|
data-label="Turn back alone"
|
||||||
|
data-confirm-label="Yes, turn back"
|
||||||
|
data-confirm="Walk out of this party and head for town on your own? The rest of them carry on without you, and the supplies you bought stay in their pool: they were spent on the expedition, not lent to it. Your leader will be told.">Turn back alone</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* W5b. Everything below is server-rendered from the offer list gogobee
|
||||||
|
pushed onto this owner's own private row, so the page can only ever
|
||||||
|
propose a zone, a loadout and a price the game itself quoted. The order
|
||||||
|
carries those keys back and gogobee re-resolves all of them — an offer is
|
||||||
|
a quote, never a permission. */}}
|
||||||
|
|
||||||
|
{{if .Self.Resume}}
|
||||||
|
{{/* The way back in, first: it is the only offer here with a deadline on it,
|
||||||
|
and it is the answer to the verdict "Pull out" leaves behind, which until
|
||||||
|
now told a web player to go and type !resume in Matrix. */}}
|
||||||
|
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="font-display text-base font-bold">Go back in</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
|
||||||
|
{{.Self.Resume.Display}}, day {{.Self.Resume.Day}} — waiting where you left it.
|
||||||
|
{{with untilUnix .Self.Resume.ExpiresAt}}<span class="text-[color:var(--warn)] font-semibold">{{.}}</span>.{{end}}
|
||||||
|
You re-stock before you go, so pick a pack.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{{$rz := .Self.Resume}}
|
||||||
|
{{range $rz.Loadouts}}
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="expedition_resume"
|
||||||
|
data-loadout="{{.Key}}"
|
||||||
|
data-cost="{{.Cost}}"
|
||||||
|
data-label="{{.Name}} · €{{euro .Cost}}"
|
||||||
|
data-confirm-label="Go back in"
|
||||||
|
data-confirm="Walk back into {{$rz.Display}} on day {{$rz.Day}} with a {{.Name}} pack: {{.Blurb}}. About {{.Days}} days of provisions.">{{.Name}} · €{{euro .Cost}}</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Self.Zones}}
|
||||||
|
{{/* Set out. The zone list is already level-gated and postgame-gated by
|
||||||
|
gogobee, and it arrives EMPTY while this adventurer is already out —
|
||||||
|
which is why there is no "you're on an expedition" branch here. */}}
|
||||||
|
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="font-display text-base font-bold">Set out</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
|
||||||
|
Pick where, then pick how much to carry. The pack is what you pay for.
|
||||||
|
</p>
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Zone</span>
|
||||||
|
<select id="adv-zone-pick"
|
||||||
|
class="w-full rounded-xl bg-[color:var(--ink)]/5 border border-[color:var(--ink)]/15 px-3 py-2 text-sm font-semibold">
|
||||||
|
{{range .Self.Zones}}
|
||||||
|
<option value="{{.ID}}">{{.Display}} · T{{.Tier}}{{if .Postgame}} · mythic{{end}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{{/* One group per zone, all rendered, one shown. The costs differ by tier,
|
||||||
|
so a single shared row of buttons would have to be repriced in the
|
||||||
|
browser — and Pete does no arithmetic on the game's money. */}}
|
||||||
|
{{range .Self.Zones}}
|
||||||
|
{{$z := .}}
|
||||||
|
<div class="adv-zone-loadouts mt-2.5 hidden" data-zone="{{$z.ID}}">
|
||||||
|
{{with $z.Hook}}<p class="text-xs italic text-[color:var(--ink)]/50 mb-2">{{.}}</p>{{end}}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{{range $z.Loadouts}}
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="expedition_start"
|
||||||
|
data-zone="{{$z.ID}}"
|
||||||
|
data-loadout="{{.Key}}"
|
||||||
|
data-cost="{{.Cost}}"
|
||||||
|
data-label="{{.Name}} · €{{euro .Cost}}"
|
||||||
|
data-confirm-label="Set out"
|
||||||
|
data-confirm="Head for {{$z.Display}} with a {{.Name}} pack: {{.Blurb}}. About {{.Days}} days of provisions.">{{.Name}} · €{{euro .Cost}}</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Self.Babysit}}
|
||||||
|
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="font-display text-base font-bold">The sitter</h3>
|
||||||
|
{{if .Self.Babysit.Active}}
|
||||||
|
{{/* Engaged: say so, and offer only the way back out. Buying a second one
|
||||||
|
is refused by the game anyway, and a buy button under "already engaged"
|
||||||
|
reads as a page that has not noticed. */}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">
|
||||||
|
Somebody is looking after the camp{{with untilUnix .Self.Babysit.ExpiresAt}} — {{.}}{{end}}.
|
||||||
|
Your pet is being tended daily and standard camps rest like fortified ones.
|
||||||
|
</p>
|
||||||
|
{{if .CanCancelSitter}}
|
||||||
|
<div class="flex flex-wrap gap-1.5 mt-2.5">
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="babysit_cancel"
|
||||||
|
data-label="Send them home"
|
||||||
|
data-confirm-label="Yes, send them home"
|
||||||
|
data-confirm="Send the sitter home now? There is no refund: you paid for the days you booked and the rest of them go with the sitter. Your pet stops being tended and standard camps go back to resting like standard camps.">Send them home</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
|
||||||
|
Pet tended daily, standard camps rest like fortified ones, rival duels declined for you.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="babysit" data-days="7" data-cost="{{.Self.Babysit.WeekCost}}"
|
||||||
|
data-label="A week · €{{euro .Self.Babysit.WeekCost}}"
|
||||||
|
data-confirm-label="Hire for a week"
|
||||||
|
data-confirm="Engage the sitter for seven days. No refund if you cancel early.">A week · €{{euro .Self.Babysit.WeekCost}}</button>
|
||||||
|
<button type="button"
|
||||||
|
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
data-action="babysit" data-days="30" data-cost="{{.Self.Babysit.MonthCost}}"
|
||||||
|
data-label="A month · €{{euro .Self.Babysit.MonthCost}}"
|
||||||
|
data-confirm-label="Hire for a month"
|
||||||
|
data-confirm="Engage the sitter for thirty days. No refund if you cancel early.">A month · €{{euro .Self.Babysit.MonthCost}}</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- The queue is honest: an action lands on the game box's next poll, so a
|
||||||
|
fresh one reads "asked for", never "done". JS fills this from
|
||||||
|
/api/adventure/orders. -->
|
||||||
|
<div id="adv-action-orders-box" class="mt-5 hidden">
|
||||||
|
<h3 class="font-display text-base font-bold mb-2">What you've asked for</h3>
|
||||||
|
<ul id="adv-action-orders" class="space-y-1.5 text-xs"></ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .HasHistory}}
|
{{if .HasHistory}}
|
||||||
<!-- The record. Public, like the dispatches it's counted from — this is the
|
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||||||
same information the /adventure feed already printed, only as numbers
|
same information the /adventure feed already printed, only as numbers
|
||||||
@@ -345,13 +598,27 @@
|
|||||||
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
|
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* Pets, with their levelling shown. They have been earning XP from every
|
||||||
|
won fight since that wiring was fixed, and until now the only place a
|
||||||
|
level appeared was a Matrix line that scrolled away. Ranges over
|
||||||
|
.PetRows, not .Self.Pets: the progress figures are worked out in Go
|
||||||
|
from the centi-XP gogobee sends, because the curve behind the target
|
||||||
|
is the engine's and there is no copy of it here. */}}
|
||||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
||||||
{{if .Self.Pets}}
|
{{if .PetRows}}
|
||||||
<ul class="space-y-2 text-sm">
|
<ul class="space-y-3 text-sm">
|
||||||
{{range .Self.Pets}}
|
{{range .PetRows}}
|
||||||
<li class="flex items-baseline justify-between gap-3">
|
<li>
|
||||||
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
|
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pet-xp-track mt-1.5">
|
||||||
|
<div class="pet-xp-fill{{if .Capped}} pet-xp-capped{{end}}" style="width: {{if .Capped}}100{{else}}{{.Percent}}{{end}}%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-[color:var(--ink)]/45">
|
||||||
|
{{if .Capped}}fully grown{{else}}{{.Progress}} to level {{.NextLevel}}{{end}}
|
||||||
|
</p>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -421,6 +688,66 @@
|
|||||||
|
|
||||||
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||||||
|
|
||||||
|
// The liveblog is rebuilt from the JSON rather than patched: beats only ever
|
||||||
|
// arrive at the end, but a run can also END between polls, which changes the
|
||||||
|
// header and can drop the section entirely. Redrawing forty short lines is
|
||||||
|
// cheaper than getting the incremental case wrong.
|
||||||
|
//
|
||||||
|
// Built with textContent throughout — a beat carries a monster name that came
|
||||||
|
// off the wire, and innerHTML here would make the game box able to inject
|
||||||
|
// markup into a public page.
|
||||||
|
function drawRunLog(log) {
|
||||||
|
var section = document.getElementById('who-runlog-section');
|
||||||
|
var list = document.getElementById('who-runlog');
|
||||||
|
if (!section || !list) return;
|
||||||
|
if (!log || !log.Has || !log.Lines || !log.Lines.length) {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only scroll to the newest beat when the reader was already at the bottom.
|
||||||
|
// Yanking them back down while they're reading four rooms ago is worse than
|
||||||
|
// making them scroll.
|
||||||
|
var pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 24;
|
||||||
|
|
||||||
|
section.classList.remove('hidden');
|
||||||
|
txt('who-runlog-status',
|
||||||
|
(log.Live ? 'under way' : (log.Outcome || 'finished')) +
|
||||||
|
(log.Rooms ? ' · room ' + log.Rooms : ''));
|
||||||
|
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
log.Lines.forEach(function (ln) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'runlog-line' + (ln.Hurt ? ' runlog-hurt' : '') + (ln.Good ? ' runlog-good' : '');
|
||||||
|
var e = document.createElement('span');
|
||||||
|
e.className = 'runlog-emoji'; e.setAttribute('aria-hidden', 'true'); e.textContent = ln.Emoji || '';
|
||||||
|
var t = document.createElement('span');
|
||||||
|
t.className = 'runlog-text'; t.textContent = ln.Text || '';
|
||||||
|
var m = document.createElement('span');
|
||||||
|
m.className = 'runlog-meta'; m.textContent = (ln.Room ? ln.Room + ' · ' : '') + (ln.When || '');
|
||||||
|
li.appendChild(e); li.appendChild(t); li.appendChild(m);
|
||||||
|
frag.appendChild(li);
|
||||||
|
});
|
||||||
|
list.replaceChildren(frag);
|
||||||
|
if (pinned) list.scrollTop = list.scrollHeight;
|
||||||
|
|
||||||
|
// The run can end between two polls, which is exactly the moment the report
|
||||||
|
// becomes worth offering. href is set from the payload rather than built here
|
||||||
|
// so the page never invents a URL for a run Pete won't serve.
|
||||||
|
var report = document.getElementById('who-runlog-report');
|
||||||
|
if (report) {
|
||||||
|
if (log.ReportURL) {
|
||||||
|
report.href = log.ReportURL;
|
||||||
|
report.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
report.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(function () {
|
||||||
|
var list = document.getElementById('who-runlog');
|
||||||
|
if (list) list.scrollTop = list.scrollHeight;
|
||||||
|
})();
|
||||||
|
|
||||||
var timer = null;
|
var timer = null;
|
||||||
function refresh() {
|
function refresh() {
|
||||||
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
||||||
@@ -430,7 +757,10 @@
|
|||||||
if (!data.live) {
|
if (!data.live) {
|
||||||
// Off the board entirely (expedition ended, opted out): nothing more to
|
// Off the board entirely (expedition ended, opted out): nothing more to
|
||||||
// poll for, so stop the timer rather than hammer a token that's gone.
|
// poll for, so stop the timer rather than hammer a token that's gone.
|
||||||
|
// The log goes with them: off the board is off the page, and the API
|
||||||
|
// sends none in this branch.
|
||||||
txt('who-where', 'Back in town');
|
txt('who-where', 'Back in town');
|
||||||
|
drawRunLog(null);
|
||||||
if (timer) clearInterval(timer);
|
if (timer) clearInterval(timer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -452,6 +782,7 @@
|
|||||||
} else {
|
} else {
|
||||||
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
|
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
|
||||||
}
|
}
|
||||||
|
drawRunLog(data.run_log);
|
||||||
})
|
})
|
||||||
.catch(function () { /* transient — next tick will do */ });
|
.catch(function () { /* transient — next tick will do */ });
|
||||||
}
|
}
|
||||||
@@ -622,3 +953,5 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
|
||||||
|
|||||||
+188
-4
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
@@ -34,6 +36,160 @@ type whoDetail struct {
|
|||||||
ThreatLevel int `json:"threat_level"`
|
ThreatLevel int `json:"threat_level"`
|
||||||
Room string `json:"room"`
|
Room string `json:"room"`
|
||||||
Map *whoMap `json:"map"`
|
Map *whoMap `json:"map"`
|
||||||
|
// Party is who else is down there, leader first. Absent on a solo run.
|
||||||
|
Party []partySeat `json:"party"`
|
||||||
|
// PartyKnown says the sheet was built by a gogobee that knows about party
|
||||||
|
// seats, which an absent Party cannot: nil means "solo" and "this sender never
|
||||||
|
// pushes seats" identically, and those two want opposite buttons. It is a
|
||||||
|
// capability flag about the SENDER, set unconditionally by any gogobee new
|
||||||
|
// enough — including on a solo run, including when the seat list is omitted —
|
||||||
|
// so it must never be read as a fact about the character.
|
||||||
|
PartyKnown bool `json:"party_known"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// partySeat is one body on a shared expedition as gogobee described it. Kind is
|
||||||
|
// "leader", "member" or "companion" — the game keeps those three carefully
|
||||||
|
// distinct and so does this page: a companion fights but is nobody's account, so
|
||||||
|
// he is named without a link and never counted as a player.
|
||||||
|
//
|
||||||
|
// A seat with a Kind but no Name is an opted-out player, kept on purpose. gogobee
|
||||||
|
// anonymises rather than deletes here (the Siege contributor rule, not the realm
|
||||||
|
// occupant rule), because a party of three rendered as a pair contradicts the
|
||||||
|
// supply burn and threat level printed beside it. Render it as an unnamed seat;
|
||||||
|
// never as an absent one, and never with a link.
|
||||||
|
type partySeat struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anonymous reports whether this seat belongs to a player who has opted out of
|
||||||
|
// the news. Keyed on the name rather than the token because the two come apart
|
||||||
|
// only in the direction that matters: gogobee never sends a token without a name.
|
||||||
|
func (p partySeat) Anonymous() bool { return p.Kind != "companion" && p.Name == "" }
|
||||||
|
|
||||||
|
// petRow is one pet with its levelling made legible. gogobee sends centi-XP and
|
||||||
|
// the engine's own threshold for the pet's current level band; the arithmetic
|
||||||
|
// here is presentation only — a percentage for the bar and a decimal for the
|
||||||
|
// label — and there is deliberately no copy of the curve on this side.
|
||||||
|
type petRow struct {
|
||||||
|
storage.PetView
|
||||||
|
// Capped is the level ceiling: gogobee reports 0 needed, which is not the same
|
||||||
|
// as an empty bar and must not render as one.
|
||||||
|
Capped bool
|
||||||
|
// Percent is 0-100 for the bar's width. Clamped, because a pet can sit above
|
||||||
|
// its own threshold for the moment between earning XP and the next level-up
|
||||||
|
// pass, and a bar wider than its track breaks the layout rather than the maths.
|
||||||
|
Percent int
|
||||||
|
// Progress is the human label: "7.5 / 20".
|
||||||
|
Progress string
|
||||||
|
// NextLevel is what the bar is filling toward. 0 when capped.
|
||||||
|
NextLevel int
|
||||||
|
}
|
||||||
|
|
||||||
|
// petRows makes the pushed pets renderable. Pets are owner-only — the public
|
||||||
|
// sheet has never carried them — so this runs behind the ownership join.
|
||||||
|
func petRows(pets []storage.PetView) []petRow {
|
||||||
|
if len(pets) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]petRow, 0, len(pets))
|
||||||
|
for _, p := range pets {
|
||||||
|
row := petRow{PetView: p, Capped: p.XPNeeded <= 0}
|
||||||
|
if !row.Capped {
|
||||||
|
row.Percent = min(100, max(0, p.XP*100/p.XPNeeded))
|
||||||
|
row.Progress = fmt.Sprintf("%s / %s", centiXP(p.XP), centiXP(p.XPNeeded))
|
||||||
|
row.NextLevel = p.Level + 1
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// centiXP renders the game's hundredths as the number a player recognises. A pet
|
||||||
|
// earns 1.5 XP per action, so the halves are real and dropping them would make a
|
||||||
|
// bar that visibly moved report the same figure twice.
|
||||||
|
func centiXP(centi int) string {
|
||||||
|
if centi%100 == 0 {
|
||||||
|
return strconv.Itoa(centi / 100)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(centi)/100), "0"), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterStatusExpedition is the one roster status that means "down there right
|
||||||
|
// now". Spelled out here because W9's offers turn on it and a typo would silently
|
||||||
|
// hide a button rather than fail.
|
||||||
|
const rosterStatusExpedition = "expedition"
|
||||||
|
|
||||||
|
// offersToUndo decides which of the three W9 verbs this owner's page proposes.
|
||||||
|
// Courtesy only, like every offer on this page: gogobee re-resolves all three and
|
||||||
|
// its refusal is the real answer. What this buys is a page that does not put
|
||||||
|
// "Leave the party" in front of somebody standing in town.
|
||||||
|
//
|
||||||
|
// Leadership is legible in the party seats gogobee pushes (W7) and the sitter's
|
||||||
|
// standing is in the babysit offer (W5b), so the facts the buttons need are on
|
||||||
|
// the wire already — except for one, which nil could not express. partyKnown is
|
||||||
|
// the sender's "I know about party seats" flag, and it gates the empty-list
|
||||||
|
// branch alone. An empty list means "solo, so this player is the leader" ONLY
|
||||||
|
// from a sender that would have listed seats if there were any; from a blob that
|
||||||
|
// did not decode, or from a gogobee too old to push seats at all, the same empty
|
||||||
|
// slice would offer a party MEMBER the button that throws away everyone's day.
|
||||||
|
// Found by getting a fixture wrong: the page did exactly that, silently and
|
||||||
|
// convincingly.
|
||||||
|
//
|
||||||
|
// The asymmetry is deliberate: the len(party) > 0 branch reads the viewer's own
|
||||||
|
// seat and is self-evidencing — a seat that says "member" cannot be mistaken for
|
||||||
|
// leadership — so it needs no flag and keeps working against every sender.
|
||||||
|
func offersToUndo(token, status string, partyKnown bool, party []partySeat, self storage.PlayerDetail) (abandon, leave, cancelSitter bool) {
|
||||||
|
// The sitter first, because it is the only one of the three whose fact does
|
||||||
|
// not go stale: an engagement is a property of the character, not of where
|
||||||
|
// they are standing, so a two-minute-old snapshot is still right about it.
|
||||||
|
cancelSitter = self.Babysit != nil && self.Babysit.Active
|
||||||
|
|
||||||
|
if status == rosterStatusExpedition {
|
||||||
|
if len(party) == 0 {
|
||||||
|
// A SOLO run publishes no party at all — partySeatViews returns nil
|
||||||
|
// below two seats — so from a sender that knows about seats an empty list
|
||||||
|
// is not "we don't know", it is "there is nobody else", which makes this
|
||||||
|
// player the leader. Without the flag it is exactly "we don't know", and
|
||||||
|
// the button stays off.
|
||||||
|
abandon = partyKnown
|
||||||
|
} else {
|
||||||
|
// With a party, offer strictly on the viewer's own seat, and offer
|
||||||
|
// nothing at all if we cannot find it. Guessing in that case would mean
|
||||||
|
// showing a member the button that throws away everyone's day.
|
||||||
|
for _, seat := range party {
|
||||||
|
if seat.Token != token {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch seat.Kind {
|
||||||
|
case "leader":
|
||||||
|
abandon = true
|
||||||
|
case "member":
|
||||||
|
leave = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An extracted expedition is still the owner's to close, and it is the case
|
||||||
|
// the status check above cannot see: its owner reads as idle in town, with no
|
||||||
|
// party, and the resume offer is the only sign the run is still open. Without
|
||||||
|
// this, a leader who wanted out had to pay to walk back in first — the exact
|
||||||
|
// hole the game's own abandon path was widened to cover.
|
||||||
|
//
|
||||||
|
// Not while they are sitting in somebody ELSE'S party, though, and that is not
|
||||||
|
// a hypothetical: a player who extracted their own run and then took a seat
|
||||||
|
// has both facts true at once, about two different expeditions. Both buttons
|
||||||
|
// on one page would be asking the reader to work out which run each meant,
|
||||||
|
// and this page is about the one they are standing in. The abandon is still
|
||||||
|
// there from town the moment they walk out of the party.
|
||||||
|
if self.Resume != nil && !leave {
|
||||||
|
abandon = true
|
||||||
|
}
|
||||||
|
return abandon, leave, cancelSitter
|
||||||
}
|
}
|
||||||
|
|
||||||
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
|
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
|
||||||
@@ -83,13 +239,26 @@ type whoPage struct {
|
|||||||
Detail whoDetail
|
Detail whoDetail
|
||||||
Abilities []abilityRow
|
Abilities []abilityRow
|
||||||
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
||||||
HasSelf bool
|
// RunLog is the liveblog for the run the map is showing: what happened in
|
||||||
Self storage.PlayerDetail
|
// those rooms, in order. Deliberately independent of MapView — the map rides
|
||||||
|
// the roster snapshot and the log rides the beat channel, so either can be
|
||||||
|
// present without the other and the page must not assume they arrive together.
|
||||||
|
RunLog RunLogView
|
||||||
|
HasSelf bool
|
||||||
|
Self storage.PlayerDetail
|
||||||
|
// The three W9 verbs that undo something. Unlike every offer above them these
|
||||||
|
// are derived on Pete rather than pushed: leadership is already legible in the
|
||||||
|
// party seats, and a sitter's standing is already in the babysit offer, so
|
||||||
|
// there was nothing to add to the wire. See offersToUndo.
|
||||||
|
CanAbandon bool
|
||||||
|
CanLeave bool
|
||||||
|
CanCancelSitter bool
|
||||||
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
||||||
// only means something on a worn item — see itemRow.
|
// only means something on a worn item — see itemRow.
|
||||||
Worn []itemRow
|
Worn []itemRow
|
||||||
Backpack []itemRow
|
Backpack []itemRow
|
||||||
VaultRows []itemRow
|
VaultRows []itemRow
|
||||||
|
PetRows []petRow
|
||||||
BondsUsed int
|
BondsUsed int
|
||||||
// History. Unlike everything above, these are not a gogobee snapshot — they
|
// History. Unlike everything above, these are not a gogobee snapshot — they
|
||||||
// are counted from the facts Pete has been keeping since adventure_events
|
// are counted from the facts Pete has been keeping since adventure_events
|
||||||
@@ -197,6 +366,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
|||||||
page.Abilities = abil
|
page.Abilities = abil
|
||||||
page.MapView = buildMapView(d.Map)
|
page.MapView = buildMapView(d.Map)
|
||||||
}
|
}
|
||||||
|
page.RunLog = runLogFor(token)
|
||||||
|
|
||||||
// History: one read feeds both the trophy case and the trail. Keyed on the
|
// History: one read feeds both the trophy case and the trail. Keyed on the
|
||||||
// character *name* rather than the page token, because that is what a fact
|
// character *name* rather than the page token, because that is what a fact
|
||||||
@@ -238,11 +408,14 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
|||||||
page.Worn = itemRows(self.Equipped, "worn")
|
page.Worn = itemRows(self.Equipped, "worn")
|
||||||
page.Backpack = itemRows(self.Inventory, "backpack")
|
page.Backpack = itemRows(self.Inventory, "backpack")
|
||||||
page.VaultRows = itemRows(self.Vault, "vault")
|
page.VaultRows = itemRows(self.Vault, "vault")
|
||||||
|
page.PetRows = petRows(self.Pets)
|
||||||
for _, it := range self.Equipped {
|
for _, it := range self.Equipped {
|
||||||
if it.Attuned {
|
if it.Attuned {
|
||||||
page.BondsUsed++
|
page.BondsUsed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
page.CanAbandon, page.CanLeave, page.CanCancelSitter =
|
||||||
|
offersToUndo(token, entry.Status, page.HasDetail && page.Detail.PartyKnown, page.Detail.Party, self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,8 +441,15 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
if !ok {
|
if !ok {
|
||||||
// Gone from the board (expedition ended, opted out): tell the poller so it
|
// Gone from the board: tell the poller so it can stop, rather than 404-ing
|
||||||
// can stop, rather than 404-ing an open tab into an error.
|
// an open tab into an error.
|
||||||
|
//
|
||||||
|
// No run log here, deliberately. Finishing a run does NOT take an
|
||||||
|
// adventurer off the board — they stay on it as idle, and their last log
|
||||||
|
// keeps rendering through this endpoint's normal path. What DOES take them
|
||||||
|
// off it is opting out or being removed, and shipping a room-by-room
|
||||||
|
// account of where somebody is from the one branch that means "this player
|
||||||
|
// asked not to be listed" would make the API say what the page refuses to.
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
|
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -280,6 +460,10 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
"has_detail": hasDetail,
|
"has_detail": hasDetail,
|
||||||
"detail": d,
|
"detail": d,
|
||||||
"abilities": abil,
|
"abilities": abil,
|
||||||
|
// The liveblog rides the sheet's poll rather than a second timer: the two
|
||||||
|
// move on the same 2-minute push and a separate request would just be a
|
||||||
|
// second way to be out of step with the map beside it.
|
||||||
|
"run_log": runLogFor(token),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The two W7 additions to the adventurer page: the party roster (public, rides
|
||||||
|
// the roster detail) and pet levelling (owner-only, rides the self detail). Both
|
||||||
|
// are driven through the real templates, so a field slip 500s here.
|
||||||
|
|
||||||
|
// seedPartyWho puts Josie on the board mid-run with a three-seat party: her, a
|
||||||
|
// named companion player, an opted-out player (anonymised upstream: kind but no
|
||||||
|
// name), and the hireling.
|
||||||
|
func seedPartyWho(t *testing.T) *Server {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"hp_current": 30,
|
||||||
|
"hp_max": 42,
|
||||||
|
"armor_class": 17,
|
||||||
|
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||||
|
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||||
|
"supplies": 8,
|
||||||
|
"room": "3 / 7",
|
||||||
|
"party": []map[string]any{
|
||||||
|
{"kind": "leader", "name": "Josie", "token": "tok-josie", "level": 14},
|
||||||
|
{"kind": "member", "name": "Camcast", "token": "tok-cam", "level": 12},
|
||||||
|
{"kind": "member"}, // opted out: anonymised upstream
|
||||||
|
{"kind": "companion", "name": "Pete"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||||
|
e.Level = 14
|
||||||
|
e.ClassRace = "human cleric"
|
||||||
|
e.Detail = raw
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{
|
||||||
|
SnapshotAt: time.Now().Unix(), Adventurers: []storage.RosterEntry{e},
|
||||||
|
}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoDrawsThePartyWithoutNamingAnOptOut. The roster has to add up — a party
|
||||||
|
// of four rendered as three contradicts the supply burn printed beside it — while
|
||||||
|
// the seat belonging to a player who keeps out of the news carries no name and no
|
||||||
|
// link out.
|
||||||
|
func TestWhoDrawsThePartyWithoutNamingAnOptOut(t *testing.T) {
|
||||||
|
s := seedPartyWho(t)
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("GET who = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Down there together", "Camcast", "Pete", "leader", "companion"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("party block missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Four seats drawn, not three.
|
||||||
|
if got := strings.Count(body, `class="party-seat"`); got != 4 {
|
||||||
|
t.Errorf("drew %d seats, want 4 — an anonymised seat must still occupy a chair", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "keeps out of the news") {
|
||||||
|
t.Error("the anonymised seat rendered as nothing at all")
|
||||||
|
}
|
||||||
|
// The named member links to their own page; the hireling and the anonymous
|
||||||
|
// seat have nothing to link to.
|
||||||
|
if !strings.Contains(body, `/adventure/who/tok-cam`) {
|
||||||
|
t.Error("a named party member is not linked to their page")
|
||||||
|
}
|
||||||
|
// Exactly two seat links: the two seats carrying tokens. The hireling and the
|
||||||
|
// anonymised seat must be text, not doors. (Counted on the href so the page's
|
||||||
|
// own /api/adventure/who/ poll URL does not register as a link.)
|
||||||
|
if got := strings.Count(body, `href="/adventure/who/`); got != 2 {
|
||||||
|
t.Errorf("seat links = %d, want 2 — only a named seat may link out", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSoloRunDrawsNoPartyBlock: the block is the answer to "is anybody else down
|
||||||
|
// there", so on a solo run it must not appear at all rather than list one chair.
|
||||||
|
func TestSoloRunDrawsNoPartyBlock(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie") // no party in its detail blob
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("GET who = %d", w.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "Down there together") {
|
||||||
|
t.Error("a solo run drew a party block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPetLevellingIsVisibleToTheOwner. Pets have been earning XP from every won
|
||||||
|
// fight since that wiring was fixed and no web surface has ever shown it. The
|
||||||
|
// unit matters more than the bar: XP arrives as centi-XP, so a page that printed
|
||||||
|
// it raw would tell somebody their cat has 750 of 20 points.
|
||||||
|
func TestPetLevellingIsVisibleToTheOwner(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", "idle", "")
|
||||||
|
e.Detail = publicDetail(t)
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: "josie", Token: "tok-josie",
|
||||||
|
Pets: []storage.PetView{
|
||||||
|
{Type: "cat", Name: "Mittens", Level: 4, XP: 750, XPNeeded: 2000},
|
||||||
|
{Type: "dog", Name: "Rex", Level: 10}, // capped: nothing left to earn
|
||||||
|
},
|
||||||
|
}}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed detail = %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||||
|
if !strings.Contains(body, "7.5 / 20 to level 5") {
|
||||||
|
t.Error("Mittens' progress is not rendered in whole XP — check the centi-XP divide")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "750") {
|
||||||
|
t.Error("raw centi-XP leaked onto the page")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "fully grown") {
|
||||||
|
t.Error("a capped pet has no label saying why its bar is full")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "pet-xp-capped") {
|
||||||
|
t.Error("a capped pet's bar is styled like an in-progress one")
|
||||||
|
}
|
||||||
|
// A capped pet must not render an empty track, which reads as the opposite of
|
||||||
|
// what it means.
|
||||||
|
if strings.Contains(body, `class="pet-xp-fill" style="width: 0%"`) {
|
||||||
|
t.Error("a capped pet drew an empty bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And none of it leaks to a visitor: pets are owner-only.
|
||||||
|
if strings.Contains(getWho(t, s, "tok-josie", "").Body.String(), "Mittens") {
|
||||||
|
t.Error("a visitor can see the owner's pets")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,6 +298,7 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
go ws.Start(ctx)
|
go ws.Start(ctx)
|
||||||
ws.StartPushSender(ctx)
|
ws.StartPushSender(ctx)
|
||||||
|
ws.StartAdventureAlerts(ctx)
|
||||||
ws.StartAdventureDigest(ctx)
|
ws.StartAdventureDigest(ctx)
|
||||||
ws.StartTriviaBank(ctx)
|
ws.StartTriviaBank(ctx)
|
||||||
ws.StartTableClock(ctx)
|
ws.StartTableClock(ctx)
|
||||||
@@ -415,8 +416,11 @@ func runLocal(cfg *config.Config) {
|
|||||||
go ws.Start(ctx)
|
go ws.Start(ctx)
|
||||||
// Push only needs the web auth layer, not Matrix, so a web-only deployment
|
// Push only needs the web auth layer, not Matrix, so a web-only deployment
|
||||||
// still runs the digest sender — otherwise subscriptions accumulate but no
|
// still runs the digest sender — otherwise subscriptions accumulate but no
|
||||||
// digest ever fires.
|
// digest ever fires. Adventure alerts are the same: they read facts already in
|
||||||
|
// the database and never touch Matrix, so -local is a full-fidelity way to
|
||||||
|
// exercise them.
|
||||||
ws.StartPushSender(ctx)
|
ws.StartPushSender(ctx)
|
||||||
|
ws.StartAdventureAlerts(ctx)
|
||||||
slog.Info("local: web UI listening", "addr", cfg.Web.ListenAddr)
|
slog.Info("local: web UI listening", "addr", cfg.Web.ListenAddr)
|
||||||
|
|
||||||
storage.RunMaintenance()
|
storage.RunMaintenance()
|
||||||
|
|||||||
Reference in New Issue
Block a user