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:
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/storage"
|
||||
@@ -24,17 +25,47 @@ type gameTeaser struct {
|
||||
}
|
||||
|
||||
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: "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.
|
||||
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 {
|
||||
pageData
|
||||
Room room
|
||||
User *SessionUser
|
||||
Cap int64
|
||||
RakePct int
|
||||
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 {
|
||||
base := s.base(r)
|
||||
base.NoIndex = true // the casino is for players, not for search engines
|
||||
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,
|
||||
RakePct: int(blackjack.DefaultRules().RakePct * 100),
|
||||
Soon: comingSoon,
|
||||
|
||||
@@ -78,23 +78,39 @@ type Server struct {
|
||||
salt [16]byte
|
||||
}
|
||||
|
||||
// New builds the server. Templates are parsed once at startup. Each page
|
||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||
// `main` define collisions between pages.
|
||||
// New builds the server. Templates are parsed once at startup. Each page gets
|
||||
// its own template set sharing a layout, which avoids `main` define collisions
|
||||
// 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) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "games", "blackjack"}
|
||||
tpls := make(map[string]*template.Template, len(pages))
|
||||
for _, p := range pages {
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
||||
"templates/layout.html",
|
||||
"templates/_card.html",
|
||||
"templates/_chipbar.html",
|
||||
"templates/"+p+".html",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
sets := []struct {
|
||||
layout string
|
||||
shared []string
|
||||
pages []string
|
||||
}{
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack"}},
|
||||
}
|
||||
tpls := make(map[string]*template.Template)
|
||||
for _, set := range sets {
|
||||
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))
|
||||
for _, sc := range sources {
|
||||
|
||||
@@ -572,8 +572,8 @@ html[data-phase="night"] {
|
||||
two never fight over the same transform. */
|
||||
.pete-card {
|
||||
perspective: 700px;
|
||||
height: 6.4rem;
|
||||
width: 4.5rem;
|
||||
height: 7rem;
|
||||
width: 5rem;
|
||||
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
}
|
||||
.pete-card-inner {
|
||||
@@ -597,8 +597,16 @@ html[data-phase="night"] {
|
||||
-webkit-backface-visibility: hidden;
|
||||
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 {
|
||||
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;
|
||||
border: 2px solid rgba(30,20,10,0.12);
|
||||
color: #2b2118;
|
||||
@@ -606,6 +614,53 @@ html[data-phase="night"] {
|
||||
line-height: 1;
|
||||
}
|
||||
.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 {
|
||||
transform: rotateY(180deg);
|
||||
background:
|
||||
@@ -614,9 +669,6 @@ html[data-phase="night"] {
|
||||
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
|
||||
spinning slightly, into place. */
|
||||
@keyframes pete-deal {
|
||||
@@ -656,3 +708,112 @@ html[data-phase="night"] {
|
||||
.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
@@ -73,18 +73,104 @@
|
||||
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) {
|
||||
front.dataset.red = face.red ? "1" : "0";
|
||||
front.innerHTML = "";
|
||||
var rank = document.createElement("span");
|
||||
rank.className = "pete-card-rank";
|
||||
rank.textContent = face.rank;
|
||||
var suit = document.createElement("span");
|
||||
suit.className = "pete-card-suit";
|
||||
suit.textContent = face.suit;
|
||||
front.appendChild(rank);
|
||||
front.appendChild(suit);
|
||||
front.setAttribute("aria-label", face.label);
|
||||
|
||||
front.appendChild(corner(face, "tl"));
|
||||
|
||||
var body = document.createElement("span");
|
||||
if (COURT[face.rank]) {
|
||||
// Court cards: the letter, big, with the suit sitting over each shoulder.
|
||||
// No portrait — a drawn face would fight the room, and this reads instantly.
|
||||
body.className = "pete-card-court";
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Blackjack · Pete's Casino{{end}}
|
||||
{{define "title"}}Blackjack · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-blackjack>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Pete's Casino · {{.SiteTitle}}{{end}}
|
||||
{{define "title"}}{{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<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">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold">Pete's Casino 🎲</h1>
|
||||
<p class="mt-2 text-[color:var(--ink)]/70">
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold">Welcome in 🎲</h1>
|
||||
<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,
|
||||
and whatever you don't spend goes straight back when you cash out.
|
||||
</p>
|
||||
</div>
|
||||
<img src="/static/img/pete.avif" alt="" width="72" height="72"
|
||||
class="hidden sm:block h-18 w-18 shrink-0 rounded-2xl object-cover shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<div class="hidden shrink-0 sm:flex items-end gap-1.5" aria-hidden="true">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -61,7 +64,7 @@
|
||||
<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>· 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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
91
internal/web/templates/games_layout.html
Normal file
91
internal/web/templates/games_layout.html
Normal 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}}
|
||||
80
internal/web/zz_drive_test.go
Normal file
80
internal/web/zz_drive_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user