Compare commits
2
Commits
5b07199631
...
fcd4368631
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd4368631 | ||
|
|
15e229b6c3 |
@@ -38,6 +38,48 @@ type AdventureConfig struct {
|
||||
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
||||
// "unset" and doesn't get silently rewritten to the default.
|
||||
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
|
||||
|
||||
@@ -76,6 +76,13 @@ const advSource = "Pete"
|
||||
// Matrix; the row exists only so the digest skips it.
|
||||
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
|
||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||
// 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)
|
||||
|
||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||||
// 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
|
||||
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
|
||||
// against the digest up front instead.
|
||||
if f.NoPush {
|
||||
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
|
||||
// Two reasons a dispatch never reaches Matrix, both retired the same way:
|
||||
//
|
||||
// - NoPush: a cold-start backfill, the back-catalogue dump it exists to
|
||||
// prevent.
|
||||
// - A room-silent type: gogobee already announced this exact moment to the
|
||||
// games room in TwinBee's voice, and relaying it is the room hearing one
|
||||
// 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.Write([]byte("ok"))
|
||||
return
|
||||
|
||||
@@ -162,10 +162,12 @@ func TestAdventureDigest(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
// 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",
|
||||
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",
|
||||
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "milestone:b:2", EventType: "milestone", Tier: "bulletin",
|
||||
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",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
||||
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
|
||||
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
||||
// 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()
|
||||
adv config.AdventureConfig // gogobee adventure-news seam
|
||||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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
|
||||
// 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); }
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1.1rem;
|
||||
gap: 0.35rem clamp(0.6rem, 3vw, 1.1rem);
|
||||
min-height: 5rem;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.pete-word {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pete-tile {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 2.9rem;
|
||||
width: 2.2rem;
|
||||
height: clamp(2rem, 8.6vw, 2.9rem);
|
||||
width: clamp(1.5rem, 6.5vw, 2.2rem);
|
||||
border-radius: 0.5rem;
|
||||
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;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
@@ -1153,19 +1159,23 @@ html[data-phase="night"] {
|
||||
.pete-keys { display: grid; gap: 0.35rem; }
|
||||
.pete-key-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; }
|
||||
.pete-key-row[data-digits="1"] .pete-key {
|
||||
height: 2rem;
|
||||
min-width: 1.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 {
|
||||
height: 2.75rem;
|
||||
min-width: 2.2rem;
|
||||
flex: 0 1 2.4rem;
|
||||
min-width: clamp(1.15rem, 5vw, 2.2rem);
|
||||
max-width: 2.4rem;
|
||||
flex: 1 1 clamp(1.15rem, 5vw, 2.4rem);
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(in srgb, var(--ink) 6%, 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 (dealBtn) dealBtn.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) {
|
||||
@@ -1045,13 +1049,19 @@
|
||||
|
||||
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;
|
||||
G.onUpdate(function () {
|
||||
if (resumed) return;
|
||||
resumed = true;
|
||||
G.refresh().then(function (v) {
|
||||
if (v && v.uno) { paint(v.uno); seated(); }
|
||||
else { paint(null); loadLobby(); syncSit(); }
|
||||
else { paint(null); loadLobby(); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user