games: the casino moves out, and gets a clock of its own

The tables were living in the news app's shell: Pete's face in the header
and the footer, the channel nav, search, the reader, the weather canvas,
the PWA. A casino is not a news page with a felt on it.

So it gets its own layout. What carries over is the design language — the
four palette vars, Fredoka/Nunito, the fat rounded cards, the dropped
shadow. What doesn't is every control it has no use for. gamesPage stops
embedding the news pageData, which is what keeps the furniture from
drifting back one convenient field at a time.

It keeps a clock, but tells a different joke with it: Casinopolis by day,
Casino Night Zone from six, palette and felt and the sign over the door all
changing together. The rule lives in roomAt() for the first paint and again
in the browser, so a player abroad gets their own evening.

And the cards are cards now — corner indices in both corners, the bottom
one upside down as printed, pips on the three-by-seven grid every real deck
has used for four hundred years, courts as a letter with the suit over each
shoulder. Driven in a real browser, both rooms, dealt through to a payout.
This commit is contained in:
prosolis
2026-07-13 23:40:33 -07:00
parent c69fbb63db
commit 8ec13eab5b
10 changed files with 538 additions and 52 deletions

View File

@@ -2,6 +2,7 @@ package web
import ( import (
"net/http" "net/http"
"time"
"pete/internal/games/blackjack" "pete/internal/games/blackjack"
"pete/internal/storage" "pete/internal/storage"
@@ -24,17 +25,47 @@ type gameTeaser struct {
} }
var comingSoon = []gameTeaser{ var comingSoon = []gameTeaser{
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and Pete's bots know how to play."}, {Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."}, {Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."}, {Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
{Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before Pete finishes drawing."}, {Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before the gallows finish."},
} }
// betDenominations are the chips you build a bet out of. // betDenominations are the chips you build a bet out of.
var betDenominations = []int64{5, 25, 100, 500} var betDenominations = []int64{5, 25, 100, 500}
// The casino is not called Pete — the news app is Pete's, and this is somewhere
// you go. It has two names, and which one is over the door depends on the hour:
// the lights come on at six and the place turns into Casino Night Zone until
// dawn. Same tables, different room.
type room struct {
Slug string // drives the palette: html[data-room="…"]
Name string // what's on the sign
}
var (
roomDay = room{Slug: "casinopolis", Name: "Casinopolis"}
roomNight = room{Slug: "casino-night", Name: "Casino Night Zone"}
)
// roomAt picks the room for an hour of the day. Daylight is 6am to 6pm; the rest
// belongs to the neon. The browser re-runs this same rule against its own clock
// (games_layout.html), so a player in another timezone sees their own evening —
// this server-side pick only exists so the first paint isn't the wrong room.
func roomAt(hour int) room {
if hour >= 6 && hour < 18 {
return roomDay
}
return roomNight
}
// gamesPage is deliberately *not* pageData. The casino shares Pete's design
// language and nothing else — no channels, no weather, no sources, no push.
// Giving it its own page struct is what stops the news app's furniture drifting
// back in one convenient field at a time.
type gamesPage struct { type gamesPage struct {
pageData Room room
User *SessionUser
Cap int64 Cap int64
RakePct int RakePct int
Soon []gameTeaser Soon []gameTeaser
@@ -58,10 +89,9 @@ func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
} }
func (s *Server) gamesPage(r *http.Request) gamesPage { func (s *Server) gamesPage(r *http.Request) gamesPage {
base := s.base(r)
base.NoIndex = true // the casino is for players, not for search engines
return gamesPage{ return gamesPage{
pageData: base, Room: roomAt(time.Now().Hour()),
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
Cap: storage.MaxChipsOnTable, Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100), RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon, Soon: comingSoon,

View File

@@ -78,23 +78,39 @@ type Server struct {
salt [16]byte salt [16]byte
} }
// New builds the server. Templates are parsed once at startup. Each page // New builds the server. Templates are parsed once at startup. Each page gets
// gets its own template set sharing layout.html + _card.html, which avoids // its own template set sharing a layout, which avoids `main` define collisions
// `main` define collisions between pages. // between pages.
//
// There are two layouts, and they are not the same site. The news pages hang off
// layout.html: Pete's face, the channel nav, search, the reader, the weather
// canvas. The casino hangs off games_layout.html, which shares the *design* —
// the palette vars, the fonts, the fat rounded cards — and none of the
// furniture. games.parodia.dev is its own place, not a news page with a felt on
// it, so it doesn't inherit a single control it has no use for.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) { func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "games", "blackjack"} sets := []struct {
tpls := make(map[string]*template.Template, len(pages)) layout string
for _, p := range pages { shared []string
t, err := template.New("").Funcs(funcs).ParseFS(templateFS, pages []string
"templates/layout.html", }{
"templates/_card.html", {"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
"templates/_chipbar.html", {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack"}},
"templates/"+p+".html", }
) tpls := make(map[string]*template.Template)
if err != nil { for _, set := range sets {
return nil, err for _, p := range set.pages {
files := []string{"templates/" + set.layout + ".html"}
for _, s := range set.shared {
files = append(files, "templates/"+s+".html")
}
files = append(files, "templates/"+p+".html")
t, err := template.New("").Funcs(funcs).ParseFS(templateFS, files...)
if err != nil {
return nil, err
}
tpls[p] = t
} }
tpls[p] = t
} }
infos := make([]SourceInfo, 0, len(sources)) infos := make([]SourceInfo, 0, len(sources))
for _, sc := range sources { for _, sc := range sources {

View File

@@ -572,8 +572,8 @@ html[data-phase="night"] {
two never fight over the same transform. */ two never fight over the same transform. */
.pete-card { .pete-card {
perspective: 700px; perspective: 700px;
height: 6.4rem; height: 7rem;
width: 4.5rem; width: 5rem;
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards; animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
} }
.pete-card-inner { .pete-card-inner {
@@ -597,8 +597,16 @@ html[data-phase="night"] {
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
box-shadow: 0 3px 0 rgba(0,0,0,0.18), 0 6px 14px rgba(0,0,0,0.22); box-shadow: 0 3px 0 rgba(0,0,0,0.18), 0 6px 14px rgba(0,0,0,0.22);
} }
/* A real card face: index in two opposite corners, pips or a court figure in
the middle. Laid out as three rows — corner, body, corner — so the body gets
whatever's left and the indices stay pinned where they're printed. */
.pete-card-front { .pete-card-front {
place-items: center; /* minmax(0,…) on the body row, or the pip grid's min-content height (seven
rows of glyphs) wins the argument and pushes the bottom index off the
card entirely. */
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
padding: 0.2rem 0.28rem;
background: #fdfaf2; background: #fdfaf2;
border: 2px solid rgba(30,20,10,0.12); border: 2px solid rgba(30,20,10,0.12);
color: #2b2118; color: #2b2118;
@@ -606,6 +614,53 @@ html[data-phase="night"] {
line-height: 1; line-height: 1;
} }
.pete-card-front[data-red="1"] { color: #cc3d4a; } .pete-card-front[data-red="1"] { color: #cc3d4a; }
/* The corner index: rank over suit, tight. The bottom-right one is the same
thing upside down, because that's what makes a card readable from a fanned
hand held either way up. */
.pete-card-corner {
display: flex;
flex-direction: column;
align-items: center;
line-height: 0.95;
}
.pete-card-corner-tl { justify-self: start; }
.pete-card-corner-br { justify-self: end; transform: rotate(180deg); }
.pete-card-corner-rank { font-size: 0.9rem; font-weight: 700; }
.pete-card-corner-suit { font-size: 0.72rem; }
/* The pip field: three columns, seven rows — the skeleton every printed deck
shares. blackjack.js places each pip on it. */
.pete-card-pips {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(7, minmax(0, 1fr));
place-items: center;
min-height: 0;
width: 100%;
height: 100%;
padding: 0 0.08rem;
}
.pete-card-pip { font-size: 0.82rem; line-height: 1; }
.pete-card-pip[data-flip="1"] { transform: rotate(180deg); }
/* An ace is one pip, and it's allowed to be enormous. */
.pete-card-pips[data-ace="1"] .pete-card-pip { font-size: 2.1rem; }
/* Court cards: the letter with a suit over each shoulder. */
.pete-card-court {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.05rem;
}
.pete-card-court-letter {
font-size: 1.7rem;
font-weight: 700;
}
.pete-card-court-suit { font-size: 0.8rem; }
.pete-card-court-suit[data-flip="1"] { transform: rotate(180deg); }
.pete-card-back { .pete-card-back {
transform: rotateY(180deg); transform: rotateY(180deg);
background: background:
@@ -614,9 +669,6 @@ html[data-phase="night"] {
border: 2px solid rgba(0,0,0,0.15); border: 2px solid rgba(0,0,0,0.15);
} }
.pete-card-rank { font-size: 1.5rem; font-weight: 700; }
.pete-card-suit { font-size: 1.15rem; margin-top: 0.1rem; }
/* The flight itself: out of the shoe (up and to the right), scaled down and /* The flight itself: out of the shoe (up and to the right), scaled down and
spinning slightly, into place. */ spinning slightly, into place. */
@keyframes pete-deal { @keyframes pete-deal {
@@ -656,3 +708,112 @@ html[data-phase="night"] {
.pete-hand[data-won="1"] .pete-card { animation: none; } .pete-hand[data-won="1"] .pete-card { animation: none; }
} }
} }
/* ----------------------------------------------------------------------------
The rooms.
The casino borrows the news app's design language wholesale — the same four
palette vars, the same Fredoka/Nunito, the same fat rounded cards and dropped
shadows — and none of its furniture.
It borrows the clock too, but tells a different joke with it. News shifts
dawn→day→dusk→night. The casino has two rooms and swaps between them on the
hour, name and all:
casinopolis by day — honey and green felt, lamps low
casino-night by dark — neon blue, pinball purple, lit like a machine
Both are dark rooms of the same shape; only the light and the sign change.
Adding a third is a palette block, a felt, and a name — nothing else.
Written outside @layer on purpose: these must beat the base .shadow-pete.
---------------------------------------------------------------------------- */
html[data-room="casinopolis"] {
--bg: #16211c; /* the room, lights low */
--card: #23342b; /* a table in it */
--ink: #f6ecd8; /* warm cream, not white */
--accent: #f2b53d; /* honey */
--felt-a: #2f7d5b;
--felt-b: #24614a;
--felt-c: #1c4d3c;
--glow: 242,181,61; /* what the lamps throw, as rgb parts */
}
html[data-room="casino-night"] {
--bg: #140f2e; /* the machine's cabinet */
--card: #241a4d;
--ink: #f2ecff;
--accent: #ffcc2f; /* the jackpot bulb */
--felt-a: #4a2fa8; /* the table is a pinball table now */
--felt-b: #351f80;
--felt-c: #241659;
--glow: 126,86,255;
}
/* A lamp over the table and a darker floor at the edges, so the page has a
middle to sit in rather than being flat dark. */
html[data-room] .cs-room {
background:
radial-gradient(80% 55% at 50% -8%, rgba(var(--glow), 0.18), transparent 62%),
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
}
/* Casino Night is lit by bulbs, so it gets a few of them: a slow chase across
the top of the room. Cheap (one gradient, one transform) and it makes the
place feel plugged in rather than painted. */
html[data-room="casino-night"] .cs-room::before {
content: "";
position: absolute;
inset: 0 -50% auto -50%;
height: 0.5rem;
background: repeating-linear-gradient(90deg,
rgba(255,204,47,0.55) 0 0.5rem, transparent 0.5rem 2.25rem);
filter: blur(1px);
animation: cs-bulbs 2.4s linear infinite;
}
@keyframes cs-bulbs {
from { transform: translateX(0); }
to { transform: translateX(2.75rem); }
}
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
invisible down here, so the same shape is cut in black instead. */
html[data-room] .shadow-pete { box-shadow: 0 4px 0 rgba(0,0,0,0.30), 0 10px 26px rgba(0,0,0,0.30); }
html[data-room] .shadow-pete-lg { box-shadow: 0 6px 0 rgba(0,0,0,0.34), 0 20px 40px rgba(0,0,0,0.38); }
html[data-room] .cs-comb svg { filter: drop-shadow(0 3px 0 rgba(0,0,0,0.35)); }
/* The felt takes its colours from the room, so switching rooms reupholsters the
table too. */
html[data-room] .pete-felt {
background:
radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.10), transparent 60%),
linear-gradient(160deg, var(--felt-a) 0%, var(--felt-b) 55%, var(--felt-c) 100%);
}
@media (prefers-reduced-motion: reduce) {
html[data-room="casino-night"] .cs-room::before { animation: none; }
}
/* Three stacks of chips on the lobby's welcome card, in the same denomination
colours you actually bet with. Height comes from --stack, so the row reads as
a real pile someone left on the table rather than an icon of one. */
.cs-stack {
display: block;
width: 2.75rem;
height: calc(0.45rem * var(--stack, 1) + 0.55rem);
border-radius: 999px;
background:
radial-gradient(circle at 50% 30%, rgba(255,255,255,0.30), transparent 60%),
var(--chip, #e07a5f);
border: 2px solid rgba(0,0,0,0.25);
box-shadow:
0 3px 0 rgba(0,0,0,0.30),
inset 0 -0.4rem 0 rgba(0,0,0,0.16),
inset 0 0.35rem 0 rgba(255,255,255,0.14);
}
.cs-stack[data-chip="5"] { --chip: #5aa9e6; }
.cs-stack[data-chip="25"] { --chip: #4caf7d; }
.cs-stack[data-chip="100"] { --chip: #2b2118; }
.cs-stack[data-chip="500"] { --chip: #b079d6; }

File diff suppressed because one or more lines are too long

View File

@@ -73,18 +73,104 @@
return wrap; return wrap;
} }
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". Each entry is [column, row] on a 3×7 grid: three columns, seven rows,
// the same skeleton every printed deck has used for about four hundred years.
// A pip below the halfway line is printed upside down, so it does that here.
//
// Sevens and tens have a pip that sits *between* two rows, so those span a pair
// of rows and centre themselves across the pair.
var PIPS = {
"A": [[2, 4]],
"2": [[2, 1], [2, 7]],
"3": [[2, 1], [2, 4], [2, 7]],
"4": [[1, 1], [3, 1], [1, 7], [3, 7]],
"5": [[1, 1], [3, 1], [2, 4], [1, 7], [3, 7]],
"6": [[1, 1], [3, 1], [1, 4], [3, 4], [1, 7], [3, 7]],
"7": [[1, 1], [3, 1], [2, "2/4"], [1, 4], [3, 4], [1, 7], [3, 7]],
"8": [[1, 1], [3, 1], [2, "2/4"], [1, 4], [3, 4], [2, "4/6"], [1, 7], [3, 7]],
"9": [[1, 1], [3, 1], [1, 3], [3, 3], [2, 4], [1, 5], [3, 5], [1, 7], [3, 7]],
"10": [[1, 1], [3, 1], [2, "2/4"], [1, 3], [3, 3], [1, 5], [3, 5], [2, "4/6"], [1, 7], [3, 7]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
function corner(face, where) {
var el = document.createElement("span");
el.className = "pete-card-corner pete-card-corner-" + where;
var r = document.createElement("span");
r.className = "pete-card-corner-rank";
r.textContent = face.rank;
var s = document.createElement("span");
s.className = "pete-card-corner-suit";
s.textContent = face.suit;
el.appendChild(r);
el.appendChild(s);
return el;
}
function pip(face, col, row) {
var el = document.createElement("span");
el.className = "pete-card-pip";
el.textContent = face.suit;
el.style.gridColumn = String(col);
// A row given as "2/4" spans that pair and centres between them.
el.style.gridRow = String(row);
// The bottom half of a real card is printed the other way up.
var mid = typeof row === "number" ? row > 4 : row === "4/6";
if (mid) el.dataset.flip = "1";
return el;
}
// paintFace draws an actual playing card: index in two opposite corners, and
// either the pips or a court figure in the middle. The dealer's cards and
// yours use the same face, because they came out of the same shoe.
function paintFace(front, face) { function paintFace(front, face) {
front.dataset.red = face.red ? "1" : "0"; front.dataset.red = face.red ? "1" : "0";
front.innerHTML = ""; front.innerHTML = "";
var rank = document.createElement("span");
rank.className = "pete-card-rank"; front.appendChild(corner(face, "tl"));
rank.textContent = face.rank;
var suit = document.createElement("span"); var body = document.createElement("span");
suit.className = "pete-card-suit"; if (COURT[face.rank]) {
suit.textContent = face.suit; // Court cards: the letter, big, with the suit sitting over each shoulder.
front.appendChild(rank); // No portrait — a drawn face would fight the room, and this reads instantly.
front.appendChild(suit); body.className = "pete-card-court";
front.setAttribute("aria-label", face.label); var top = document.createElement("span");
top.className = "pete-card-court-suit";
top.textContent = face.suit;
var letter = document.createElement("span");
letter.className = "pete-card-court-letter";
letter.textContent = face.rank;
var bot = document.createElement("span");
bot.className = "pete-card-court-suit";
bot.dataset.flip = "1";
bot.textContent = face.suit;
body.appendChild(top);
body.appendChild(letter);
body.appendChild(bot);
} else {
body.className = "pete-card-pips";
// An ace gets one big pip in the middle, the way an ace does.
if (face.rank === "A") body.dataset.ace = "1";
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body.appendChild(pip(face, spots[i][0], spots[i][1]));
}
}
front.appendChild(body);
front.appendChild(corner(face, "br"));
front.setAttribute("aria-label", ariaFor(face));
}
// "A♠" is not something a screen reader should be asked to pronounce.
function ariaFor(face) {
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
var suit = SUITS[face.suit];
return suit ? name + " of " + suit : face.label;
} }
// turnOver flips a face-down card up, now that we've been told what it is. // turnOver flips a face-down card up, now that we've been told what it is.

View File

@@ -1,4 +1,4 @@
{{define "title"}}Blackjack · Pete's Casino{{end}} {{define "title"}}Blackjack · {{.Room.Name}}{{end}}
{{define "main"}} {{define "main"}}
<div class="space-y-6" data-blackjack> <div class="space-y-6" data-blackjack>

View File

@@ -1,4 +1,4 @@
{{define "title"}}Pete's Casino · {{.SiteTitle}}{{end}} {{define "title"}}{{.Room.Name}}{{end}}
{{define "main"}} {{define "main"}}
<div class="space-y-8"> <div class="space-y-8">
@@ -6,14 +6,17 @@
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-8 shadow-pete border-2 border-[color:var(--ink)]/10"> <section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-8 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<h1 class="font-display text-3xl sm:text-4xl font-bold">Pete's Casino 🎲</h1> <h1 class="font-display text-3xl sm:text-4xl font-bold">Welcome in 🎲</h1>
<p class="mt-2 text-[color:var(--ink)]/70"> <p class="mt-2 max-w-xl text-[color:var(--ink)]/70">
Real euros, from the same wallet as everything else. Chips are one euro each, Real euros, from the same wallet as everything else. Chips are one euro each,
and whatever you don't spend goes straight back when you cash out. and whatever you don't spend goes straight back when you cash out.
</p> </p>
</div> </div>
<img src="/static/img/pete.avif" alt="" width="72" height="72" <div class="hidden shrink-0 sm:flex items-end gap-1.5" aria-hidden="true">
class="hidden sm:block h-18 w-18 shrink-0 rounded-2xl object-cover shadow-pete border-2 border-[color:var(--ink)]/10"> <span class="cs-stack" data-chip="5"></span>
<span class="cs-stack" data-chip="100" style="--stack:3"></span>
<span class="cs-stack" data-chip="25" style="--stack:2"></span>
</div>
</div> </div>
</section> </section>
@@ -61,7 +64,7 @@
<li>· A chip is a euro. Nothing is worth more here than it is out there.</li> <li>· A chip is a euro. Nothing is worth more here than it is out there.</li>
<li>· You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.</li> <li>· You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.</li>
<li>· The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.</li> <li>· The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.</li>
<li>· Walk away for half an hour and Pete cashes you out on his own, so your euros never sit in limbo.</li> <li>· Walk away for half an hour and the house cashes you out for you, so your euros never sit in limbo.</li>
<li>· Every hand is logged with the seed it was dealt from, so any hand can be dealt again exactly as it fell.</li> <li>· Every hand is logged with the seed it was dealt from, so any hand can be dealt again exactly as it fell.</li>
</ul> </ul>
</section> </section>

View File

@@ -0,0 +1,91 @@
{{define "layout"}}<!doctype html>
<html lang="en" data-room="{{.Room.Slug}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
<meta name="robots" content="noindex">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,3 27,9.5 27,22.5 16,29 5,22.5 5,9.5' fill='%23f2b53d' stroke='%232b2118' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E" type="image/svg+xml">
<meta name="theme-color" content="#17231d">
<script>
// Which room you're standing in, from your clock rather than the server's:
// Casinopolis in daylight, Casino Night Zone once the lights come on at six.
// Same rule as roomAt() in games_pages.go — keep the two in step.
//
// The server already painted its best guess into the markup, so this only
// corrects a player whose evening isn't the server's.
(function () {
var ROOMS = {
"casinopolis": "Casinopolis",
"casino-night": "Casino Night Zone",
};
function roomAt(h) { return (h >= 6 && h < 18) ? "casinopolis" : "casino-night"; }
function apply() {
var slug = roomAt(new Date().getHours());
// The palette can be set from <head> — the sign can't: this script runs
// before the header exists. So set the attribute now (no flash of the
// wrong room) and always re-stamp the name, which is a no-op until the
// header is there to stamp. Bailing out early when the slug already
// matches would leave the server's name under the browser's palette.
document.documentElement.dataset.room = slug;
document.querySelectorAll("[data-room-name]").forEach(function (el) {
el.textContent = ROOMS[slug];
});
}
apply();
document.addEventListener("DOMContentLoaded", apply);
setInterval(apply, 60000);
})();
</script>
</head>
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)]">
<div class="pointer-events-none fixed inset-0 -z-10 cs-room" aria-hidden="true"></div>
<header class="mx-auto max-w-5xl px-4 pt-6 pb-4 sm:pt-10">
<div class="flex items-center justify-between gap-3">
<a href="/games" class="group flex min-w-0 items-center gap-3">
{{template "_comb" .}}
<span class="min-w-0">
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
<span class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
</span>
</a>
{{if .User}}
<a href="/auth/logout" title="Signed in as {{.User.Display}} — sign out"
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-xs font-bold text-[#20180c]">{{.User.Initial}}</span>
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
</a>
{{end}}
</div>
</header>
<main class="mx-auto max-w-5xl px-4 pb-20">
{{block "main" .}}{{end}}
</main>
<footer class="mx-auto max-w-5xl px-4 pb-10 text-center text-xs text-[color:var(--ink)]/40">
Play for what you can lose. Cash out whenever you like.
</footer>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}
{{/* The house mark: a honeycomb cell struck like a chip. Stands in for a logo,
and deliberately isn't a face — nobody is watching you play. */}}
{{define "_comb"}}
<span class="cs-comb grid h-11 w-11 shrink-0 place-items-center transition-transform group-hover:-rotate-6" aria-hidden="true">
<svg viewBox="0 0 32 32" class="h-11 w-11">
<polygon points="16,2.5 27.5,9.25 27.5,22.75 16,29.5 4.5,22.75 4.5,9.25"
fill="var(--accent)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
<polygon points="16,9 21.5,12.25 21.5,18.75 16,22 10.5,18.75 10.5,12.25"
fill="none" stroke="var(--ink)" stroke-width="1.75" stroke-linejoin="round" opacity="0.5"/>
</svg>
</span>
{{end}}

View File

@@ -0,0 +1,80 @@
package web
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// TestDrive is not a test. It is a hand crank: it stands the casino up on a real
// port with one funded player so a browser can be pointed at it. Skipped unless
// PETE_DRIVE is set. Delete it if it ever stops earning its keep.
func TestDrive(t *testing.T) {
if os.Getenv("PETE_DRIVE") == "" {
t.Skip("set PETE_DRIVE=1 to hand-drive the casino")
}
// Built through New() with the tables already open, because the /games routes
// are registered at construction — newCasino() switches them on afterwards,
// which is fine for handler tests and useless for a browser.
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "drive.db")); err != nil {
t.Fatal(err)
}
defer storage.Close()
s, err := New(config.WebConfig{
SiteTitle: "Pete",
ListenAddr: ":0",
BaseURL: "http://127.0.0.1",
Games: config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"},
}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
// New() only mounts the casino when auth is live, and auth means an OIDC
// discovery call. Sign the cookie by hand and mount the routes here instead —
// the handshake is not what a browser is here to look at.
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
fund(t, 5000)
static, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
srv := httptest.NewServer(mux)
defer srv.Close()
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: "reala", Exp: time.Now().Add(time.Hour).Unix(),
})
fmt.Printf("DRIVE_URL=%s\nDRIVE_COOKIE=%s=%s\n", srv.URL, sessionCookie, s.auth.sign(payload))
// Long enough for a browser to buy in, deal a few hands, and be screenshotted.
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if _, err := http.Get(srv.URL + "/healthz"); err != nil {
return
}
}
}

View File

@@ -19,9 +19,20 @@ A multi-session build. This section is the handover; read it before anything els
in flight, so it can't be cleared by firing several requests at once. in flight, so it can't be cleared by firing several requests at once.
- **A house rake**, 5% in blackjack's `DefaultRules`, taken from *winnings only* - **A house rake**, 5% in blackjack's `DefaultRules`, taken from *winnings only*
never the stake. A push returns the bet untouched; a loss is never charged a fee. never the stake. A push returns the bet untouched; a loss is never charged a fee.
- **The site must look like Pete.** Same cute, bubbly interface: `layout.html`, - **The site shares Pete's design, not Pete's shell.** *(Revised 2026-07-13 — this
Fredoka/Nunito, the CSS vars, `rounded-3xl` cards, `shadow-pete`. Not a replaces the earlier "the site must look like Pete", which meant `layout.html`
separate-looking SPA bolted onto the same box. itself.)* The casino is its own place. It takes the design language — Fredoka/
Nunito, the four palette vars, `rounded-3xl`, `shadow-pete`, the bubbly weight of
everything — and takes none of the furniture: no Pete avatar, no channel nav, no
search, no reader, no settings, no weather canvas, no PWA. It has its own layout
(`games_layout.html`), its own header, its own footer, its own scripts. Still not
an SPA; still server-rendered `html/template`.
- **It has two names, on a clock.** Casinopolis by day, Casino Night Zone from six
in the evening — palette, felt and the sign over the door all change together.
This is the news app's phase system pointed at a joke: one `data-room` attribute,
two palette blocks, and a rule shared between `roomAt()` in Go (first paint) and
the same rule in JS (the player's own clock, so a player abroad gets their own
evening).
- **Dealing is animated.** Cards visibly dealt and flipped, chips that move. This is - **Dealing is animated.** Cards visibly dealt and flipped, chips that move. This is
a requirement, not polish to drop when the clock runs out. a requirement, not polish to drop when the clock runs out.
@@ -70,13 +81,21 @@ A multi-session build. This section is the handover; read it before anything els
animation. Driven in a real browser: chips staked before the deal, hole card withheld animation. Driven in a real browser: chips staked before the deal, hole card withheld
from the payload until the reveal, payout settled back into the stack. from the payload until the reveal, payout settled back into the stack.
- **The casino moved out.** Its own layout (`games_layout.html`), parsed as its own
template set alongside the news one; `gamesPage` no longer embeds the news
`pageData`, which is what stops the old furniture drifting back one convenient
field at a time. Two rooms on a clock (above), the felt reupholstered from the
room's vars, and a house mark that is a honeycomb chip rather than a face.
- **The cards are cards.** Corner indices in both corners (the bottom one upside
down, as printed), pips laid out on the three-by-seven grid a real deck uses,
bottom-half pips inverted, courts as a letter with the suit over each shoulder,
and a screen-reader label that says "Queen of hearts" instead of "Q♥".
### Next, in order ### Next, in order
1. **Make the table lively.** The mechanics are all there and the dealing animates, but 1. **Finish making the table lively.** Card faces are done. Still thin: chips never
the presentation is thin: card faces are a rank and a small suit (no pips, no corner physically move, and nothing celebrates. Ideas already sketched: chips that fly to
indices), chips never physically move, and nothing celebrates. This is a *stated a bet spot and back on a win, cards landing with weight, a dealer beat before
requirement*, not polish — see the decisions above. Ideas already sketched: chips that
fly to a bet spot and back on a win, cards landing with weight, a dealer beat before
drawing out, a burst on a natural. drawing out, a burst on a natural.
2. **Deploy.** Add the `games.parodia.dev` redirect URI to the `pete` app in Authentik, 2. **Deploy.** Add the `games.parodia.dev` redirect URI to the `pete` app in Authentik,
point Caddy at the same port, set `[web.games]` + `web.auth.cookie_domain` on the point Caddy at the same port, set `[web.games]` + `web.auth.cookie_domain` on the