Compare commits
2
Commits
5b07199631
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd4368631 | ||
|
|
15e229b6c3 |
@@ -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
|
||||||
|
|||||||
@@ -76,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.
|
||||||
@@ -210,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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(); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user