commit 612a4e6227b5c21bc81c5fa857e83885c09f588a Author: Greyson Parrelli Date: Wed Jul 22 23:07:29 2026 -0400 Initial commit. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d451def --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Copy to .env and adjust as needed. Real environment variables win over +# values in this file. + +# Directory holding the SQLite database (created if missing). +DATA_DIR=data + +# HTTP port for the server. +PORT=8080 + +# Built frontend to serve. Leave as-is unless you move the build output. +STATIC_DIR=web/dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d817ecb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/bin/ +/data/ +.env + +# frontend +web/node_modules/ +web/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..75a6d5a --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Super Auto Pets: The Board Game β€” Online + +A web app for playing the Super Auto Pets board game remotely. 1v1 for now; +the engine is built to grow to more players. + +## Stack + +- **Backend**: Go (stdlib-first). Pure game engine in `internal/game`, + WebSocket sync via `coder/websocket`, persistence as JSON blobs in SQLite + (`modernc.org/sqlite`, no cgo). +- **Frontend**: React + Vite + TypeScript in `web/`. DOM/CSS card rendering + and battle animations. +- **Tasks**: [mise](https://mise.jdx.dev) (`mise.toml`). + +## Quick start + +```sh +mise run install-web # once: npm install +mise run serve # build frontend + server, run on :8080 +``` + +Open http://localhost:8080, host a game, and join from another browser (or +incognito window) with the 5-letter code. + +### Development + +```sh +mise run dev # Go server on :8080 + Vite hot reload on :5173 +mise run test # Go tests (game engine) +mise run check # go vet + frontend type-check +``` + +During development open the Vite URL (http://localhost:5173); it proxies +`/api` (including the WebSocket) to the Go server. + +## Configuration + +Set via environment or a `.env` file (see `.env.example`): + +| Variable | Default | Purpose | +| ------------ | ---------- | ------------------------------------ | +| `DATA_DIR` | `data` | Directory holding the SQLite DB | +| `PORT` | `8080` | HTTP port | +| `STATIC_DIR` | `web/dist` | Built frontend to serve | + +## Rules implemented + +Six rounds, each with its own shop tier deck. Per round: + +1. **Shop** β€” each player has 3 coins and players alternate actions, 1 coin + each: **buy** one of 4 face-up cards; **discard** any number of hand cards + (each becomes an 🍎 apple, +1 power food); or **trade in** 3 same-suit pets + to pick 1 of the top 2 cards of the next tier's deck (the other goes under + that deck). Passing forfeits remaining coins. +2. **Cleanup** β€” anyone holding more than 5 pets must discard down to 5 + (discards become apples). +3. **Arrange** β€” players secretly order their decks. Food cards apply to the + next pet after them; trailing foods are wasted. +4. **Battle** β€” automatic. Front pets simultaneously deal their full power to + each other as damage markers; a pet with markers β‰₯ power dies (attack + power never drops while wounded). Last player with pets standing wins the + round: 1 trophy for rounds 1–5, 2 trophies for round 6. Draws award + nothing. + +Most trophies after round 6 wins. Pet effects are not yet implemented (the +card model carries an `effect` field for when they land). + +## Layout + +``` +cmd/server/ entrypoint +internal/game/ rules engine (pure, fully tested) +internal/server/ HTTP + WebSocket rooms +internal/store/ SQLite persistence +internal/env/ .env loading +web/ React frontend +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..6363382 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,61 @@ +// Command server runs the Super Auto Pets board game server: game API, +// WebSocket sync, and the built web frontend. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/greyson/super-auto-pets-board-game/internal/env" + "github.com/greyson/super-auto-pets-board-game/internal/server" + "github.com/greyson/super-auto-pets-board-game/internal/store" +) + +func main() { + if err := env.Load(".env"); err != nil { + slog.Error("failed to read .env", "err", err) + os.Exit(1) + } + dataDir := env.Get("DATA_DIR", "data") + port := env.Get("PORT", "8080") + staticDir := env.Get("STATIC_DIR", "web/dist") + + st, err := store.Open(dataDir) + if err != nil { + slog.Error("failed to open store", "err", err) + os.Exit(1) + } + defer st.Close() + + if _, err := os.Stat(staticDir); err != nil { + slog.Warn("static dir missing; only the API will be served", "dir", staticDir) + staticDir = "" + } + + srv := &http.Server{ + Addr: ":" + port, + Handler: server.New(st, staticDir).Handler(), + } + + go func() { + slog.Info("listening", "addr", "http://localhost:"+port, "dataDir", dataDir) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server failed", "err", err) + os.Exit(1) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + slog.Info("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + srv.Shutdown(ctx) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7e3c1e3 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/greyson/super-auto-pets-board-game + +go 1.25.5 + +require ( + github.com/coder/websocket v1.8.15 + modernc.org/sqlite v1.54.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.46.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e1fd805 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..ca12dec --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,55 @@ +// Package env provides .env file loading and typed lookups using only the +// standard library. +package env + +import ( + "bufio" + "os" + "strings" +) + +// Load reads KEY=VALUE pairs from the given file into the process +// environment. Existing environment variables win over file values. A +// missing file is not an error, so .env stays optional. +func Load(path string) error { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + if _, exists := os.LookupEnv(key); !exists { + os.Setenv(key, value) + } + } + return scanner.Err() +} + +// Get returns the environment variable or a default. +func Get(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/game/battle.go b/internal/game/battle.go new file mode 100644 index 0000000..f8e9a0d --- /dev/null +++ b/internal/game/battle.go @@ -0,0 +1,122 @@ +package game + +// BattleUnit is a pet on the battle line with its attached foods applied. +// Power (attack) is unaffected by damage; a unit dies when Damage >= Power. +type BattleUnit struct { + Card Card `json:"card"` + Foods []Card `json:"foods"` + Bonus int `json:"bonus"` // total power added by foods + Damage int `json:"damage"` // damage markers accumulated this battle +} + +func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus } +func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() } + +// BattleEvent is one step of the battle, in order, for clients to animate. +type BattleEvent struct { + Type string `json:"type"` // "clash" + // Indexes into the initial lineups (per seat) of the two front pets. + Units []int `json:"units"` // one entry per seat + // Damage each front pet has accumulated after the clash, per seat. + Damage []int `json:"damage"` + // Whether each front pet died in the clash, per seat. + Died []bool `json:"died"` +} + +// BattleResult is the full, public record of one round's battle. +type BattleResult struct { + Round int `json:"round"` + Lineups [][]BattleUnit `json:"lineups"` // initial lineups per seat + WastedFood [][]Card `json:"wastedFoods"` // foods with no pet beneath them, per seat + Events []BattleEvent `json:"events"` + WinnerSeat int `json:"winnerSeat"` // -1 = draw + Trophies int `json:"trophies"` // awarded to the winner +} + +// buildLineup walks a deck top-to-bottom, attaching each run of foods to the +// next pet below it. Foods after the last pet affect nothing and are wasted. +func buildLineup(deck []Card) (units []BattleUnit, wasted []Card) { + var pendingFoods []Card + for _, c := range deck { + if c.IsFood() { + pendingFoods = append(pendingFoods, c) + continue + } + u := BattleUnit{Card: c, Foods: pendingFoods} + for _, f := range pendingFoods { + if f.Food == FoodApple { + u.Bonus++ + } + } + pendingFoods = nil + units = append(units, u) + } + return units, pendingFoods +} + +// resolveBattle simulates the battle from the players' arranged decks, +// records the event log, awards trophies, and moves to PhaseBattle. +// +// Combat: the two front pets deal their full Power to each other +// simultaneously as damage markers. A pet with Damage >= Power dies. Since +// remaining health never exceeds Power, at least one pet dies every clash, +// so the loop always terminates (until effects say otherwise). +func (g *Game) resolveBattle() { + res := &BattleResult{ + Round: g.Round, + Lineups: make([][]BattleUnit, len(g.Players)), + WastedFood: make([][]Card, len(g.Players)), + WinnerSeat: -1, + } + live := make([][]BattleUnit, len(g.Players)) // working copies + front := make([]int, len(g.Players)) // index of each seat's front pet + for _, p := range g.Players { + units, wasted := buildLineup(p.Deck) + res.Lineups[p.Seat] = units + res.WastedFood[p.Seat] = wasted + live[p.Seat] = append([]BattleUnit(nil), units...) + } + + // Two-player combat. Effects and >2 player battle formats come later; + // the surrounding state (lineups, events) is already per-seat. + a, b := 0, 1 + for front[a] < len(live[a]) && front[b] < len(live[b]) { + ua, ub := &live[a][front[a]], &live[b][front[b]] + ua.Damage += ub.Power() + ub.Damage += ua.Power() + ev := BattleEvent{ + Type: "clash", + Units: []int{front[a], front[b]}, + Damage: []int{ua.Damage, ub.Damage}, + Died: []bool{!ua.Alive(), !ub.Alive()}, + } + res.Events = append(res.Events, ev) + if !ua.Alive() { + front[a]++ + } + if !ub.Alive() { + front[b]++ + } + } + + trophies := 1 + if g.Round == MaxRounds { + trophies = 2 + } + switch { + case front[a] < len(live[a]): + res.WinnerSeat = a + case front[b] < len(live[b]): + res.WinnerSeat = b + } + if res.WinnerSeat >= 0 { + res.Trophies = trophies + g.Players[res.WinnerSeat].Trophies += trophies + } + + g.Battle = res + g.Phase = PhaseBattle + for _, p := range g.Players { + p.Ready = false + } +} diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go new file mode 100644 index 0000000..01a4539 --- /dev/null +++ b/internal/game/battle_test.go @@ -0,0 +1,156 @@ +package game + +import "testing" + +// testGame builds a started 2-player game without going through the lobby. +func testGame(t *testing.T) (*Game, *Player, *Player) { + t.Helper() + g := New() + p1, err := g.AddPlayer("Alice") + if err != nil { + t.Fatal(err) + } + p2, err := g.AddPlayer("Bob") + if err != nil { + t.Fatal(err) + } + if g.Phase != PhaseShop { + t.Fatalf("expected shop phase after both players join, got %s", g.Phase) + } + return g, p1, p2 +} + +func (g *Game) pet(name string, power int) Card { + return Card{ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: power, Suit: SuitSun} +} + +// forceBattle sets both decks, arranges them in current order, and resolves. +func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult { + t.Helper() + g.Players[0].Deck = d1 + g.Players[1].Deck = d2 + g.Phase = PhaseArrange + g.Players[0].Ready = false + g.Players[1].Ready = false + for _, p := range g.Players { + ids := make([]string, len(p.Deck)) + for i, c := range p.Deck { + ids[i] = c.ID + } + if err := g.SubmitOrder(p.ID, ids); err != nil { + t.Fatal(err) + } + } + if g.Phase != PhaseBattle { + t.Fatalf("expected battle phase, got %s", g.Phase) + } + return g.Battle +} + +// The worked example from the rules: a 3-power pet fights a 5-power pet. The +// 3 dies, the 5 survives with 3 damage markers (2 health left, 5 attack). +// Then a 2-power pet trades with it: both die. +func TestBattleDamageMarkers(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.pet("Three", 3), g.pet("Two", 2)}, + []Card{g.pet("Five", 5)}, + ) + if len(res.Events) != 2 { + t.Fatalf("expected 2 clashes, got %d", len(res.Events)) + } + first := res.Events[0] + if !first.Died[0] || first.Died[1] { + t.Fatalf("first clash: 3-power should die, 5-power should survive: %+v", first) + } + if first.Damage[1] != 3 { + t.Fatalf("5-power pet should carry 3 damage, has %d", first.Damage[1]) + } + second := res.Events[1] + if !second.Died[0] || !second.Died[1] { + t.Fatalf("second clash: both should die (5 attack kills the 2; 3+2 damage kills the 5): %+v", second) + } + if res.WinnerSeat != -1 { + t.Fatalf("battle should be a draw, winner=%d", res.WinnerSeat) + } + if g.Players[0].Trophies != 0 || g.Players[1].Trophies != 0 { + t.Fatal("no trophies on a draw") + } +} + +func TestBattleEqualPowerBothDie(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.pet("A", 4)}, + []Card{g.pet("B", 4)}, + ) + ev := res.Events[0] + if !ev.Died[0] || !ev.Died[1] { + t.Fatalf("equal power pets should both die: %+v", ev) + } + if res.WinnerSeat != -1 { + t.Fatal("expected a draw") + } +} + +func TestBattleWinnerGetsTrophy(t *testing.T) { + g, _, p2 := testGame(t) + res := forceBattle(t, g, + []Card{g.pet("Small", 1)}, + []Card{g.pet("Big", 5)}, + ) + if res.WinnerSeat != 1 { + t.Fatalf("seat 1 should win, got %d", res.WinnerSeat) + } + if res.Trophies != 1 || p2.Trophies != 1 { + t.Fatalf("round 1 win should award 1 trophy, got %d/%d", res.Trophies, p2.Trophies) + } +} + +func TestBattleFinalRoundWorthTwoTrophies(t *testing.T) { + g, _, p2 := testGame(t) + g.Round = MaxRounds + res := forceBattle(t, g, + []Card{g.pet("Small", 1)}, + []Card{g.pet("Big", 5)}, + ) + if res.Trophies != 2 || p2.Trophies != 2 { + t.Fatalf("final round win should award 2 trophies, got %d/%d", res.Trophies, p2.Trophies) + } +} + +// Foods stack onto the next pet beneath them; each apple adds 1 power. +// Trailing foods with no pet under them are wasted. +func TestBattleApplesBuffNextPet(t *testing.T) { + g, _, _ := testGame(t) + apple1, apple2, apple3 := g.newApple(), g.newApple(), g.newApple() + res := forceBattle(t, g, + []Card{apple1, apple2, g.pet("Buffed", 3), apple3}, // 3+2=5 power; apple3 wasted + []Card{g.pet("Enemy", 5)}, + ) + u := res.Lineups[0][0] + if u.Bonus != 2 || u.Power() != 5 { + t.Fatalf("expected 2 apples for 5 total power, got bonus=%d power=%d", u.Bonus, u.Power()) + } + if len(res.WastedFood[0]) != 1 || res.WastedFood[0][0].ID != apple3.ID { + t.Fatalf("trailing apple should be wasted: %+v", res.WastedFood[0]) + } + if res.WinnerSeat != -1 { + t.Fatal("5 vs 5 should draw") + } +} + +// A player with an empty lineup loses immediately with zero clashes. +func TestBattleEmptyLineupLoses(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.pet("Solo", 1)}, + []Card{g.newApple()}, // food only, no pets + ) + if len(res.Events) != 0 { + t.Fatalf("expected no clashes, got %d", len(res.Events)) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win by default, got %d", res.WinnerSeat) + } +} diff --git a/internal/game/cards.go b/internal/game/cards.go new file mode 100644 index 0000000..36ca272 --- /dev/null +++ b/internal/game/cards.go @@ -0,0 +1,120 @@ +package game + +import "fmt" + +// Suit is the trade-in symbol printed on pet cards. Three pets of the same +// suit can be traded for a pick of the next tier's deck. +type Suit string + +const ( + SuitSun Suit = "sun" + SuitMoon Suit = "moon" + SuitStar Suit = "star" + SuitLeaf Suit = "leaf" +) + +var suits = []Suit{SuitSun, SuitMoon, SuitStar, SuitLeaf} + +// CardKind distinguishes pets from foods. +type CardKind string + +const ( + KindPet CardKind = "pet" + KindFood CardKind = "food" +) + +// Food identifiers. Only apples exist for now; more foods come later. +const FoodApple = "apple" + +// Card is a single physical card instance. IDs are unique per game. +type Card struct { + ID string `json:"id"` + Kind CardKind `json:"kind"` + Name string `json:"name"` + Tier int `json:"tier"` + Power int `json:"power,omitempty"` + Suit Suit `json:"suit,omitempty"` + // Effect is display text for the pet's ability. Effects are not yet + // implemented mechanically; the field keeps card data forward-compatible. + Effect string `json:"effect,omitempty"` + Food string `json:"food,omitempty"` +} + +func (c Card) IsPet() bool { return c.Kind == KindPet } +func (c Card) IsFood() bool { return c.Kind == KindFood } + +// petTemplate is the printed definition of a pet; each template appears as +// multiple card copies in its tier's shop deck. +type petTemplate struct { + Name string + Power int +} + +const copiesPerPet = 2 + +// petTiers defines the shop decks. Index 0 is tier 1 (round 1) through +// index 5 for tier 6 (round 6). Suits are assigned round-robin per tier so +// every tier contains every suit. +var petTiers = [MaxRounds][]petTemplate{ + { // Tier 1 + {"Ant", 1}, {"Cricket", 1}, {"Fish", 2}, {"Horse", 1}, + {"Beaver", 2}, {"Otter", 1}, {"Pig", 3}, {"Mosquito", 2}, + }, + { // Tier 2 + {"Crab", 3}, {"Swan", 2}, {"Hedgehog", 3}, {"Peacock", 4}, + {"Flamingo", 3}, {"Rat", 2}, {"Shrimp", 2}, {"Spider", 3}, + }, + { // Tier 3 + {"Dog", 4}, {"Badger", 4}, {"Camel", 3}, {"Giraffe", 3}, + {"Kangaroo", 4}, {"Ox", 5}, {"Rabbit", 3}, {"Sheep", 4}, + }, + { // Tier 4 + {"Skunk", 5}, {"Hippo", 6}, {"Bison", 6}, {"Deer", 4}, + {"Squirrel", 4}, {"Whale", 5}, {"Worm", 4}, {"Penguin", 5}, + }, + { // Tier 5 + {"Scorpion", 5}, {"Rhino", 7}, {"Monkey", 6}, {"Cow", 6}, + {"Seal", 6}, {"Shark", 7}, {"Turkey", 5}, {"Crocodile", 8}, + }, + { // Tier 6 + {"Leopard", 8}, {"Boar", 9}, {"Fly", 7}, {"Gorilla", 9}, + {"Mammoth", 10}, {"Snake", 8}, {"Tiger", 9}, {"Dragon", 10}, + }, +} + +// newCardID mints a unique card ID within the game. +func (g *Game) newCardID() string { + g.NextCardID++ + return fmt.Sprintf("c%d", g.NextCardID) +} + +// buildShopDecks creates all six tier decks (unshuffled). +func (g *Game) buildShopDecks() { + g.ShopDecks = make([][]Card, MaxRounds) + for tierIdx, templates := range petTiers { + deck := make([]Card, 0, len(templates)*copiesPerPet) + for i, t := range templates { + for range copiesPerPet { + deck = append(deck, Card{ + ID: g.newCardID(), + Kind: KindPet, + Name: t.Name, + Tier: tierIdx + 1, + Power: t.Power, + Suit: suits[i%len(suits)], + }) + } + } + g.ShopDecks[tierIdx] = deck + } +} + +// newApple mints an apple food card (from discarding, etc.). +func (g *Game) newApple() Card { + return Card{ + ID: g.newCardID(), + Kind: KindFood, + Name: "Apple", + Food: FoodApple, + } +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..2b69164 --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,533 @@ +package game + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math/big" + "slices" +) + +// Tunable rules. The engine supports any player count >= 2; MinPlayers / +// MaxPlayers gate when a lobby can start (2 for now, more later). +const ( + MaxRounds = 6 + CoinsPerRound = 3 + ShopRowSize = 4 + MaxPets = 5 + TradeInCount = 3 + MinPlayers = 2 + MaxPlayers = 2 +) + +// Phase is the game's top-level state. +type Phase string + +const ( + PhaseLobby Phase = "lobby" // waiting for players + PhaseShop Phase = "shop" // players take turns spending coins + PhaseCleanup Phase = "cleanup" // forced discard down to MaxPets pets + PhaseArrange Phase = "arrange" // players order their decks for battle + PhaseBattle Phase = "battle" // battle resolved; players review the log + PhaseGameOver Phase = "gameover" // all rounds played +) + +// Player holds everything about one seat. All fields are exported so a Game +// serializes to JSON for persistence. +type Player struct { + ID string `json:"id"` + Token string `json:"token"` // secret; never sent in views + Name string `json:"name"` + Seat int `json:"seat"` + Coins int `json:"coins"` + Deck []Card `json:"deck"` + Trophies int `json:"trophies"` + Ready bool `json:"ready"` // arrange submitted / battle acknowledged + Connected bool `json:"connected"` +} + +// PetCount counts pet cards in the player's deck. +func (p *Player) PetCount() int { + n := 0 + for _, c := range p.Deck { + if c.IsPet() { + n++ + } + } + return n +} + +func (p *Player) cardIndex(cardID string) int { + return slices.IndexFunc(p.Deck, func(c Card) bool { return c.ID == cardID }) +} + +// PendingTrade is an in-progress trade-in: the trading player has paid and +// must now pick one of two revealed cards from the next tier's deck. +type PendingTrade struct { + PlayerID string `json:"playerId"` + Tier int `json:"tier"` // 1-based tier the options came from + Options [2]Card `json:"options"` +} + +// Game is the complete authoritative state. It is a pure state machine: no +// goroutines, no clocks, no I/O. Callers are responsible for locking. +type Game struct { + ID string `json:"id"` + Code string `json:"code"` + Phase Phase `json:"phase"` + Round int `json:"round"` // 1-based + Players []*Player `json:"players"` + ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1 + ShopRow []Card `json:"shopRow"` // empty ID = empty slot + Turn int `json:"turn"` // seat with the current shop turn + Pending *PendingTrade `json:"pending,omitempty"` + Battle *BattleResult `json:"battle,omitempty"` // most recent battle + NextCardID int `json:"nextCardId"` + WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie +} + +var ( + ErrNotYourTurn = errors.New("not your turn") + ErrWrongPhase = errors.New("action not allowed in this phase") + ErrNoCoins = errors.New("no coins remaining") + ErrInvalidAction = errors.New("invalid action") +) + +func randomID(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func randomCode() string { + const letters = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" // no easily-confused chars + code := make([]byte, 5) + for i := range code { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + panic(err) + } + code[i] = letters[n.Int64()] + } + return string(code) +} + +func randInt(n int) int { + v, err := rand.Int(rand.Reader, big.NewInt(int64(n))) + if err != nil { + panic(err) + } + return int(v.Int64()) +} + +func shuffle[T any](s []T) { + for i := len(s) - 1; i > 0; i-- { + j := randInt(i + 1) + s[i], s[j] = s[j], s[i] + } +} + +// New creates a game in the lobby phase with its shop decks built and +// shuffled. All later "randomness" is just drawing from these decks, so the +// state is fully deterministic (and serializable) after this point. +func New() *Game { + g := &Game{ + ID: randomID(16), + Code: randomCode(), + Phase: PhaseLobby, + WinnerSeat: -1, + } + g.buildShopDecks() + for i := range g.ShopDecks { + shuffle(g.ShopDecks[i]) + } + return g +} + +// AddPlayer seats a new player during the lobby phase and returns them (with +// their secret token). The game starts automatically once full. +func (g *Game) AddPlayer(name string) (*Player, error) { + if g.Phase != PhaseLobby { + return nil, fmt.Errorf("%w: game already started", ErrWrongPhase) + } + if len(g.Players) >= MaxPlayers { + return nil, errors.New("game is full") + } + if name == "" { + name = fmt.Sprintf("Player %d", len(g.Players)+1) + } + p := &Player{ + ID: randomID(8), + Token: randomID(16), + Name: name, + Seat: len(g.Players), + } + g.Players = append(g.Players, p) + if len(g.Players) == MaxPlayers { + g.start() + } + return p, nil +} + +// PlayerByID returns the player, or nil. +func (g *Game) PlayerByID(id string) *Player { + for _, p := range g.Players { + if p.ID == id { + return p + } + } + return nil +} + +func (g *Game) start() { + g.Round = 1 + g.startShopRound() +} + +// startShopRound resets coins, deals the shop row from this round's tier +// deck, and rotates the starting player. +func (g *Game) startShopRound() { + g.Phase = PhaseShop + g.Pending = nil + for _, p := range g.Players { + p.Coins = CoinsPerRound + p.Ready = false + } + g.ShopRow = make([]Card, ShopRowSize) + for i := range g.ShopRow { + g.ShopRow[i] = g.drawFromTier(g.Round) + } + g.Turn = (g.Round - 1) % len(g.Players) +} + +// drawFromTier pops the top card of the given tier's deck (1-based tier). +// Returns a zero Card if the deck is empty. +func (g *Game) drawFromTier(tier int) Card { + deck := g.ShopDecks[tier-1] + if len(deck) == 0 { + return Card{} + } + top := deck[0] + g.ShopDecks[tier-1] = deck[1:] + return top +} + +// requireShopTurn validates that playerID may act in the shop right now. +func (g *Game) requireShopTurn(playerID string) (*Player, error) { + if g.Phase != PhaseShop { + return nil, ErrWrongPhase + } + p := g.PlayerByID(playerID) + if p == nil { + return nil, errors.New("unknown player") + } + if g.Players[g.Turn].ID != playerID { + return nil, ErrNotYourTurn + } + if g.Pending != nil { + return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction) + } + if p.Coins <= 0 { + return nil, ErrNoCoins + } + return p, nil +} + +// Buy spends one coin to take the card at rowIdx into the player's deck. +// The slot refills from the current round's tier deck. +func (g *Game) Buy(playerID string, rowIdx int) error { + p, err := g.requireShopTurn(playerID) + if err != nil { + return err + } + if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" { + return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction) + } + p.Coins-- + p.Deck = append(p.Deck, g.ShopRow[rowIdx]) + g.ShopRow[rowIdx] = g.drawFromTier(g.Round) + g.advanceShopTurn() + return nil +} + +// Discard spends one coin to convert any number (>=1) of the player's cards +// into that many apples. +func (g *Game) Discard(playerID string, cardIDs []string) error { + p, err := g.requireShopTurn(playerID) + if err != nil { + return err + } + if len(cardIDs) == 0 { + return fmt.Errorf("%w: choose at least one card to discard", ErrInvalidAction) + } + if err := g.convertToApples(p, cardIDs); err != nil { + return err + } + p.Coins-- + g.advanceShopTurn() + return nil +} + +// convertToApples removes the given cards from p's deck and adds one apple +// per removed card. It validates before mutating. +func (g *Game) convertToApples(p *Player, cardIDs []string) error { + if hasDuplicates(cardIDs) { + return fmt.Errorf("%w: duplicate card", ErrInvalidAction) + } + for _, id := range cardIDs { + if p.cardIndex(id) < 0 { + return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) + } + } + for _, id := range cardIDs { + p.Deck = slices.Delete(p.Deck, p.cardIndex(id), p.cardIndex(id)+1) + } + for range cardIDs { + p.Deck = append(p.Deck, g.newApple()) + } + return nil +} + +// TradeStart spends one coin and three same-suit pets from the player's deck +// to reveal the top two cards of the next tier's deck. The player must then +// call TradeChoose before anything else happens. +func (g *Game) TradeStart(playerID string, cardIDs []string) error { + p, err := g.requireShopTurn(playerID) + if err != nil { + return err + } + if g.Round >= MaxRounds { + return fmt.Errorf("%w: no higher tier to trade into", ErrInvalidAction) + } + if len(cardIDs) != TradeInCount || hasDuplicates(cardIDs) { + return fmt.Errorf("%w: trade in exactly %d cards", ErrInvalidAction, TradeInCount) + } + var suit Suit + for i, id := range cardIDs { + idx := p.cardIndex(id) + if idx < 0 { + return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) + } + c := p.Deck[idx] + if !c.IsPet() { + return fmt.Errorf("%w: only pets have suits", ErrInvalidAction) + } + if i == 0 { + suit = c.Suit + } else if c.Suit != suit { + return fmt.Errorf("%w: cards must share a suit", ErrInvalidAction) + } + } + nextTier := g.Round + 1 + if len(g.ShopDecks[nextTier-1]) < 2 { + return fmt.Errorf("%w: next tier deck is exhausted", ErrInvalidAction) + } + // Validated; commit. + for _, id := range cardIDs { + p.Deck = slices.Delete(p.Deck, p.cardIndex(id), p.cardIndex(id)+1) + } + p.Coins-- + g.Pending = &PendingTrade{ + PlayerID: playerID, + Tier: nextTier, + Options: [2]Card{g.drawFromTier(nextTier), g.drawFromTier(nextTier)}, + } + return nil +} + +// TradeChoose resolves a pending trade: pick (0 or 1) joins the player's +// deck, the other goes to the bottom of its tier deck. +func (g *Game) TradeChoose(playerID string, pick int) error { + if g.Phase != PhaseShop || g.Pending == nil || g.Pending.PlayerID != playerID { + return fmt.Errorf("%w: no trade waiting on you", ErrInvalidAction) + } + if pick != 0 && pick != 1 { + return fmt.Errorf("%w: pick 0 or 1", ErrInvalidAction) + } + p := g.PlayerByID(playerID) + chosen, other := g.Pending.Options[pick], g.Pending.Options[1-pick] + p.Deck = append(p.Deck, chosen) + tierIdx := g.Pending.Tier - 1 + g.ShopDecks[tierIdx] = append(g.ShopDecks[tierIdx], other) + g.Pending = nil + g.advanceShopTurn() + return nil +} + +// Pass forfeits the player's remaining coins and ends their shopping. +func (g *Game) Pass(playerID string) error { + p, err := g.requireShopTurn(playerID) + if err != nil { + return err + } + p.Coins = 0 + g.advanceShopTurn() + return nil +} + +// advanceShopTurn hands the turn to the next player who still has coins, or +// moves the game onward when everyone is spent. +func (g *Game) advanceShopTurn() { + for i := 1; i <= len(g.Players); i++ { + seat := (g.Turn + i) % len(g.Players) + if g.Players[seat].Coins > 0 { + g.Turn = seat + return + } + } + g.endShop() +} + +// endShop moves to forced discard if anyone is over the pet limit, otherwise +// straight to arranging. +func (g *Game) endShop() { + over := false + for _, p := range g.Players { + p.Ready = p.PetCount() <= MaxPets + if !p.Ready { + over = true + } + } + if over { + g.Phase = PhaseCleanup + return + } + g.beginArrange() +} + +// CleanupDiscard performs the forced end-of-shop discard: the player must +// convert exactly their excess pets into apples. +func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error { + if g.Phase != PhaseCleanup { + return ErrWrongPhase + } + p := g.PlayerByID(playerID) + if p == nil { + return errors.New("unknown player") + } + excess := p.PetCount() - MaxPets + if excess <= 0 { + return fmt.Errorf("%w: you are not over the pet limit", ErrInvalidAction) + } + if len(cardIDs) != excess { + return fmt.Errorf("%w: discard exactly %d pets", ErrInvalidAction, excess) + } + for _, id := range cardIDs { + idx := p.cardIndex(id) + if idx < 0 || !p.Deck[idx].IsPet() { + return fmt.Errorf("%w: pick pets from your deck", ErrInvalidAction) + } + } + if err := g.convertToApples(p, cardIDs); err != nil { + return err + } + p.Ready = true + if g.allReady() { + g.beginArrange() + } + return nil +} + +func (g *Game) allReady() bool { + for _, p := range g.Players { + if !p.Ready { + return false + } + } + return true +} + +func (g *Game) beginArrange() { + g.Phase = PhaseArrange + for _, p := range g.Players { + p.Ready = false + } +} + +// SubmitOrder records the player's battle ordering (a permutation of their +// deck's card IDs, top of deck first). When everyone has submitted, the +// battle resolves. +func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error { + if g.Phase != PhaseArrange { + return ErrWrongPhase + } + p := g.PlayerByID(playerID) + if p == nil { + return errors.New("unknown player") + } + if p.Ready { + return fmt.Errorf("%w: order already submitted", ErrInvalidAction) + } + if len(orderedIDs) != len(p.Deck) || hasDuplicates(orderedIDs) { + return fmt.Errorf("%w: order must include each of your cards exactly once", ErrInvalidAction) + } + ordered := make([]Card, 0, len(p.Deck)) + for _, id := range orderedIDs { + idx := p.cardIndex(id) + if idx < 0 { + return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) + } + ordered = append(ordered, p.Deck[idx]) + } + p.Deck = ordered + p.Ready = true + if g.allReady() { + g.resolveBattle() + } + return nil +} + +// AcknowledgeBattle marks the player done reviewing the battle. When all +// players acknowledge, the next round starts (or the game ends). +func (g *Game) AcknowledgeBattle(playerID string) error { + if g.Phase != PhaseBattle { + return ErrWrongPhase + } + p := g.PlayerByID(playerID) + if p == nil { + return errors.New("unknown player") + } + p.Ready = true + if !g.allReady() { + return nil + } + if g.Round >= MaxRounds { + g.finish() + return nil + } + g.Round++ + g.startShopRound() + return nil +} + +func (g *Game) finish() { + g.Phase = PhaseGameOver + best, bestSeat, tie := -1, -1, false + for _, p := range g.Players { + switch { + case p.Trophies > best: + best, bestSeat, tie = p.Trophies, p.Seat, false + case p.Trophies == best: + tie = true + } + } + if tie { + g.WinnerSeat = -1 + } else { + g.WinnerSeat = bestSeat + } +} + +func hasDuplicates(ids []string) bool { + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + return true + } + seen[id] = struct{}{} + } + return false +} diff --git a/internal/game/game_test.go b/internal/game/game_test.go new file mode 100644 index 0000000..eec7159 --- /dev/null +++ b/internal/game/game_test.go @@ -0,0 +1,335 @@ +package game + +import ( + "testing" +) + +func deckIDs(p *Player, filter func(Card) bool) []string { + var ids []string + for _, c := range p.Deck { + if filter == nil || filter(c) { + ids = append(ids, c.ID) + } + } + return ids +} + +func current(g *Game) *Player { return g.Players[g.Turn] } + +func TestLobbyStartsWhenFull(t *testing.T) { + g := New() + if g.Phase != PhaseLobby { + t.Fatalf("new game should be in lobby, got %s", g.Phase) + } + if _, err := g.AddPlayer("Alice"); err != nil { + t.Fatal(err) + } + if g.Phase != PhaseLobby { + t.Fatal("game should wait for second player") + } + if _, err := g.AddPlayer("Bob"); err != nil { + t.Fatal(err) + } + if g.Phase != PhaseShop || g.Round != 1 { + t.Fatalf("game should start round 1 shop, got phase=%s round=%d", g.Phase, g.Round) + } + if _, err := g.AddPlayer("Carol"); err == nil { + t.Fatal("third player should be rejected while MaxPlayers=2") + } + for _, p := range g.Players { + if p.Coins != CoinsPerRound { + t.Fatalf("player should start with %d coins", CoinsPerRound) + } + } + if len(g.ShopRow) != ShopRowSize { + t.Fatalf("shop row should have %d cards", ShopRowSize) + } + for _, c := range g.ShopRow { + if c.Tier != 1 { + t.Fatalf("round 1 shop should deal tier 1 cards, got tier %d", c.Tier) + } + } +} + +func TestBuyTakesCardAndRefills(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + want := g.ShopRow[0] + deckBefore := len(g.ShopDecks[0]) + if err := g.Buy(p.ID, 0); err != nil { + t.Fatal(err) + } + if p.Coins != CoinsPerRound-1 { + t.Fatalf("buy should cost 1 coin, coins=%d", p.Coins) + } + if len(p.Deck) != 1 || p.Deck[0].ID != want.ID { + t.Fatalf("bought card should be in deck") + } + if g.ShopRow[0].ID == "" || g.ShopRow[0].ID == want.ID { + t.Fatal("shop slot should refill with a new card") + } + if len(g.ShopDecks[0]) != deckBefore-1 { + t.Fatal("refill should come from the tier deck") + } + if current(g).ID == p.ID { + t.Fatal("turn should pass after an action") + } +} + +func TestTurnValidation(t *testing.T) { + g, _, _ := testGame(t) + other := g.Players[(g.Turn+1)%2] + if err := g.Buy(other.ID, 0); err == nil { + t.Fatal("acting out of turn should fail") + } +} + +func TestDiscardConvertsToApples(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + if err := g.Buy(p.ID, 0); err != nil { + t.Fatal(err) + } + // Skip opponent back to p. + if err := g.Buy(current(g).ID, 0); err != nil { + t.Fatal(err) + } + if err := g.Discard(p.ID, deckIDs(p, Card.IsPet)); err != nil { + t.Fatal(err) + } + if p.Coins != CoinsPerRound-2 { + t.Fatalf("discard should cost 1 coin, coins=%d", p.Coins) + } + if p.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple { + t.Fatalf("discarded pet should become an apple: %+v", p.Deck) + } +} + +func TestTradeInThreeMatchingSuits(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + // Hand p three same-suit tier-1 pets directly. + for range 3 { + c := g.pet("Fodder", 1) + c.Suit = SuitMoon + p.Deck = append(p.Deck, c) + } + nextDeckBefore := len(g.ShopDecks[1]) + if err := g.TradeStart(p.ID, deckIDs(p, nil)); err != nil { + t.Fatal(err) + } + if g.Pending == nil || g.Pending.Tier != 2 { + t.Fatalf("trade should reveal two tier-2 cards: %+v", g.Pending) + } + if len(p.Deck) != 0 { + t.Fatal("traded cards should leave the deck") + } + if current(g).ID != p.ID { + t.Fatal("turn should not pass until the trade is chosen") + } + if err := g.Buy(p.ID, 0); err == nil { + t.Fatal("other actions should be blocked while a trade is pending") + } + chosen := g.Pending.Options[0] + rejected := g.Pending.Options[1] + if err := g.TradeChoose(p.ID, 0); err != nil { + t.Fatal(err) + } + if len(p.Deck) != 1 || p.Deck[0].ID != chosen.ID { + t.Fatal("chosen card should join the deck") + } + deck2 := g.ShopDecks[1] + if len(deck2) != nextDeckBefore-1 { + t.Fatalf("tier 2 deck should be down exactly one card, was %d now %d", nextDeckBefore, len(deck2)) + } + if deck2[len(deck2)-1].ID != rejected.ID { + t.Fatal("rejected card should go to the bottom of the tier deck") + } + if p.Coins != CoinsPerRound-1 { + t.Fatalf("trade should cost 1 coin, coins=%d", p.Coins) + } +} + +func TestTradeRequiresMatchingSuit(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + a, b, c := g.pet("A", 1), g.pet("B", 1), g.pet("C", 1) + a.Suit, b.Suit, c.Suit = SuitSun, SuitSun, SuitMoon + p.Deck = append(p.Deck, a, b, c) + if err := g.TradeStart(p.ID, []string{a.ID, b.ID, c.ID}); err == nil { + t.Fatal("mismatched suits should be rejected") + } + if len(p.Deck) != 3 || p.Coins != CoinsPerRound { + t.Fatal("failed trade must not mutate state") + } +} + +func TestTradeBlockedOnFinalRound(t *testing.T) { + g, _, _ := testGame(t) + g.Round = MaxRounds + p := current(g) + for range 3 { + c := g.pet("Fodder", 1) + c.Suit = SuitMoon + p.Deck = append(p.Deck, c) + } + if err := g.TradeStart(p.ID, deckIDs(p, nil)); err == nil { + t.Fatal("trading should be impossible in the final round") + } +} + +// spendAllCoins has both players pass until the shop ends. +func spendAllCoins(t *testing.T, g *Game) { + t.Helper() + for g.Phase == PhaseShop { + if err := g.Pass(current(g).ID); err != nil { + t.Fatal(err) + } + } +} + +func TestShopEndsIntoArrange(t *testing.T) { + g, _, _ := testGame(t) + spendAllCoins(t, g) + if g.Phase != PhaseArrange { + t.Fatalf("shop should end into arrange when no one is over the pet limit, got %s", g.Phase) + } +} + +func TestForcedDiscardOverPetLimit(t *testing.T) { + g, p1, _ := testGame(t) + for range MaxPets + 2 { + p1.Deck = append(p1.Deck, g.pet("Extra", 1)) + } + spendAllCoins(t, g) + if g.Phase != PhaseCleanup { + t.Fatalf("player with %d pets must be forced to discard, got phase %s", MaxPets+2, g.Phase) + } + // Wrong count rejected. + if err := g.CleanupDiscard(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil { + t.Fatal("must discard exactly the excess") + } + if err := g.CleanupDiscard(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil { + t.Fatal(err) + } + if p1.PetCount() != MaxPets { + t.Fatalf("expected %d pets after cleanup, got %d", MaxPets, p1.PetCount()) + } + apples := 0 + for _, c := range p1.Deck { + if c.Food == FoodApple { + apples++ + } + } + if apples != 2 { + t.Fatalf("discarded pets should become apples, got %d", apples) + } + if g.Phase != PhaseArrange { + t.Fatalf("cleanup should flow into arrange, got %s", g.Phase) + } +} + +func TestArrangeRejectsBadPermutation(t *testing.T) { + g, p1, _ := testGame(t) + p1.Deck = append(p1.Deck, g.pet("A", 1), g.pet("B", 2)) + spendAllCoins(t, g) + if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID}); err == nil { + t.Fatal("partial order should be rejected") + } + if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID, p1.Deck[0].ID}); err == nil { + t.Fatal("duplicate IDs should be rejected") + } + if err := g.SubmitOrder(p1.ID, []string{p1.Deck[1].ID, p1.Deck[0].ID}); err != nil { + t.Fatal(err) + } + if p1.Deck[0].Name != "B" { + t.Fatal("submitted order should be applied to the deck") + } +} + +// Full game: six rounds of pass-through shops and battles, trophy totals, +// and game over with a winner. +func TestFullGameFlow(t *testing.T) { + g, p1, p2 := testGame(t) + p1.Deck = append(p1.Deck, g.pet("Champ", 9)) + p2.Deck = append(p2.Deck, g.pet("Chump", 1)) + for round := 1; round <= MaxRounds; round++ { + if g.Round != round || g.Phase != PhaseShop { + t.Fatalf("expected shop of round %d, got round %d phase %s", round, g.Round, g.Phase) + } + if g.Turn != (round-1)%len(g.Players) { + t.Fatalf("round %d should rotate the starting player, turn=%d", round, g.Turn) + } + for _, c := range g.ShopRow { + if c.ID != "" && c.Tier != round { + t.Fatalf("round %d shop dealt tier %d card", round, c.Tier) + } + } + spendAllCoins(t, g) + for _, p := range g.Players { + if err := g.SubmitOrder(p.ID, deckIDs(p, nil)); err != nil { + t.Fatal(err) + } + } + if g.Phase != PhaseBattle { + t.Fatalf("expected battle after both arrange, got %s", g.Phase) + } + if g.Battle.WinnerSeat != p1.Seat { + t.Fatalf("round %d: seat 0 should win", round) + } + for _, p := range g.Players { + if err := g.AcknowledgeBattle(p.ID); err != nil { + t.Fatal(err) + } + } + } + if g.Phase != PhaseGameOver { + t.Fatalf("game should be over after %d rounds, got %s", MaxRounds, g.Phase) + } + // 1 trophy for rounds 1-5, 2 for round 6. + if p1.Trophies != 7 { + t.Fatalf("winner should have 7 trophies, got %d", p1.Trophies) + } + if g.WinnerSeat != p1.Seat { + t.Fatalf("winner seat should be %d, got %d", p1.Seat, g.WinnerSeat) + } +} + +func TestViewHidesSecrets(t *testing.T) { + g, p1, p2 := testGame(t) + p1.Deck = append(p1.Deck, g.pet("Secret", 3)) + v := g.ViewFor(p2.ID) + if v.YouSeat != p2.Seat { + t.Fatalf("view should identify the viewer's seat") + } + for _, pv := range v.Players { + if pv.Seat == p1.Seat && pv.Deck != nil { + t.Fatal("opponent deck contents must be hidden") + } + if pv.Seat == p1.Seat && pv.DeckSize != 1 { + t.Fatal("opponent deck size should be visible") + } + } + // Pending trade options hidden from the opponent. + for range 3 { + c := g.pet("Fodder", 1) + c.Suit = SuitLeaf + p1.Deck = append(p1.Deck, c) + } + g.Turn = p1.Seat + var fodder []string + for _, c := range p1.Deck { + if c.Name == "Fodder" { + fodder = append(fodder, c.ID) + } + } + if err := g.TradeStart(p1.ID, fodder); err != nil { + t.Fatal(err) + } + if opts := g.ViewFor(p2.ID).Pending.Options; opts[0].ID != "" || opts[1].ID != "" { + t.Fatal("trade options must be hidden from opponents") + } + if opts := g.ViewFor(p1.ID).Pending.Options; opts[0].ID == "" { + t.Fatal("trade options must be visible to the trader") + } +} diff --git a/internal/game/view.go b/internal/game/view.go new file mode 100644 index 0000000..854d008 --- /dev/null +++ b/internal/game/view.go @@ -0,0 +1,85 @@ +package game + +// PlayerView is what any player may know about a seat. Deck contents are +// only included for the viewer's own seat; opponents see counts. +type PlayerView struct { + ID string `json:"id"` + Name string `json:"name"` + Seat int `json:"seat"` + Coins int `json:"coins"` + Trophies int `json:"trophies"` + Ready bool `json:"ready"` + Connected bool `json:"connected"` + DeckSize int `json:"deckSize"` + PetCount int `json:"petCount"` + Deck []Card `json:"deck,omitempty"` // self only +} + +// View is the full game state as seen by one player. +type View struct { + GameID string `json:"gameId"` + Code string `json:"code"` + Phase Phase `json:"phase"` + Round int `json:"round"` + MaxRounds int `json:"maxRounds"` + MaxPets int `json:"maxPets"` + YouSeat int `json:"youSeat"` + Turn int `json:"turn"` + ShopRow []Card `json:"shopRow"` + DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier + Players []PlayerView `json:"players"` + // Pending is included for everyone so opponents see a trade is in + // progress, but the revealed options are only shown to the trader. + Pending *PendingTrade `json:"pending,omitempty"` + Battle *BattleResult `json:"battle,omitempty"` + WinnerSeat int `json:"winnerSeat"` +} + +// ViewFor builds the state visible to the given player. +func (g *Game) ViewFor(playerID string) View { + v := View{ + GameID: g.ID, + Code: g.Code, + Phase: g.Phase, + Round: g.Round, + MaxRounds: MaxRounds, + MaxPets: MaxPets, + YouSeat: -1, + Turn: g.Turn, + ShopRow: g.ShopRow, + WinnerSeat: g.WinnerSeat, + } + for _, deck := range g.ShopDecks { + v.DeckCounts = append(v.DeckCounts, len(deck)) + } + for _, p := range g.Players { + pv := PlayerView{ + ID: p.ID, + Name: p.Name, + Seat: p.Seat, + Coins: p.Coins, + Trophies: p.Trophies, + Ready: p.Ready, + Connected: p.Connected, + DeckSize: len(p.Deck), + PetCount: p.PetCount(), + } + if p.ID == playerID { + v.YouSeat = p.Seat + pv.Deck = p.Deck + } + v.Players = append(v.Players, pv) + } + if g.Pending != nil { + pending := *g.Pending + if pending.PlayerID != playerID { + pending.Options = [2]Card{} // hide the revealed cards + } + v.Pending = &pending + } + // Battle results (lineups, events) are public once resolved. Keep the + // battle around during the following shop phase too, so late joiners / + // reconnects can still see the last result. + v.Battle = g.Battle + return v +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..87e0957 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,186 @@ +// Package server exposes the game engine over HTTP + WebSockets. Each game +// lives in a "room": the authoritative Game state, a lock, and the set of +// connected clients. All mutations happen under the room lock and are +// persisted to the store before being broadcast. +package server + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/greyson/super-auto-pets-board-game/internal/game" + "github.com/greyson/super-auto-pets-board-game/internal/store" +) + +// Server routes HTTP/WS traffic to game rooms. +type Server struct { + store *store.Store + staticDir string + + mu sync.Mutex + rooms map[string]*room // by game ID +} + +// New creates a server. staticDir is the built frontend to serve (may be +// empty or missing during backend-only development). +func New(st *store.Store, staticDir string) *Server { + return &Server{ + store: st, + staticDir: staticDir, + rooms: make(map[string]*room), + } +} + +// Handler builds the full route table. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/games", s.handleCreate) + mux.HandleFunc("POST /api/join", s.handleJoin) + mux.HandleFunc("GET /api/ws", s.handleWS) + mux.HandleFunc("/", s.handleStatic) + return mux +} + +// room is one live game plus its connections. +type room struct { + mu sync.Mutex + game *game.Game + conns map[*client]struct{} +} + +// getRoom returns the room for a game ID, loading it from the store if it +// isn't in memory (e.g. after a server restart). +func (s *Server) getRoom(gameID string) (*room, error) { + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.rooms[gameID]; ok { + return r, nil + } + g, err := s.store.Load(gameID) + if err != nil { + return nil, err + } + r := &room{game: g, conns: make(map[*client]struct{})} + s.rooms[gameID] = r + return r, nil +} + +// getRoomByCode resolves a join code to a room. +func (s *Server) getRoomByCode(code string) (*room, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + s.mu.Lock() + for _, r := range s.rooms { + if r.game.Code == code { + s.mu.Unlock() + return r, nil + } + } + s.mu.Unlock() + g, err := s.store.LoadByCode(code) + if err != nil { + return nil, err + } + return s.getRoom(g.ID) +} + +// persist saves the room's game; callers must hold r.mu. +func (s *Server) persist(r *room) { + if err := s.store.Save(r.game); err != nil { + slog.Error("failed to persist game", "game", r.game.ID, "err", err) + } +} + +type joinResponse struct { + GameID string `json:"gameId"` + Code string `json:"code"` + PlayerID string `json:"playerId"` + Token string `json:"token"` +} + +func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) { + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + httpError(w, http.StatusBadRequest, "invalid JSON body") + return + } + g := game.New() + p, err := g.AddPlayer(strings.TrimSpace(body.Name)) + if err != nil { + httpError(w, http.StatusBadRequest, err.Error()) + return + } + r := &room{game: g, conns: make(map[*client]struct{})} + s.mu.Lock() + s.rooms[g.ID] = r + s.mu.Unlock() + + r.mu.Lock() + s.persist(r) + r.mu.Unlock() + writeJSON(w, joinResponse{GameID: g.ID, Code: g.Code, PlayerID: p.ID, Token: p.Token}) +} + +func (s *Server) handleJoin(w http.ResponseWriter, req *http.Request) { + var body struct { + Code string `json:"code"` + Name string `json:"name"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + httpError(w, http.StatusBadRequest, "invalid JSON body") + return + } + r, err := s.getRoomByCode(body.Code) + if errors.Is(err, store.ErrNotFound) { + httpError(w, http.StatusNotFound, "no game with that code") + return + } + if err != nil { + httpError(w, http.StatusInternalServerError, "failed to load game") + return + } + r.mu.Lock() + p, err := r.game.AddPlayer(strings.TrimSpace(body.Name)) + if err != nil { + r.mu.Unlock() + httpError(w, http.StatusConflict, err.Error()) + return + } + s.persist(r) + resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token} + r.broadcastLocked() + r.mu.Unlock() + writeJSON(w, resp) +} + +// handleStatic serves the built frontend with an SPA fallback to index.html. +func (s *Server) handleStatic(w http.ResponseWriter, req *http.Request) { + if s.staticDir == "" { + httpError(w, http.StatusNotFound, "frontend not built (run: mise run build-web)") + return + } + path := filepath.Join(s.staticDir, filepath.Clean("/"+req.URL.Path)) + if info, err := os.Stat(path); err == nil && !info.IsDir() { + http.ServeFile(w, req, path) + return + } + http.ServeFile(w, req, filepath.Join(s.staticDir, "index.html")) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func httpError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/internal/server/ws.go b/internal/server/ws.go new file mode 100644 index 0000000..3711d28 --- /dev/null +++ b/internal/server/ws.go @@ -0,0 +1,189 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/coder/websocket" + + "github.com/greyson/super-auto-pets-board-game/internal/game" +) + +// client is one WebSocket connection bound to a player in a room. +type client struct { + ws *websocket.Conn + playerID string + send chan []byte +} + +// clientMessage is anything a player can ask the server to do. Type decides +// which other fields matter. +type clientMessage struct { + Type string `json:"type"` + Row int `json:"row"` // buy + Cards []string `json:"cards"` // discard, trade + Pick int `json:"pick"` // tradeChoose + Order []string `json:"order"` // arrange +} + +type serverMessage struct { + Type string `json:"type"` + State *game.View `json:"state,omitempty"` + Error string `json:"error,omitempty"` +} + +// handleWS upgrades the connection and pumps messages until the client +// leaves. Query params: game, player, token. +func (s *Server) handleWS(w http.ResponseWriter, req *http.Request) { + q := req.URL.Query() + gameID, playerID, token := q.Get("game"), q.Get("player"), q.Get("token") + + r, err := s.getRoom(gameID) + if err != nil { + httpError(w, http.StatusNotFound, "game not found") + return + } + r.mu.Lock() + p := r.game.PlayerByID(playerID) + r.mu.Unlock() + if p == nil || p.Token != token { + httpError(w, http.StatusForbidden, "bad player credentials") + return + } + + ws, err := websocket.Accept(w, req, &websocket.AcceptOptions{ + // Same-origin in production; the Vite dev server proxies /api, so + // cross-origin checks buy nothing here yet. + InsecureSkipVerify: true, + }) + if err != nil { + return + } + c := &client{ws: ws, playerID: playerID, send: make(chan []byte, 16)} + + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + go c.writeLoop(ctx) + + r.mu.Lock() + r.conns[c] = struct{}{} + p.Connected = true + r.broadcastLocked() + r.mu.Unlock() + + defer func() { + r.mu.Lock() + delete(r.conns, c) + // Only mark disconnected if no other tab/connection remains. + stillHere := false + for other := range r.conns { + if other.playerID == playerID { + stillHere = true + } + } + if !stillHere { + p.Connected = false + } + r.broadcastLocked() + r.mu.Unlock() + ws.Close(websocket.StatusNormalClosure, "") + }() + + for { + _, data, err := ws.Read(ctx) + if err != nil { + return + } + var msg clientMessage + if err := json.Unmarshal(data, &msg); err != nil { + c.sendError("invalid message") + continue + } + s.apply(r, c, msg) + } +} + +// apply runs one player action against the game under the room lock, then +// persists and broadcasts on success. +func (s *Server) apply(r *room, c *client, msg clientMessage) { + r.mu.Lock() + defer r.mu.Unlock() + + g := r.game + var err error + switch msg.Type { + case "buy": + err = g.Buy(c.playerID, msg.Row) + case "discard": + if g.Phase == game.PhaseCleanup { + err = g.CleanupDiscard(c.playerID, msg.Cards) + } else { + err = g.Discard(c.playerID, msg.Cards) + } + case "trade": + err = g.TradeStart(c.playerID, msg.Cards) + case "tradeChoose": + err = g.TradeChoose(c.playerID, msg.Pick) + case "pass": + err = g.Pass(c.playerID) + case "arrange": + err = g.SubmitOrder(c.playerID, msg.Order) + case "ready": + err = g.AcknowledgeBattle(c.playerID) + default: + err = game.ErrInvalidAction + } + if err != nil { + c.sendError(err.Error()) + return + } + s.persist(r) + r.broadcastLocked() +} + +// broadcastLocked sends each connected client its own view of the game. +// Callers must hold r.mu. +func (r *room) broadcastLocked() { + for c := range r.conns { + view := r.game.ViewFor(c.playerID) + data, err := json.Marshal(serverMessage{Type: "state", State: &view}) + if err != nil { + slog.Error("failed to marshal view", "err", err) + continue + } + c.trySend(data) + } +} + +func (c *client) sendError(msg string) { + data, _ := json.Marshal(serverMessage{Type: "error", Error: msg}) + c.trySend(data) +} + +// trySend queues a message, dropping it if the client's buffer is full (a +// stalled client will resync from the next state broadcast anyway). +func (c *client) trySend(data []byte) { + select { + case c.send <- data: + default: + } +} + +func (c *client) writeLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case data := <-c.send: + writeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + err := c.ws.Write(writeCtx, websocket.MessageText, data) + cancel() + if err != nil { + return + } + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..3ea8c62 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,96 @@ +// Package store persists game state as JSON blobs in SQLite. +package store + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" + + "github.com/greyson/super-auto-pets-board-game/internal/game" +) + +// ErrNotFound is returned when a game doesn't exist. +var ErrNotFound = errors.New("game not found") + +// Store wraps the SQLite database in the data directory. +type Store struct { + db *sql.DB +} + +// Open creates the data directory if needed and opens (or initializes) the +// database inside it. +func Open(dataDir string) (*Store, error) { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", + filepath.Join(dataDir, "games.db")) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // modernc.org/sqlite is happiest with a single writer connection. + db.SetMaxOpenConns(1) + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + `); err != nil { + db.Close() + return nil, fmt.Errorf("init schema: %w", err) + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +// Save upserts the full game state. +func (s *Store) Save(g *game.Game) error { + blob, err := json.Marshal(g) + if err != nil { + return err + } + now := time.Now().UnixMilli() + _, err = s.db.Exec(` + INSERT INTO games (id, code, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at + `, g.ID, g.Code, string(blob), now, now) + return err +} + +// Load fetches a game by ID. +func (s *Store) Load(id string) (*game.Game, error) { + return s.loadWhere(`id = ?`, id) +} + +// LoadByCode fetches a game by its join code. +func (s *Store) LoadByCode(code string) (*game.Game, error) { + return s.loadWhere(`code = ?`, code) +} + +func (s *Store) loadWhere(cond string, arg any) (*game.Game, error) { + var blob string + err := s.db.QueryRow(`SELECT state FROM games WHERE `+cond, arg).Scan(&blob) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + var g game.Game + if err := json.Unmarshal([]byte(blob), &g); err != nil { + return nil, fmt.Errorf("corrupt game state %v: %w", arg, err) + } + return &g, nil +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..5d307d2 --- /dev/null +++ b/mise.toml @@ -0,0 +1,44 @@ +[tools] +go = "latest" +node = "latest" + +[tasks.dev-server] +description = "Run the Go server (serves API + built frontend on :8080)" +run = "go run ./cmd/server" + +[tasks.dev-web] +description = "Run the Vite dev server with hot reload (proxies /api to :8080)" +dir = "web" +run = "npm run dev" + +[tasks.dev] +description = "Run backend and frontend dev servers together" +depends = ["dev-server", "dev-web"] + +[tasks.install-web] +description = "Install frontend dependencies" +dir = "web" +run = "npm install" + +[tasks.build-web] +description = "Build the frontend into web/dist" +dir = "web" +run = "npm run build" + +[tasks.build] +description = "Build everything: frontend + server binary (bin/server)" +depends = ["build-web"] +run = "go build -o bin/server ./cmd/server" + +[tasks.test] +description = "Run all Go tests" +run = "go test ./..." + +[tasks.check] +description = "Vet Go code and type-check the frontend" +run = ["go vet ./...", "cd web && npx tsc -b"] + +[tasks.serve] +description = "Build everything and run the production server" +depends = ["build"] +run = "./bin/server" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..dac1b55 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Super Auto Pets: The Board Game + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..afe41c4 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1849 @@ +{ + "name": "super-auto-pets-board-game-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "super-auto-pets-board-game-web", + "version": "0.1.0", + "dependencies": { + "@fontsource/lilita-one": "^5.1.0", + "@fontsource/nunito": "^5.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/lilita-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/lilita-one/-/lilita-one-5.3.0.tgz", + "integrity": "sha512-bNW3tD+m5pOsAQpW/NvtRbFthFe+B73hDJK2DLLCFCtF+1p+UMBgo7ibxdcb9Sftr06LKAVbt080wiMdSqGbOg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/nunito": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/nunito/-/nunito-5.3.0.tgz", + "integrity": "sha512-vw9TaTQJ/zpEpKrsODuPmOvaVjXgdda6B+xXA4YqjrdeJ62MLgVoDerdRXFQHyBnSCt2yQ2nHKCHPP/Pv7Xq4Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8c371c9 --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "super-auto-pets-board-game-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource/lilita-one": "^5.1.0", + "@fontsource/nunito": "^5.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..56340be --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react' +import { clearSession, loadSession, saveSession } from './api' +import type { Session } from './types' +import { Home } from './components/Home' +import { Table } from './components/Table' + +export function App() { + const [session, setSession] = useState(loadSession) + + if (!session) { + return ( + { + saveSession(s) + setSession(s) + }} + /> + ) + } + return ( + { + clearSession() + setSession(null) + }} + /> + ) +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..53a63f7 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,39 @@ +import type { Session } from './types' + +const SESSION_KEY = 'sapbg-session' + +async function post(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'request failed') + return data as Session +} + +export function createGame(name: string): Promise { + return post('/api/games', { name }) +} + +export function joinGame(code: string, name: string): Promise { + return post('/api/join', { code, name }) +} + +export function loadSession(): Session | null { + try { + const raw = localStorage.getItem(SESSION_KEY) + return raw ? (JSON.parse(raw) as Session) : null + } catch { + return null + } +} + +export function saveSession(s: Session) { + localStorage.setItem(SESSION_KEY, JSON.stringify(s)) +} + +export function clearSession() { + localStorage.removeItem(SESSION_KEY) +} diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx new file mode 100644 index 0000000..8da6bef --- /dev/null +++ b/web/src/components/ArrangePhase.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from 'react' +import type { Card, ClientMessage, GameView, PlayerView } from '../types' +import { CardView } from './CardView' + +interface Props { + view: GameView + you: PlayerView + send: (msg: ClientMessage) => void +} + +// ArrangePhase lets the player order their deck for battle. Leftmost card +// fights first; food cards buff the next pet to their right... i.e. foods +// apply "down" to the next pet later in the order. Drag cards or use the +// arrow buttons to reorder, then lock in. +export function ArrangePhase({ view, you, send }: Props) { + const [order, setOrder] = useState(you.deck ?? []) + const dragIndex = useRef(null) + const locked = you.ready + + // If the server-side deck changes (shouldn't during arrange, but be safe), + // resync. + useEffect(() => { + setOrder(you.deck ?? []) + }, [you.deck]) + + function move(from: number, to: number) { + if (to < 0 || to >= order.length) return + setOrder((o) => { + const next = [...o] + const [c] = next.splice(from, 1) + next.splice(to, 0, c) + return next + }) + } + + // Which pets do the foods land on? Compute buff per card for preview. + const bonuses = new Map() + { + let pendingApples = 0 + for (const c of order) { + if (c.kind === 'food') { + if (c.food === 'apple') pendingApples++ + } else { + bonuses.set(c.id, pendingApples) + pendingApples = 0 + } + } + } + const trailingFoods = (() => { + let n = 0 + for (let i = order.length - 1; i >= 0 && order[i].kind === 'food'; i--) n++ + return n + })() + + const opponent = view.players.find((p) => p.seat !== view.youSeat) + + if (locked) { + return ( +
+

Order locked in βš”οΈ

+

+ Waiting for {opponent?.name ?? 'your opponent'} to arrange their deck… +

+
+ ) + } + + return ( +
+
+ Arrange your battle line +
+

+ The leftmost card fights first. Food cards power up the + next pet to their right. + {trailingFoods > 0 && ( + + {' '} + ⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} at the end + will be wasted! + + )} +

+ +
+
βš”οΈ first
+ {order.map((c, i) => ( +
(dragIndex.current = i)} + onDragOver={(e) => { + e.preventDefault() + if (dragIndex.current !== null && dragIndex.current !== i) { + move(dragIndex.current, i) + dragIndex.current = i + } + }} + onDragEnd={() => (dragIndex.current = null)} + > + +
+ + +
+
+ ))} +
+ +
+ +
+
+ ) +} diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx new file mode 100644 index 0000000..e6a630c --- /dev/null +++ b/web/src/components/BattlePhase.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useState } from 'react' +import type { BattleEvent, BattleUnit, ClientMessage, GameView } from '../types' +import { CardView } from './CardView' + +interface Props { + view: GameView + send: (msg: ClientMessage) => void +} + +const STEP_MS = 1400 + +interface UnitState { + unit: BattleUnit + index: number + damage: number + dead: boolean // died in an earlier step (gone) + dying: boolean // died in the step just played (animate out) +} + +// applyEvents replays the first `step` events onto a seat's lineup. +function applyEvents(view: GameView, seat: number, step: number): UnitState[] { + const battle = view.battle! + const events = battle.events ?? [] + const states: UnitState[] = (battle.lineups[seat] ?? []).map((u, i) => ({ + unit: u, + index: i, + damage: 0, + dead: false, + dying: false, + })) + const seatPos = seat === 0 ? 0 : 1 + for (let k = 0; k < step && k < events.length; k++) { + const ev = events[k] + const s = states[ev.units[seatPos]] + if (!s) continue + s.damage = ev.damage[seatPos] + if (ev.died[seatPos]) { + s.dying = k === step - 1 + s.dead = k < step - 1 + } + } + return states +} + +// BattlePhase plays back the battle log: front pets clash, damage numbers +// fly, the fallen fade out, then the round result lands. +export function BattlePhase({ view, send }: Props) { + const battle = view.battle! + const events = battle.events ?? [] + const [step, setStep] = useState(0) + const [acked, setAcked] = useState(false) + const done = step >= events.length + + useEffect(() => { + if (done) return + const t = window.setTimeout(() => setStep((s) => s + 1), STEP_MS) + return () => window.clearTimeout(t) + }, [step, done]) + + const youSeat = view.youSeat + const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1 + + const yourLine = useMemo( + () => applyEvents(view, youSeat, step), + [view, youSeat, step], + ) + const oppLine = useMemo( + () => applyEvents(view, oppSeat, step), + [view, oppSeat, step], + ) + + const lastEvent = step > 0 ? events[step - 1] : null + + function renderSide(line: UnitState[], side: 'left' | 'right', seat: number) { + const seatPos = seat === 0 ? 0 : 1 + const frontIdx = line.find((s) => !s.dead && !s.dying)?.index + return ( +
+ {line + .filter((s) => !s.dead) + .map((s) => { + const isFront = s.index === frontIdx + const clashing = + !done && lastEvent !== null && lastEvent.units[seatPos] === s.index + return ( +
+ + {clashing && ( +
+ βˆ’{lastEvent!.damage[seatPos] - + prevDamage(events, step - 1, seatPos, s.index)} +
+ )} +
+ ) + })} +
+ ) + } + + const you = view.players[youSeat] + const opp = view.players[oppSeat] + const won = battle.winnerSeat === youSeat + const draw = battle.winnerSeat < 0 + + return ( +
+
+

Battle! Round {battle.round}

+ {!done && ( + + )} +
+ +
+ {you?.name} (you) + VS + {opp?.name} +
+ +
+ {renderSide(yourLine, 'left', youSeat)} +
+ ⚑ +
+ {renderSide(oppLine, 'right', oppSeat)} +
+ + {done && ( +
+
+ {draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'} +
+ {!draw && ( +
+ {view.players[battle.winnerSeat]?.name} wins{' '} + {'πŸ†'.repeat(battle.trophies)} +
+ )} + {draw &&
No trophies awarded
} + {acked ? ( +

Waiting for opponent…

+ ) : ( + + )} +
+ )} +
+ ) +} + +// prevDamage finds the damage a unit had before the given event, so the +// floating number shows just this clash's hit. +function prevDamage( + events: BattleEvent[], + upto: number, + seatPos: number, + unitIndex: number, +): number { + let dmg = 0 + for (let k = 0; k < upto; k++) { + const ev = events[k] + if (ev.units[seatPos] === unitIndex) dmg = ev.damage[seatPos] + } + return dmg +} diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx new file mode 100644 index 0000000..81ebcb0 --- /dev/null +++ b/web/src/components/CardView.tsx @@ -0,0 +1,70 @@ +import type { Card } from '../types' +import { artFor, SUIT_EMOJI } from '../petArt' + +interface Props { + card: Card + size?: 'sm' | 'md' | 'lg' + selected?: boolean + disabled?: boolean + onClick?: () => void + // Battle decorations + bonus?: number + damage?: number + dead?: boolean +} + +// CardView renders one physical card: pets get a power badge and suit stamp, +// foods a description line. Battle mode layers on buffs and damage markers. +export function CardView({ + card, + size = 'md', + selected, + disabled, + onClick, + bonus = 0, + damage = 0, + dead, +}: Props) { + const power = (card.power ?? 0) + bonus + const classes = [ + 'card', + `card-${size}`, + card.kind === 'food' ? 'card-food' : 'card-pet', + selected ? 'is-selected' : '', + disabled ? 'is-disabled' : '', + dead ? 'is-dead' : '', + onClick && !disabled ? 'is-clickable' : '', + ] + .filter(Boolean) + .join(' ') + + return ( +
+
+ T{card.tier || '–'} + {card.suit && ( + + {SUIT_EMOJI[card.suit]} + + )} +
+
+ {artFor(card.name)} +
+
{card.name}
+ {card.kind === 'pet' ? ( +
+ 0 ? 'is-buffed' : ''}`}>{power} + {damage > 0 && !dead && βˆ’{damage}} +
+ ) : ( +
+1 power
+ )} + {selected &&
βœ“
} +
+ ) +} diff --git a/web/src/components/GameOver.tsx b/web/src/components/GameOver.tsx new file mode 100644 index 0000000..4d4afce --- /dev/null +++ b/web/src/components/GameOver.tsx @@ -0,0 +1,35 @@ +import type { GameView } from '../types' + +export function GameOver({ view, onLeave }: { view: GameView; onLeave: () => void }) { + const winner = view.winnerSeat >= 0 ? view.players[view.winnerSeat] : null + const youWon = view.winnerSeat === view.youSeat + + return ( +
+
+ {winner ? (youWon ? 'πŸŽ‰' : 'πŸ’€') : '🀝'} +
+

+ {winner ? (youWon ? 'You win!' : `${winner.name} wins!`) : "It's a tie!"} +

+
+ {[...view.players] + .sort((a, b) => b.trophies - a.trophies) + .map((p) => ( +
+ + {p.name} + {p.seat === view.youSeat ? ' (you)' : ''} + + + {'πŸ†'.repeat(p.trophies) || 'β€”'} {p.trophies} + +
+ ))} +
+ +
+ ) +} diff --git a/web/src/components/Home.tsx b/web/src/components/Home.tsx new file mode 100644 index 0000000..bdafa39 --- /dev/null +++ b/web/src/components/Home.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' +import { createGame, joinGame } from '../api' +import type { Session } from '../types' + +// Home is the create/join screen shown when there's no active session. +export function Home({ onSession }: { onSession: (s: Session) => void }) { + const [name, setName] = useState('') + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function run(fn: () => Promise) { + setBusy(true) + setError(null) + try { + onSession(await fn()) + } catch (e) { + setError(e instanceof Error ? e.message : 'something went wrong') + } finally { + setBusy(false) + } + } + + return ( +
+
+ πŸ·πŸ¦”πŸΆπŸ¦©πŸ‰ +
+

+ Super Auto Pets + The Board Game +

+ +
+ + + + +
+ or join a friend +
+ +
+ setCode(e.target.value.toUpperCase())} + /> + +
+ + {error &&
{error}
} +
+
+ ) +} diff --git a/web/src/components/Lobby.tsx b/web/src/components/Lobby.tsx new file mode 100644 index 0000000..df37075 --- /dev/null +++ b/web/src/components/Lobby.tsx @@ -0,0 +1,20 @@ +import type { GameView } from '../types' + +export function Lobby({ view }: { view: GameView }) { + return ( +
+
+ 🐟 +
+

Waiting for an opponent…

+

Share this code so a friend can join:

+
{view.code}
+ +
+ ) +} diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx new file mode 100644 index 0000000..7648a13 --- /dev/null +++ b/web/src/components/ShopPhase.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from 'react' +import type { Card, ClientMessage, GameView, PlayerView } from '../types' +import { CardView } from './CardView' +import { SUIT_EMOJI } from '../petArt' + +interface Props { + view: GameView + you: PlayerView + send: (msg: ClientMessage) => void +} + +export function ShopPhase({ view, you, send }: Props) { + const [selected, setSelected] = useState([]) + const cleanup = view.phase === 'cleanup' + const myTurn = !cleanup && view.turn === view.youSeat && you.coins > 0 + const deck = you.deck ?? [] + const opponent = view.players.find((p) => p.seat !== view.youSeat) + const pending = view.pending + const myPending = pending?.playerId === you.id + + // Drop selections that no longer exist (bought/traded/discarded cards). + useEffect(() => { + setSelected((sel) => sel.filter((id) => deck.some((c) => c.id === id))) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [you.deck]) + + function toggle(id: string) { + setSelected((sel) => + sel.includes(id) ? sel.filter((s) => s !== id) : [...sel, id], + ) + } + + const selectedCards = selected + .map((id) => deck.find((c) => c.id === id)) + .filter((c): c is Card => !!c) + const sameSuit = + selectedCards.length === 3 && + selectedCards.every((c) => c.suit && c.suit === selectedCards[0].suit) + const excessPets = you.petCount - view.maxPets + const cleanupReady = + cleanup && + excessPets > 0 && + selectedCards.length === excessPets && + selectedCards.every((c) => c.kind === 'pet') + + function act(msg: ClientMessage) { + send(msg) + setSelected([]) + } + + return ( +
+ {/* Status line */} +
+ {cleanup ? ( + excessPets > 0 ? ( + + Too many pets! Discard {excessPets} β€” they become + apples 🍎 + + ) : ( + + Waiting for {opponent?.name ?? 'opponent'} to discard down to{' '} + {view.maxPets} pets… + + ) + ) : pending && !myPending ? ( + + {opponent?.name ?? 'Opponent'} is trading up a tier… + + ) : myTurn ? ( + Your turn β€” spend a coin πŸͺ™ + ) : ( + + {view.players[view.turn]?.name ?? 'Opponent'}’s turn… + + )} +
+ + {/* Shop row */} + {!cleanup && ( +
+
+ Shop Β· Tier {view.round} + Β· {view.deckCounts[view.round - 1]} left in deck +
+
+ {view.shopRow.map((c, i) => + c.id ? ( + act({ type: 'buy', row: i }) : undefined} + /> + ) : ( +
+ ), + )} +
+ {myTurn &&
Tap a card to buy it for 1 πŸͺ™
} +
+ )} + + {/* Your deck */} +
+
+ Your deck + + {' '} + Β· {you.petCount}/{view.maxPets} pets + +
+ {deck.length === 0 ? ( +
No cards yet β€” buy something!
+ ) : ( +
+ {deck.map((c) => ( + toggle(c.id)} + /> + ))} +
+ )} +
+ + {/* Actions */} +
+ {cleanup ? ( + excessPets > 0 && ( + + ) + ) : ( + <> + + + + + )} +
+ + {/* Trade picker */} + {myPending && pending && ( +
+
+

Pick one β€” the other goes under the tier {pending.tier} deck

+
+ {pending.options.map((c, i) => ( + send({ type: 'tradeChoose', pick: i })} + /> + ))} +
+
+
+ )} +
+ ) +} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx new file mode 100644 index 0000000..fea4ea6 --- /dev/null +++ b/web/src/components/Table.tsx @@ -0,0 +1,74 @@ +import { useGame } from '../useGame' +import type { Session } from '../types' +import { Lobby } from './Lobby' +import { ShopPhase } from './ShopPhase' +import { ArrangePhase } from './ArrangePhase' +import { BattlePhase } from './BattlePhase' +import { GameOver } from './GameOver' + +// Table connects to the game and routes to the right phase screen. +export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) { + const { view, error, connected, send } = useGame(session) + + if (!view) { + return ( +
+ {connected ? 'Loading game…' : 'Connecting…'} +
+ ) + } + + const you = view.players[view.youSeat] + const opponents = view.players.filter((p) => p.seat !== view.youSeat) + + return ( +
+
+
+ 🐾 SAP +
+ {view.phase !== 'gameover' && ( +
+ Round {view.round} / {view.maxRounds} +
+ )} +
+ {view.players.map((p) => ( +
+ + {p.name} + πŸ† {p.trophies} + {(view.phase === 'shop' || view.phase === 'cleanup') && ( + πŸͺ™ {p.coins} + )} +
+ ))} +
+
+ {view.code} +
+ +
+ +
+ {view.phase === 'lobby' && } + {(view.phase === 'shop' || view.phase === 'cleanup') && ( + + )} + {view.phase === 'arrange' && } + {view.phase === 'battle' && } + {view.phase === 'gameover' && } +
+ + {opponents.some((p) => !p.connected) && view.phase !== 'lobby' && ( +
An opponent is disconnected…
+ )} + {error &&
{error}
} +
+ ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..33e5f43 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import '@fontsource/lilita-one' +import '@fontsource/nunito/400.css' +import '@fontsource/nunito/700.css' +import '@fontsource/nunito/900.css' +import './styles.css' +import { App } from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/petArt.ts b/web/src/petArt.ts new file mode 100644 index 0000000..be2085e --- /dev/null +++ b/web/src/petArt.ts @@ -0,0 +1,35 @@ +import type { Suit } from './types' + +const PET_EMOJI: Record = { + // Tier 1 + Ant: '🐜', Cricket: 'πŸ¦—', Fish: '🐟', Horse: '🐴', + Beaver: '🦫', Otter: '🦦', Pig: '🐷', Mosquito: '🦟', + // Tier 2 + Crab: 'πŸ¦€', Swan: '🦒', Hedgehog: 'πŸ¦”', Peacock: '🦚', + Flamingo: '🦩', Rat: 'πŸ€', Shrimp: '🦐', Spider: 'πŸ•·οΈ', + // Tier 3 + Dog: '🐢', Badger: '🦑', Camel: '🐫', Giraffe: 'πŸ¦’', + Kangaroo: '🦘', Ox: 'πŸ‚', Rabbit: '🐰', Sheep: 'πŸ‘', + // Tier 4 + Skunk: '🦨', Hippo: 'πŸ¦›', Bison: '🦬', Deer: '🦌', + Squirrel: '🐿️', Whale: '🐳', Worm: 'πŸͺ±', Penguin: '🐧', + // Tier 5 + Scorpion: 'πŸ¦‚', Rhino: '🦏', Monkey: 'πŸ’', Cow: 'πŸ„', + Seal: '🦭', Shark: '🦈', Turkey: 'πŸ¦ƒ', Crocodile: '🐊', + // Tier 6 + Leopard: 'πŸ†', Boar: 'πŸ—', Fly: 'πŸͺ°', Gorilla: '🦍', + Mammoth: '🦣', Snake: '🐍', Tiger: '🐯', Dragon: 'πŸ‰', + // Foods + Apple: '🍎', +} + +export function artFor(name: string): string { + return PET_EMOJI[name] ?? '🐾' +} + +export const SUIT_EMOJI: Record = { + sun: 'β˜€οΈ', + moon: 'πŸŒ™', + star: '⭐', + leaf: 'πŸƒ', +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..f1de73f --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,957 @@ +/* ============================================================ + Super Auto Pets: The Board Game β€” cozy tabletop theme. + Deep felt table, cream cards with cocoa borders, chunky type. + ============================================================ */ + +:root { + --felt-900: #12351f; + --felt-800: #1a4a2b; + --felt-700: #226038; + --wood: #5b3a1e; + --wood-light: #7a5230; + --cream: #fdf3dc; + --cream-dark: #f3e3bd; + --cocoa: #4a2c14; + --ink: #33230f; + --coral: #ff8a3d; + --coral-dark: #e06a1b; + --gold: #ffcf5c; + --red: #d94a38; + --teal: #2f9c8a; + --font-display: 'Lilita One', system-ui, sans-serif; + --font-body: 'Nunito', system-ui, sans-serif; + --card-radius: 12px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100vh; +} + +body { + font-family: var(--font-body); + color: var(--cream); + background-color: var(--felt-900); + background-image: + radial-gradient(ellipse at 50% -20%, rgba(255, 255, 255, 0.09), transparent 60%), + radial-gradient(ellipse at 50% 120%, rgba(0, 0, 0, 0.45), transparent 60%), + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E"), + linear-gradient(160deg, var(--felt-800), var(--felt-900) 70%); +} + +h1, +h2, +h3 { + font-family: var(--font-display); + font-weight: 400; + letter-spacing: 0.02em; +} + +.muted { + color: rgba(253, 243, 220, 0.55); +} + +.centered { + min-height: 60vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + text-align: center; +} + +.hint { + font-size: 0.9rem; + color: rgba(253, 243, 220, 0.65); + text-align: center; +} + +.warn-text { + color: var(--gold); + font-weight: 700; +} + +/* ---------- buttons ---------- */ + +.btn { + font-family: var(--font-display); + font-size: 1rem; + letter-spacing: 0.03em; + color: var(--cream); + background: var(--wood); + border: 3px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; + padding: 10px 18px; + cursor: pointer; + box-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); + transition: transform 80ms ease, box-shadow 80ms ease, filter 120ms ease; +} + +.btn:hover:not(:disabled) { + filter: brightness(1.1); +} + +.btn:active:not(:disabled) { + transform: translateY(3px); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.35); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-primary { + background: linear-gradient(180deg, var(--coral), var(--coral-dark)); + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.btn-secondary { + background: linear-gradient(180deg, var(--teal), #227465); +} + +.btn-ghost { + background: transparent; + border-color: rgba(253, 243, 220, 0.3); + box-shadow: none; +} + +.btn-big { + font-size: 1.25rem; + padding: 14px 28px; +} + +.btn-sm { + font-size: 0.8rem; + padding: 4px 10px; + border-width: 2px; +} + +/* ---------- home ---------- */ + +.home { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + padding: 24px; +} + +.home-pets { + font-size: 2.6rem; + letter-spacing: 0.3em; + animation: float 3s ease-in-out infinite; +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-8px); + } +} + +.home-title { + font-size: clamp(2.2rem, 6vw, 3.6rem); + text-align: center; + color: var(--gold); + text-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; + line-height: 1.05; +} + +.home-subtitle { + font-size: 0.42em; + color: var(--cream); + letter-spacing: 0.25em; + text-transform: uppercase; +} + +.home-card { + background: rgba(0, 0, 0, 0.25); + border: 2px solid rgba(253, 243, 220, 0.15); + border-radius: 20px; + padding: 28px; + display: flex; + flex-direction: column; + gap: 16px; + width: min(380px, 92vw); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + font-weight: 700; + font-size: 0.9rem; +} + +.field input, +.code-input { + font-family: var(--font-body); + font-size: 1.1rem; + font-weight: 700; + color: var(--ink); + background: var(--cream); + border: 3px solid var(--cocoa); + border-radius: 10px; + padding: 10px 12px; + outline: none; + width: 100%; +} + +.code-input { + font-family: var(--font-display); + letter-spacing: 0.35em; + text-transform: uppercase; + text-align: center; +} + +.home-divider { + text-align: center; + font-size: 0.85rem; + color: rgba(253, 243, 220, 0.5); + display: flex; + align-items: center; + gap: 10px; +} + +.home-divider::before, +.home-divider::after { + content: ''; + flex: 1; + height: 1px; + background: rgba(253, 243, 220, 0.2); +} + +.home-join { + display: flex; + gap: 10px; +} + +.home-error { + color: #ffb3a7; + font-weight: 700; + text-align: center; +} + +/* ---------- topbar ---------- */ + +.table { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: linear-gradient(180deg, var(--wood-light), var(--wood)); + border-bottom: 4px solid rgba(0, 0, 0, 0.35); + flex-wrap: wrap; +} + +.topbar-brand { + font-family: var(--font-display); + font-size: 1.2rem; + color: var(--gold); +} + +.topbar-round { + font-size: 0.95rem; +} + +.topbar-players { + display: flex; + gap: 14px; + flex: 1; + flex-wrap: wrap; +} + +.topbar-player { + display: flex; + align-items: center; + gap: 6px; + background: rgba(0, 0, 0, 0.2); + border-radius: 999px; + padding: 4px 12px; + font-size: 0.9rem; +} + +.topbar-player.is-you { + outline: 2px solid var(--gold); +} + +.topbar-name { + font-weight: 900; +} + +.chip { + font-size: 0.85rem; +} + +.conn-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.conn-dot.on { + background: #6fe08b; +} + +.conn-dot.off { + background: var(--red); +} + +.topbar-code { + font-family: var(--font-display); + letter-spacing: 0.25em; + background: rgba(0, 0, 0, 0.25); + border: 2px dashed rgba(253, 243, 220, 0.4); + border-radius: 8px; + padding: 4px 10px; +} + +.table-main { + flex: 1; + padding: 20px 16px 40px; + max-width: 1100px; + width: 100%; + margin: 0 auto; +} + +/* ---------- lobby ---------- */ + +.lobby { + min-height: 60vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + text-align: center; +} + +.lobby-bounce { + font-size: 3rem; + animation: float 2s ease-in-out infinite; +} + +.lobby-code { + font-family: var(--font-display); + font-size: 3rem; + letter-spacing: 0.35em; + color: var(--gold); + background: rgba(0, 0, 0, 0.25); + border: 3px dashed rgba(255, 207, 92, 0.5); + border-radius: 16px; + padding: 12px 28px 12px 40px; +} + +/* ---------- cards ---------- */ + +.card { + position: relative; + width: 108px; + height: 148px; + background: linear-gradient(180deg, var(--cream), var(--cream-dark)); + border: 3px solid var(--cocoa); + border-radius: var(--card-radius); + color: var(--ink); + display: flex; + flex-direction: column; + align-items: center; + padding: 6px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.35); + flex-shrink: 0; + transition: transform 120ms ease, box-shadow 120ms ease; + user-select: none; +} + +.card-lg { + width: 128px; + height: 176px; +} + +.card-sm { + width: 84px; + height: 116px; +} + +.card.is-clickable { + cursor: pointer; +} + +.card.is-clickable:hover { + transform: translateY(-6px) rotate(-1deg); + box-shadow: 0 10px 16px rgba(0, 0, 0, 0.4); +} + +.card.is-selected { + outline: 4px solid var(--gold); + transform: translateY(-6px); +} + +.card.is-disabled { + filter: saturate(0.6) brightness(0.85); +} + +.card.is-dead { + filter: grayscale(1); +} + +.card-top { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.72rem; +} + +.card-tier { + font-weight: 900; + color: rgba(51, 35, 15, 0.55); +} + +.card-suit { + font-size: 0.95rem; +} + +.card-art { + font-size: 3rem; + line-height: 1.25; + filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.25)); +} + +.card-lg .card-art { + font-size: 3.6rem; +} + +.card-sm .card-art { + font-size: 2.2rem; +} + +.card-name { + font-family: var(--font-display); + font-size: 0.85rem; + margin-top: auto; +} + +.card-bottom { + display: flex; + gap: 6px; + align-items: center; + margin-top: 2px; +} + +.card-power { + font-family: var(--font-display); + background: radial-gradient(circle at 35% 30%, #ffb347, var(--coral-dark)); + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + border: 2px solid rgba(0, 0, 0, 0.25); + border-radius: 50%; + width: 30px; + height: 30px; + display: grid; + place-items: center; + font-size: 0.95rem; +} + +.card-power.is-buffed { + background: radial-gradient(circle at 35% 30%, #7be495, #1f9e55); +} + +.card-damage { + font-family: var(--font-display); + color: #fff; + background: var(--red); + border: 2px solid rgba(0, 0, 0, 0.25); + border-radius: 8px; + padding: 1px 6px; + font-size: 0.8rem; +} + +.card-food-text { + font-size: 0.72rem; + font-weight: 700; + color: #7a2e1e; + margin-top: 2px; +} + +.card-food { + background: linear-gradient(180deg, #ffe9e0, #ffd4c2); +} + +.card-check { + position: absolute; + top: -10px; + right: -10px; + background: var(--gold); + color: var(--ink); + font-weight: 900; + border: 2px solid var(--cocoa); + border-radius: 50%; + width: 26px; + height: 26px; + display: grid; + place-items: center; +} + +.card-slot-empty { + width: 128px; + height: 176px; + border: 3px dashed rgba(253, 243, 220, 0.25); + border-radius: var(--card-radius); + flex-shrink: 0; +} + +/* ---------- shop ---------- */ + +.shop { + display: flex; + flex-direction: column; + gap: 22px; +} + +.shop-status { + text-align: center; + font-size: 1.15rem; + font-weight: 900; + min-height: 1.6em; +} + +.status-hot { + color: var(--gold); + animation: pulse 1.6s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.65; + } +} + +.section-label { + font-family: var(--font-display); + font-size: 1rem; + margin-bottom: 10px; + color: rgba(253, 243, 220, 0.9); +} + +.shop-row-wrap, +.deck-wrap { + background: rgba(0, 0, 0, 0.18); + border: 2px solid rgba(253, 243, 220, 0.1); + border-radius: 18px; + padding: 14px 16px; +} + +.shop-row, +.deck-row { + display: flex; + gap: 14px; + flex-wrap: wrap; + justify-content: center; +} + +.deck-empty { + text-align: center; + padding: 20px 0; +} + +.actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +/* ---------- modal ---------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: grid; + place-items: center; + z-index: 50; +} + +.modal { + background: linear-gradient(180deg, var(--felt-700), var(--felt-800)); + border: 3px solid rgba(253, 243, 220, 0.25); + border-radius: 20px; + padding: 24px; + text-align: center; + display: flex; + flex-direction: column; + gap: 18px; + max-width: 92vw; +} + +.modal-cards { + display: flex; + gap: 18px; + justify-content: center; +} + +/* ---------- arrange ---------- */ + +.arrange { + display: flex; + flex-direction: column; + gap: 18px; +} + +.arrange-row { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + justify-content: center; + background: rgba(0, 0, 0, 0.18); + border: 2px solid rgba(253, 243, 220, 0.1); + border-radius: 18px; + padding: 18px 16px; + min-height: 220px; +} + +.arrange-marker { + font-family: var(--font-display); + color: var(--gold); + writing-mode: vertical-rl; + transform: rotate(180deg); + font-size: 0.9rem; + opacity: 0.8; +} + +.arrange-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + cursor: grab; +} + +.arrange-card:active { + cursor: grabbing; +} + +.arrange-arrows { + display: flex; + gap: 4px; +} + +/* ---------- battle ---------- */ + +.battle { + display: flex; + flex-direction: column; + gap: 16px; +} + +.battle-header { + display: flex; + justify-content: center; + align-items: center; + gap: 14px; +} + +.battle-names { + display: flex; + justify-content: center; + gap: 18px; + font-weight: 900; +} + +.battle-vs { + font-family: var(--font-display); + color: var(--gold); +} + +.battlefield { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: + radial-gradient(ellipse at center, rgba(255, 207, 92, 0.07), transparent 65%), + rgba(0, 0, 0, 0.2); + border: 2px solid rgba(253, 243, 220, 0.1); + border-radius: 18px; + padding: 26px 12px; + min-height: 240px; + overflow-x: auto; +} + +.battle-center { + font-size: 1.6rem; + opacity: 0.5; + flex-shrink: 0; +} + +.battle-side { + display: flex; + gap: 10px; + flex: 1; + min-width: 0; +} + +/* Front units meet in the middle: left side is reversed so index 0 sits + next to the center. */ +.battle-side-left { + flex-direction: row-reverse; +} + +.battle-side-right { + flex-direction: row; +} + +.battle-unit { + position: relative; + flex-shrink: 0; +} + +.battle-unit .card { + width: 96px; + height: 132px; +} + +.battle-unit.is-front .card { + outline: 3px solid rgba(255, 207, 92, 0.6); +} + +@keyframes clash-left { + 0% { + transform: translateX(0); + } + 35% { + transform: translateX(26px) rotate(4deg); + } + 60% { + transform: translateX(-6px); + } + 100% { + transform: translateX(0); + } +} + +@keyframes clash-right { + 0% { + transform: translateX(0); + } + 35% { + transform: translateX(-26px) rotate(-4deg); + } + 60% { + transform: translateX(6px); + } + 100% { + transform: translateX(0); + } +} + +.battle-unit.clash-left { + animation: clash-left 500ms ease; +} + +.battle-unit.clash-right { + animation: clash-right 500ms ease; +} + +@keyframes dying { + to { + opacity: 0; + transform: translateY(30px) rotate(12deg) scale(0.85); + } +} + +.battle-unit.unit-dying { + animation: dying 700ms ease 500ms forwards; +} + +@keyframes damage-pop { + 0% { + opacity: 0; + transform: translate(-50%, 0) scale(0.6); + } + 25% { + opacity: 1; + transform: translate(-50%, -16px) scale(1.15); + } + 100% { + opacity: 0; + transform: translate(-50%, -44px) scale(1); + } +} + +.damage-pop { + position: absolute; + top: 0; + left: 50%; + font-family: var(--font-display); + font-size: 1.4rem; + color: #ff6b52; + text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5); + animation: damage-pop 1100ms ease 250ms forwards; + opacity: 0; + pointer-events: none; + z-index: 5; +} + +.battle-result { + text-align: center; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + animation: result-in 400ms ease; +} + +@keyframes result-in { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.battle-result-title { + font-family: var(--font-display); + font-size: 2.4rem; +} + +.battle-result.is-win .battle-result-title { + color: var(--gold); +} + +.battle-result.is-loss .battle-result-title { + color: #ff8f7a; +} + +.battle-result.is-draw .battle-result-title { + color: var(--teal); +} + +.battle-result-sub { + font-size: 1.1rem; + font-weight: 700; +} + +/* ---------- game over ---------- */ + +.gameover { + min-height: 60vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + text-align: center; +} + +.gameover-emoji { + font-size: 4rem; +} + +.gameover-title { + font-size: 3rem; + color: var(--gold); + text-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); +} + +.gameover-scores { + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(0, 0, 0, 0.22); + border-radius: 16px; + padding: 18px 26px; + min-width: min(360px, 90vw); +} + +.score-line { + display: flex; + justify-content: space-between; + gap: 24px; + font-size: 1.1rem; +} + +.score-line.is-you .score-name { + color: var(--gold); + font-weight: 900; +} + +/* ---------- toasts & banners ---------- */ + +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--red); + color: #fff; + font-weight: 700; + border-radius: 12px; + padding: 10px 20px; + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.4); + animation: result-in 200ms ease; + z-index: 100; +} + +.banner { + position: fixed; + top: 64px; + left: 50%; + transform: translateX(-50%); + border-radius: 10px; + padding: 6px 16px; + font-size: 0.9rem; + font-weight: 700; + z-index: 90; +} + +.banner-warn { + background: rgba(217, 74, 56, 0.9); +} + +@media (max-width: 600px) { + .card { + width: 92px; + height: 128px; + } + .card-lg { + width: 104px; + height: 146px; + } + .battle-unit .card { + width: 78px; + height: 110px; + } +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..ccc191a --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,91 @@ +// Mirrors of the Go view types (internal/game/view.go). + +export type Suit = 'sun' | 'moon' | 'star' | 'leaf' +export type CardKind = 'pet' | 'food' +export type Phase = 'lobby' | 'shop' | 'cleanup' | 'arrange' | 'battle' | 'gameover' + +export interface Card { + id: string + kind: CardKind + name: string + tier: number + power?: number + suit?: Suit + effect?: string + food?: string +} + +export interface PlayerView { + id: string + name: string + seat: number + coins: number + trophies: number + ready: boolean + connected: boolean + deckSize: number + petCount: number + deck?: Card[] +} + +export interface PendingTrade { + playerId: string + tier: number + options: [Card, Card] +} + +export interface BattleUnit { + card: Card + foods: Card[] | null + bonus: number + damage: number +} + +export interface BattleEvent { + type: 'clash' + units: number[] + damage: number[] + died: boolean[] +} + +export interface BattleResult { + round: number + lineups: BattleUnit[][] + wastedFoods: (Card[] | null)[] + events: BattleEvent[] | null + winnerSeat: number + trophies: number +} + +export interface GameView { + gameId: string + code: string + phase: Phase + round: number + maxRounds: number + maxPets: number + youSeat: number + turn: number + shopRow: Card[] + deckCounts: number[] + players: PlayerView[] + pending?: PendingTrade + battle?: BattleResult + winnerSeat: number +} + +export type ClientMessage = + | { type: 'buy'; row: number } + | { type: 'discard'; cards: string[] } + | { type: 'trade'; cards: string[] } + | { type: 'tradeChoose'; pick: number } + | { type: 'pass' } + | { type: 'arrange'; order: string[] } + | { type: 'ready' } + +export interface Session { + gameId: string + code: string + playerId: string + token: string +} diff --git a/web/src/useGame.ts b/web/src/useGame.ts new file mode 100644 index 0000000..d34b910 --- /dev/null +++ b/web/src/useGame.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ClientMessage, GameView, Session } from './types' + +interface ServerMessage { + type: 'state' | 'error' + state?: GameView + error?: string +} + +// useGame owns the WebSocket for a session: it keeps the latest server view, +// reconnects with backoff, and exposes send() for actions. Server-rejected +// actions surface as a transient `error`. +export function useGame(session: Session) { + const [view, setView] = useState(null) + const [error, setError] = useState(null) + const [connected, setConnected] = useState(false) + const wsRef = useRef(null) + const errorTimer = useRef(undefined) + + useEffect(() => { + let ws: WebSocket + let closed = false + let retryDelay = 500 + let retryTimer: number | undefined + + function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + const params = new URLSearchParams({ + game: session.gameId, + player: session.playerId, + token: session.token, + }) + ws = new WebSocket(`${proto}://${location.host}/api/ws?${params}`) + wsRef.current = ws + ws.onopen = () => { + retryDelay = 500 + setConnected(true) + } + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data) as ServerMessage + if (msg.type === 'state' && msg.state) setView(msg.state) + if (msg.type === 'error' && msg.error) showError(msg.error) + } + ws.onclose = () => { + setConnected(false) + if (closed) return + retryTimer = window.setTimeout(connect, retryDelay) + retryDelay = Math.min(retryDelay * 2, 8000) + } + } + + function showError(msg: string) { + setError(msg) + window.clearTimeout(errorTimer.current) + errorTimer.current = window.setTimeout(() => setError(null), 3500) + } + + connect() + return () => { + closed = true + window.clearTimeout(retryTimer) + window.clearTimeout(errorTimer.current) + ws.close() + } + }, [session.gameId, session.playerId, session.token]) + + const send = useCallback((msg: ClientMessage) => { + wsRef.current?.send(JSON.stringify(msg)) + }, []) + + return { view, error, connected, send } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..c351008 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..b8ead30 --- /dev/null +++ b/web/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useGame.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..71bbed8 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// The Go server owns /api (including the WebSocket); Vite proxies to it in +// dev so the frontend can be served with hot reload on :5173. +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + ws: true, + }, + }, + }, +})