Compare commits

...

2 Commits

Author SHA1 Message Date
prosolis
6ccd18452c css: rebuild — the stale-roster dimmer had no rule behind it
99574db added the live roster card to the adventure page but shipped the CSS
bundle it was built against, which is generated and checked in. Three utility
classes the new markup uses were never compiled: mb-3, divide-y, and opacity-60.

The last one isn't cosmetic. channel.html toggles opacity-60 from JS to dim the
card when the roster data has gone stale — that dimming IS the staleness signal,
and with no rule behind it a stale roster rendered identically to a live one.
Same failure as the ticker: the thing that's supposed to look wrong looks fine.

Pure `npm run build:css` output, byte-reproducible from the committed templates.
2026-07-13 20:05:16 -07:00
prosolis
e85ebe56f7 news: Pete learns to report a contract killing
gogobee's Mischief Makers ships four new event types — a hit going out, the
target walking away from it, the target not walking away, and the monster
turning up to an empty dungeon. Pete 400s an unknown event_type, gogobee retries
and then parks the bulletin forever, so this deploys first.

The anonymity is the story, not an implementation detail. A contract is
anonymous unless the buyer paid extra to sign it, so the lede reports the money
and pointedly not the name Pete doesn't have. A survival unseals the buyer
whether or not they signed — that exposure is the only brake on casual griefing,
and it's the one beat worth leading with. Being maimed buys you nothing: an
anonymous buyer stays anonymous when the contract lands.
2026-07-13 20:03:28 -07:00
3 changed files with 98 additions and 1 deletions

View File

@@ -194,6 +194,14 @@ func advEventMeta(eventType string) (label, emoji string) {
return "Pulled out", "🎒" return "Pulled out", "🎒"
case "departure": case "departure":
return "Wandered off", "🚪" return "Wandered off", "🚪"
case "mischief_contract":
return "Coin on their head", "😈"
case "mischief_survived":
return "They walked away", "🛡️"
case "mischief_downed":
return "The contract landed", "💀"
case "mischief_fizzled":
return "Nobody home", "🚪"
} }
return "Dispatch", "📣" return "Dispatch", "📣"
} }
@@ -401,6 +409,37 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
// the joke tells itself, and the player it's about may well be reading. // the joke tells itself, and the player it's about may well be reading.
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject), return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
case "mischief_contract":
// Somebody paid to have a monster sent after an adventurer who is out in a
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
// the anonymity is the story: Pete reports the money, not the name he
// doesn't have. Opponent carries the buyer only when it's public.
if f.Opponent != "" {
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
}
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
case "mischief_survived":
// The unseal. A survival is the only thing that names an anonymous buyer,
// and it is the whole brake on casual griefing — so Pete leads with it.
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
case "mischief_downed":
who := "Nobody's saying who paid for it"
if f.Opponent != "" {
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
}
return fmt.Sprintf("%s didn't walk away.", f.Subject),
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
f.Boss, f.Subject, atLevel, who), true
case "mischief_fizzled":
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
f.Subject, f.Subject), true
case "arrival": case "arrival":
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject), return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true

View File

@@ -311,3 +311,61 @@ func TestAdventureDisabled(t *testing.T) {
t.Errorf("disabled: status = %d, want 404", rw.Code) t.Errorf("disabled: status = %d, want 404", rw.Code)
} }
} }
// 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
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
//
// 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.
func TestRenderMischief(t *testing.T) {
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
hl, lede, ok := renderAdventure(anon)
if !ok {
t.Fatal("mischief_contract did not render — ingest would 400")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous contract leaked a buyer name")
}
if !strings.Contains(hl, "€350") {
t.Errorf("contract headline lost the stakes: %q", hl)
}
signed := anon
signed.Opponent = "Brannigan"
hl, _, ok = renderAdventure(signed)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
}
// The unseal: a survival names the buyer whether or not they signed.
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
hl, _, ok = renderAdventure(survived)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
}
// A downed target with an anonymous buyer stays anonymous — being maimed
// doesn't buy you the name.
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
hl, lede, ok = renderAdventure(downed)
if !ok {
t.Fatal("mischief_downed did not render")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous buyer named on a downed contract")
}
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
t.Error("mischief_fizzled did not render")
}
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
t.Errorf("%s has no permalink label", et)
}
}
}

File diff suppressed because one or more lines are too long