refactor: extract shared player, session, and chat packages #5
@@ -1,45 +0,0 @@
|
|||||||
Create a new Gitea release for this project using semantic versioning.
|
|
||||||
|
|
||||||
## Current state
|
|
||||||
|
|
||||||
Fetch tags and find the latest version:
|
|
||||||
|
|
||||||
```
|
|
||||||
!git fetch --tags && git tag --sort=-v:refname | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Commits since the last release (if no tags exist, this shows all commits):
|
|
||||||
|
|
||||||
```
|
|
||||||
!git log $(git describe --tags --abbrev=0 2>/dev/null && echo "$(git describe --tags --abbrev=0)..HEAD" || echo "") --oneline
|
|
||||||
```
|
|
||||||
|
|
||||||
## Instructions
|
|
||||||
|
|
||||||
1. **Determine current version** from the tag output above. If no `vX.Y.Z` tags exist, treat current version as `v0.0.0`.
|
|
||||||
|
|
||||||
2. **Analyze commits** using conventional commit prefixes to pick the semver bump:
|
|
||||||
- Breaking changes (`!` after type, or `BREAKING CHANGE` in body) → **major** bump
|
|
||||||
- `feat:` → **minor** bump
|
|
||||||
- `fix:`, `chore:`, `deps:`, `revert:`, and everything else → **patch** bump
|
|
||||||
- Use the **highest** applicable bump level across all commits
|
|
||||||
|
|
||||||
3. **Generate release notes** — group commits into sections:
|
|
||||||
- **Features** — `feat:` commits
|
|
||||||
- **Fixes** — `fix:` commits
|
|
||||||
- **Other** — everything else (`chore:`, `deps:`, `revert:`, etc.)
|
|
||||||
- Omit empty sections. Each commit is a bullet point with its short description (strip the prefix).
|
|
||||||
|
|
||||||
4. **Present for approval** — show the user:
|
|
||||||
- Current version → proposed new version
|
|
||||||
- The full release notes
|
|
||||||
- The exact `tea` command that will run
|
|
||||||
- Ask the user to confirm before proceeding
|
|
||||||
|
|
||||||
5. **Create the release** — on user approval, run:
|
|
||||||
```
|
|
||||||
tea releases create --login gitea --repo ryan/c4 --tag <version> --target main -t "<version>" -n "<release notes>"
|
|
||||||
```
|
|
||||||
Do NOT create a local git tag — Gitea creates it server-side.
|
|
||||||
|
|
||||||
6. **Verify** — run `tea releases ls --login gitea --repo ryan/c4` to confirm the release was created.
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
c4
|
games
|
||||||
c4.db
|
games.db
|
||||||
data/
|
data/
|
||||||
deploy/
|
deploy/
|
||||||
.env
|
.env
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
assets/css/output.css
|
assets/css/output.css
|
||||||
c4-deploy-*.tar.gz
|
games-deploy-*.tar.gz
|
||||||
c4-deploy-*_b64*.txt
|
games-deploy-*_b64*.txt
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO.
|
# Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO.
|
||||||
# LOG_LEVEL=DEBUG
|
# LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
# SQLite database path. Defaults to data/c4.db.
|
# SQLite database path. Defaults to data/games.db.
|
||||||
# DB_PATH=data/c4.db
|
# DB_PATH=data/games.db
|
||||||
|
|
||||||
# Application URL for invite links. Defaults to https://games.adriatica.io.
|
# Application URL for invite links. Defaults to https://games.adriatica.io.
|
||||||
# APP_URL=http://localhost:7331
|
# APP_URL=http://localhost:7331
|
||||||
@@ -12,5 +12,5 @@
|
|||||||
|
|
||||||
# Goose CLI migration config (only needed for running goose manually)
|
# Goose CLI migration config (only needed for running goose manually)
|
||||||
GOOSE_DRIVER=sqlite3
|
GOOSE_DRIVER=sqlite3
|
||||||
GOOSE_DBSTRING=data/c4.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)
|
GOOSE_DBSTRING=data/games.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)
|
||||||
GOOSE_MIGRATION_DIR=db/migrations
|
GOOSE_MIGRATION_DIR=db/migrations
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DEPLOY_DIR: /home/ryan/c4
|
DEPLOY_DIR: /home/ryan/games
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ formatters:
|
|||||||
settings:
|
settings:
|
||||||
goimports:
|
goimports:
|
||||||
local-prefixes:
|
local-prefixes:
|
||||||
- github.com/ryanhamamura/c4
|
- github.com/ryanhamamura/games
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-rules:
|
exclude-rules:
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
CGO_ENABLED=0 go build -ldflags="-s" -o /bin/c4 .
|
CGO_ENABLED=0 go build -ldflags="-s" -o /bin/games .
|
||||||
RUN upx -9 -k /bin/c4
|
RUN upx -9 -k /bin/games
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
COPY --from=build /bin/c4 /
|
COPY --from=build /bin/games /
|
||||||
ENTRYPOINT ["/c4"]
|
ENTRYPOINT ["/games"]
|
||||||
|
|||||||
10
Taskfile.yml
10
Taskfile.yml
@@ -27,9 +27,9 @@ tasks:
|
|||||||
- "assets/css/output.css"
|
- "assets/css/output.css"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
desc: Production build to bin/c4
|
desc: Production build to bin/games
|
||||||
cmds:
|
cmds:
|
||||||
- go build -o bin/c4 .
|
- go build -o bin/games .
|
||||||
deps:
|
deps:
|
||||||
- build:templ
|
- build:templ
|
||||||
- build:styles
|
- build:styles
|
||||||
@@ -49,8 +49,8 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
go tool air \
|
go tool air \
|
||||||
-build.cmd "go build -tags=dev -o tmp/bin/c4 ." \
|
-build.cmd "go build -tags=dev -o tmp/bin/games ." \
|
||||||
-build.bin "tmp/bin/c4" \
|
-build.bin "tmp/bin/games" \
|
||||||
-build.exclude_dir "data,bin,tmp,deploy" \
|
-build.exclude_dir "data,bin,tmp,deploy" \
|
||||||
-build.include_ext "go,templ" \
|
-build.include_ext "go,templ" \
|
||||||
-misc.clean_on_exit "true"
|
-misc.clean_on_exit "true"
|
||||||
@@ -75,7 +75,7 @@ tasks:
|
|||||||
run:
|
run:
|
||||||
desc: Build and run the server
|
desc: Build and run the server
|
||||||
cmds:
|
cmds:
|
||||||
- ./bin/c4
|
- ./bin/games
|
||||||
deps:
|
deps:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
|
|||||||
149
chat/chat.go
Normal file
149
chat/chat.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// Package chat provides a reusable chat room backed by NATS pub/sub
|
||||||
|
// with optional database persistence.
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is the wire format for chat messages over NATS.
|
||||||
|
type Message struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Slot int `json:"slot"` // player slot/color index
|
||||||
|
Message string `json:"message"`
|
||||||
|
Time int64 `json:"time"` // unix millis, zero for ephemeral messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMessages = 50
|
||||||
|
|
||||||
|
// Room manages an in-memory message buffer and NATS pub/sub for a single
|
||||||
|
// chat room (typically one per game). When created with NewPersistentRoom,
|
||||||
|
// messages are automatically loaded from and saved to the database.
|
||||||
|
type Room struct {
|
||||||
|
subject string
|
||||||
|
nc *nats.Conn
|
||||||
|
messages []Message
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Optional persistence; nil for ephemeral rooms (e.g. snake).
|
||||||
|
queries *repository.Queries
|
||||||
|
roomID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoom creates an ephemeral chat room with no database persistence.
|
||||||
|
func NewRoom(nc *nats.Conn, subject string) *Room {
|
||||||
|
return &Room{
|
||||||
|
subject: subject,
|
||||||
|
nc: nc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPersistentRoom creates a chat room backed by the database. It loads
|
||||||
|
// existing messages on creation and auto-saves new messages on Send.
|
||||||
|
func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room {
|
||||||
|
r := &Room{
|
||||||
|
subject: subject,
|
||||||
|
nc: nc,
|
||||||
|
queries: queries,
|
||||||
|
roomID: roomID,
|
||||||
|
}
|
||||||
|
r.messages = r.loadMessages()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send publishes a message to the room's NATS subject and persists it
|
||||||
|
// if the room is backed by a database.
|
||||||
|
func (r *Room) Send(msg Message) {
|
||||||
|
if r.queries != nil {
|
||||||
|
r.saveMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.nc.Publish(r.subject, data); err != nil {
|
||||||
|
log.Error().Err(err).Str("subject", r.subject).Msg("failed to publish chat message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive processes an incoming NATS message, appending it to the buffer.
|
||||||
|
// Returns the new message and a snapshot of all messages.
|
||||||
|
func (r *Room) Receive(data []byte) (Message, []Message) {
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.messages = append(r.messages, msg)
|
||||||
|
if len(r.messages) > maxMessages {
|
||||||
|
r.messages = r.messages[len(r.messages)-maxMessages:]
|
||||||
|
}
|
||||||
|
snapshot := make([]Message, len(r.messages))
|
||||||
|
copy(snapshot, r.messages)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
return msg, snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages returns a snapshot of the current message buffer.
|
||||||
|
func (r *Room) Messages() []Message {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
snapshot := make([]Message, len(r.messages))
|
||||||
|
copy(snapshot, r.messages)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe creates a NATS channel subscription for the room's subject.
|
||||||
|
// Caller is responsible for unsubscribing.
|
||||||
|
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
|
||||||
|
ch := make(chan *nats.Msg, 64)
|
||||||
|
sub, err := r.nc.ChanSubscribe(r.subject, ch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return ch, sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) saveMessage(msg Message) {
|
||||||
|
err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
|
||||||
|
GameID: r.roomID,
|
||||||
|
Nickname: msg.Nickname,
|
||||||
|
Color: int64(msg.Slot),
|
||||||
|
Message: msg.Message,
|
||||||
|
CreatedAt: msg.Time,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) loadMessages() []Message {
|
||||||
|
rows, err := r.queries.GetChatMessages(context.Background(), r.roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msgs := make([]Message, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
msgs[i] = Message{
|
||||||
|
Nickname: row.Nickname,
|
||||||
|
Slot: int(row.Color),
|
||||||
|
Message: row.Message,
|
||||||
|
Time: row.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DB returns newest-first; reverse for chronological display
|
||||||
|
slices.Reverse(msgs)
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
74
chat/components/chat.templ
Normal file
74
chat/components/chat.templ
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/games/chat"
|
||||||
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColorFunc resolves a player slot to a CSS color string.
|
||||||
|
type ColorFunc func(slot int) string
|
||||||
|
|
||||||
|
// Config holds the game-specific settings for rendering a chat component.
|
||||||
|
type Config struct {
|
||||||
|
// CSSPrefix is used for element IDs and CSS classes (e.g. "c4" or "snake").
|
||||||
|
CSSPrefix string
|
||||||
|
// PostURL is the URL to POST chat messages to (e.g. "/games/{id}/chat").
|
||||||
|
PostURL string
|
||||||
|
// Color resolves a player slot to a CSS color string.
|
||||||
|
Color ColorFunc
|
||||||
|
// StopKeyPropagation adds data-on:keydown.stop="" to the input to prevent
|
||||||
|
// key events from propagating (needed for snake to avoid steering while typing).
|
||||||
|
StopKeyPropagation bool
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Chat(messages []chat.Message, cfg Config) {
|
||||||
|
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
|
||||||
|
<div class={ cfg.CSSPrefix + "-chat-history" }>
|
||||||
|
for _, m := range messages {
|
||||||
|
<div class={ cfg.CSSPrefix + "-chat-msg" }>
|
||||||
|
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", cfg.Color(m.Slot)) }>
|
||||||
|
{ m.Nickname + ": " }
|
||||||
|
</span>
|
||||||
|
<span>{ m.Message }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
|
||||||
|
if cfg.StopKeyPropagation {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Chat..."
|
||||||
|
autocomplete="off"
|
||||||
|
data-bind="chatMsg"
|
||||||
|
data-on:keydown.stop=""
|
||||||
|
data-on:keydown.key_enter={ datastar.PostSSE("%s", cfg.PostURL) }
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Chat..."
|
||||||
|
autocomplete="off"
|
||||||
|
data-bind="chatMsg"
|
||||||
|
data-on:keydown.enter={ datastar.PostSSE("%s", cfg.PostURL) }
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-on:click={ datastar.PostSSE("%s", cfg.PostURL) }
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@chatAutoScroll(cfg.CSSPrefix)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
script chatAutoScroll(cssPrefix string) {
|
||||||
|
var el = document.querySelector('.' + cssPrefix + '-chat-history');
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
||||||
|
.observe(el, {childList:true, subtree:true});
|
||||||
|
}
|
||||||
@@ -71,6 +71,6 @@ func loadBase() *Config {
|
|||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
AppURL: getEnv("APP_URL", "https://games.adriatica.io"),
|
AppURL: getEnv("APP_URL", "https://games.adriatica.io"),
|
||||||
DBPath: getEnv("DB_PATH", "data/c4.db"),
|
DBPath: getEnv("DB_PATH", "data/games.db"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package game implements Connect 4 game logic, state management, and persistence.
|
// Package connect4 implements Connect 4 game logic, state management, and persistence.
|
||||||
package game
|
package connect4
|
||||||
|
|
||||||
// DropPiece attempts to drop a piece in the given column.
|
// DropPiece attempts to drop a piece in the given column.
|
||||||
// Returns (row placed, success).
|
// Returns (row placed, success).
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
package game
|
package connect4
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
|
"github.com/ryanhamamura/games/player"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (gi *GameInstance) save() error {
|
func (gi *Instance) save() error {
|
||||||
err := saveGame(gi.queries, gi.game)
|
err := saveGame(gi.queries, gi.game)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game")
|
log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game")
|
||||||
@@ -16,8 +17,8 @@ func (gi *GameInstance) save() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) savePlayer(player *Player, slot int) error {
|
func (gi *Instance) savePlayer(p *Player, slot int) error {
|
||||||
err := saveGamePlayer(gi.queries, gi.game.ID, player, slot)
|
err := saveGamePlayer(gi.queries, gi.game.ID, p, slot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player")
|
log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player")
|
||||||
}
|
}
|
||||||
@@ -47,12 +48,12 @@ func saveGame(queries *repository.Queries, g *Game) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveGamePlayer(queries *repository.Queries, gameID string, player *Player, slot int) error {
|
func saveGamePlayer(queries *repository.Queries, gameID string, p *Player, slot int) error {
|
||||||
var userID, guestPlayerID *string
|
var userID, guestPlayerID *string
|
||||||
if player.UserID != nil {
|
if p.UserID != nil {
|
||||||
userID = player.UserID
|
userID = p.UserID
|
||||||
} else {
|
} else {
|
||||||
id := string(player.ID)
|
id := string(p.ID)
|
||||||
guestPlayerID = &id
|
guestPlayerID = &id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +61,8 @@ func saveGamePlayer(queries *repository.Queries, gameID string, player *Player,
|
|||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
GuestPlayerID: guestPlayerID,
|
GuestPlayerID: guestPlayerID,
|
||||||
Nickname: player.Nickname,
|
Nickname: p.Nickname,
|
||||||
Color: int64(player.Color),
|
Color: int64(p.Color),
|
||||||
Slot: int64(slot),
|
Slot: int64(slot),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,13 +83,11 @@ func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error)
|
|||||||
return playersFromRows(rows), nil
|
return playersFromRows(rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain ↔ DB mapping helpers.
|
|
||||||
|
|
||||||
func gameFromRow(row *repository.Game) (*Game, error) {
|
func gameFromRow(row *repository.Game) (*Game, error) {
|
||||||
g := &Game{
|
g := &Game{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
CurrentTurn: int(row.CurrentTurn),
|
CurrentTurn: int(row.CurrentTurn),
|
||||||
Status: GameStatus(row.Status),
|
Status: Status(row.Status),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.BoardFromJSON(row.Board); err != nil {
|
if err := g.BoardFromJSON(row.Board); err != nil {
|
||||||
@@ -109,19 +108,19 @@ func gameFromRow(row *repository.Game) (*Game, error) {
|
|||||||
func playersFromRows(rows []*repository.GamePlayer) []*Player {
|
func playersFromRows(rows []*repository.GamePlayer) []*Player {
|
||||||
players := make([]*Player, 0, len(rows))
|
players := make([]*Player, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
player := &Player{
|
p := &Player{
|
||||||
Nickname: row.Nickname,
|
Nickname: row.Nickname,
|
||||||
Color: int(row.Color),
|
Color: int(row.Color),
|
||||||
}
|
}
|
||||||
|
|
||||||
if row.UserID != nil {
|
if row.UserID != nil {
|
||||||
player.UserID = row.UserID
|
p.UserID = row.UserID
|
||||||
player.ID = PlayerID(*row.UserID)
|
p.ID = player.ID(*row.UserID)
|
||||||
} else if row.GuestPlayerID != nil {
|
} else if row.GuestPlayerID != nil {
|
||||||
player.ID = PlayerID(*row.GuestPlayerID)
|
p.ID = player.ID(*row.GuestPlayerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
players = append(players, player)
|
players = append(players, p)
|
||||||
}
|
}
|
||||||
return players
|
return players
|
||||||
}
|
}
|
||||||
@@ -1,79 +1,78 @@
|
|||||||
package game
|
package connect4
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
|
"github.com/ryanhamamura/games/player"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PlayerSession struct {
|
type PlayerSession struct {
|
||||||
Player *Player
|
Player *Player
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameStore struct {
|
type Store struct {
|
||||||
games map[string]*GameInstance
|
games map[string]*Instance
|
||||||
gamesMu sync.RWMutex
|
gamesMu sync.RWMutex
|
||||||
queries *repository.Queries
|
queries *repository.Queries
|
||||||
notifyFunc func(gameID string)
|
notifyFunc func(gameID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGameStore(queries *repository.Queries) *GameStore {
|
func NewStore(queries *repository.Queries) *Store {
|
||||||
return &GameStore{
|
return &Store{
|
||||||
games: make(map[string]*GameInstance),
|
games: make(map[string]*Instance),
|
||||||
queries: queries,
|
queries: queries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gs *GameStore) SetNotifyFunc(f func(gameID string)) {
|
func (s *Store) SetNotifyFunc(f func(gameID string)) {
|
||||||
gs.notifyFunc = f
|
s.notifyFunc = f
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gs *GameStore) makeNotify(gameID string) func() {
|
func (s *Store) makeNotify(gameID string) func() {
|
||||||
return func() {
|
return func() {
|
||||||
if gs.notifyFunc != nil {
|
if s.notifyFunc != nil {
|
||||||
gs.notifyFunc(gameID)
|
s.notifyFunc(gameID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gs *GameStore) Create() *GameInstance {
|
func (s *Store) Create() *Instance {
|
||||||
id := GenerateID(4)
|
id := player.GenerateID(4)
|
||||||
gi := NewGameInstance(id)
|
gi := NewInstance(id)
|
||||||
gi.queries = gs.queries
|
gi.queries = s.queries
|
||||||
gi.notify = gs.makeNotify(id)
|
gi.notify = s.makeNotify(id)
|
||||||
gs.gamesMu.Lock()
|
s.gamesMu.Lock()
|
||||||
gs.games[id] = gi
|
s.games[id] = gi
|
||||||
gs.gamesMu.Unlock()
|
s.gamesMu.Unlock()
|
||||||
|
|
||||||
if gs.queries != nil {
|
if s.queries != nil {
|
||||||
gi.save() //nolint:errcheck
|
gi.save() //nolint:errcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
return gi
|
return gi
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
func (s *Store) Get(id string) (*Instance, bool) {
|
||||||
gs.gamesMu.RLock()
|
s.gamesMu.RLock()
|
||||||
gi, ok := gs.games[id]
|
gi, ok := s.games[id]
|
||||||
gs.gamesMu.RUnlock()
|
s.gamesMu.RUnlock()
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
return gi, true
|
return gi, true
|
||||||
}
|
}
|
||||||
|
|
||||||
if gs.queries == nil {
|
if s.queries == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
g, err := loadGame(gs.queries, id)
|
g, err := loadGame(s.queries, id)
|
||||||
if err != nil || g == nil {
|
if err != nil || g == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
players, _ := loadGamePlayers(gs.queries, id)
|
players, _ := loadGamePlayers(s.queries, id)
|
||||||
for _, p := range players {
|
for _, p := range players {
|
||||||
switch p.Color {
|
switch p.Color {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -83,57 +82,51 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gi = &GameInstance{
|
gi = &Instance{
|
||||||
game: g,
|
game: g,
|
||||||
queries: gs.queries,
|
queries: s.queries,
|
||||||
notify: gs.makeNotify(id),
|
notify: s.makeNotify(id),
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.gamesMu.Lock()
|
s.gamesMu.Lock()
|
||||||
gs.games[id] = gi
|
s.games[id] = gi
|
||||||
gs.gamesMu.Unlock()
|
s.gamesMu.Unlock()
|
||||||
|
|
||||||
return gi, true
|
return gi, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gs *GameStore) Delete(id string) error {
|
func (s *Store) Delete(id string) error {
|
||||||
gs.gamesMu.Lock()
|
s.gamesMu.Lock()
|
||||||
delete(gs.games, id)
|
delete(s.games, id)
|
||||||
gs.gamesMu.Unlock()
|
s.gamesMu.Unlock()
|
||||||
|
|
||||||
if gs.queries != nil {
|
if s.queries != nil {
|
||||||
return gs.queries.DeleteGame(context.Background(), id)
|
return s.queries.DeleteGame(context.Background(), id)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateID(size int) string {
|
type Instance struct {
|
||||||
b := make([]byte, size)
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameInstance struct {
|
|
||||||
game *Game
|
game *Game
|
||||||
gameMu sync.RWMutex
|
gameMu sync.RWMutex
|
||||||
notify func()
|
notify func()
|
||||||
queries *repository.Queries
|
queries *repository.Queries
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGameInstance(id string) *GameInstance {
|
func NewInstance(id string) *Instance {
|
||||||
return &GameInstance{
|
return &Instance{
|
||||||
game: NewGame(id),
|
game: NewGame(id),
|
||||||
notify: func() {},
|
notify: func() {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) ID() string {
|
func (gi *Instance) ID() string {
|
||||||
gi.gameMu.RLock()
|
gi.gameMu.RLock()
|
||||||
defer gi.gameMu.RUnlock()
|
defer gi.gameMu.RUnlock()
|
||||||
return gi.game.ID
|
return gi.game.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
func (gi *Instance) Join(ps *PlayerSession) bool {
|
||||||
gi.gameMu.Lock()
|
gi.gameMu.Lock()
|
||||||
defer gi.gameMu.Unlock()
|
defer gi.gameMu.Unlock()
|
||||||
|
|
||||||
@@ -160,13 +153,13 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) GetGame() *Game {
|
func (gi *Instance) GetGame() *Game {
|
||||||
gi.gameMu.RLock()
|
gi.gameMu.RLock()
|
||||||
defer gi.gameMu.RUnlock()
|
defer gi.gameMu.RUnlock()
|
||||||
return gi.game
|
return gi.game
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
func (gi *Instance) GetPlayerColor(pid player.ID) int {
|
||||||
gi.gameMu.RLock()
|
gi.gameMu.RLock()
|
||||||
defer gi.gameMu.RUnlock()
|
defer gi.gameMu.RUnlock()
|
||||||
for _, p := range gi.game.Players {
|
for _, p := range gi.game.Players {
|
||||||
@@ -177,7 +170,7 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
|
func (gi *Instance) CreateRematch(s *Store) *Instance {
|
||||||
gi.gameMu.Lock()
|
gi.gameMu.Lock()
|
||||||
defer gi.gameMu.Unlock()
|
defer gi.gameMu.Unlock()
|
||||||
|
|
||||||
@@ -185,13 +178,13 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
newGI := gs.Create()
|
newGI := s.Create()
|
||||||
newID := newGI.ID()
|
newID := newGI.ID()
|
||||||
gi.game.RematchGameID = &newID
|
gi.game.RematchGameID = &newID
|
||||||
|
|
||||||
if gi.queries != nil {
|
if gi.queries != nil {
|
||||||
if err := gi.save(); err != nil {
|
if err := gi.save(); err != nil {
|
||||||
gs.Delete(newID) //nolint:errcheck
|
s.Delete(newID) //nolint:errcheck
|
||||||
gi.game.RematchGameID = nil
|
gi.game.RematchGameID = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -201,7 +194,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
|
|||||||
return newGI
|
return newGI
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
func (gi *Instance) DropPiece(col int, playerColor int) bool {
|
||||||
gi.gameMu.Lock()
|
gi.gameMu.Lock()
|
||||||
defer gi.gameMu.Unlock()
|
defer gi.gameMu.Unlock()
|
||||||
|
|
||||||
@@ -1,20 +1,31 @@
|
|||||||
package game
|
package connect4
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
type PlayerID string
|
"github.com/ryanhamamura/games/player"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubjectPrefix is the NATS subject namespace for connect4 games.
|
||||||
|
const SubjectPrefix = "connect4"
|
||||||
|
|
||||||
|
// GameSubject returns the NATS subject for game state updates.
|
||||||
|
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
|
||||||
|
|
||||||
|
// ChatSubject returns the NATS subject for chat messages.
|
||||||
|
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
|
||||||
|
|
||||||
type Player struct {
|
type Player struct {
|
||||||
ID PlayerID
|
ID player.ID
|
||||||
UserID *string // UUID for authenticated users, nil for guests
|
UserID *string // UUID for authenticated users, nil for guests
|
||||||
Nickname string
|
Nickname string
|
||||||
Color int // 1 = Red, 2 = Yellow
|
Color int // 1 = Red, 2 = Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameStatus int
|
type Status int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusWaitingForPlayer GameStatus = iota
|
StatusWaitingForPlayer Status = iota
|
||||||
StatusInProgress
|
StatusInProgress
|
||||||
StatusWon
|
StatusWon
|
||||||
StatusDraw
|
StatusDraw
|
||||||
@@ -25,7 +36,7 @@ type Game struct {
|
|||||||
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
|
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
|
||||||
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
|
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
|
||||||
CurrentTurn int // 1 or 2 (matches player color)
|
CurrentTurn int // 1 or 2 (matches player color)
|
||||||
Status GameStatus
|
Status Status
|
||||||
Winner *Player
|
Winner *Player
|
||||||
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
||||||
RematchGameID *string // ID of the rematch game, if one was created
|
RematchGameID *string // ID of the rematch game, if one was created
|
||||||
@@ -67,11 +78,3 @@ func (g *Game) WinningCellsFromJSON(data string) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal([]byte(data), &g.WinningCells)
|
return json.Unmarshal([]byte(data), &g.WinningCells)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatMessage is the domain type for persisted C4 chat messages.
|
|
||||||
type ChatMessage struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Color int `json:"color"` // 1=Red, 2=Yellow
|
|
||||||
Message string `json:"message"`
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deploy the c4 binary to /opt/c4, then restart the service.
|
# Deploy the games binary to /opt/games, then restart the service.
|
||||||
# Works from the repo (builds first) or from an extracted tarball (pre-built binary).
|
# Works from the repo (builds first) or from an extracted tarball (pre-built binary).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
INSTALL_DIR="/opt/c4"
|
INSTALL_DIR="/opt/games"
|
||||||
BINARY="$ROOT_DIR/c4"
|
BINARY="$ROOT_DIR/games"
|
||||||
|
|
||||||
# If Go is available and we have source, build fresh
|
# If Go is available and we have source, build fresh
|
||||||
if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then
|
if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then
|
||||||
@@ -13,7 +13,7 @@ if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then
|
|||||||
(cd "$ROOT_DIR" && go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify)
|
(cd "$ROOT_DIR" && go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify)
|
||||||
|
|
||||||
echo "Building binary..."
|
echo "Building binary..."
|
||||||
(cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o c4 .)
|
(cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o games .)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$BINARY" ]]; then
|
if [[ ! -f "$BINARY" ]]; then
|
||||||
@@ -22,10 +22,10 @@ if [[ ! -f "$BINARY" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Installing to $INSTALL_DIR..."
|
echo "Installing to $INSTALL_DIR..."
|
||||||
install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/c4"
|
install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/games"
|
||||||
|
|
||||||
echo "Restarting service..."
|
echo "Restarting service..."
|
||||||
systemctl restart c4.service
|
systemctl restart games.service
|
||||||
|
|
||||||
echo "Done. Status:"
|
echo "Done. Status:"
|
||||||
systemctl status c4.service --no-pager
|
systemctl status games.service --no-pager
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=C4 Game Lobby
|
Description=Games Lobby
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=games
|
User=games
|
||||||
Group=games
|
Group=games
|
||||||
WorkingDirectory=/opt/c4
|
WorkingDirectory=/opt/games
|
||||||
ExecStart=/opt/c4/c4
|
ExecStart=/opt/games/games
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Environment=PORT=8080
|
|||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/opt/c4
|
ReadWritePaths=/opt/games
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build the c4 binary, bundle it with deploy files into a tarball,
|
# Build the games binary, bundle it with deploy files into a tarball,
|
||||||
# base64-encode it, and split into 25MB chunks for transfer.
|
# base64-encode it, and split into 25MB chunks for transfer.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -7,14 +7,14 @@ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
TARBALL="c4-deploy-${TIMESTAMP}.tar.gz"
|
TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
|
||||||
BASE64_FILE="c4-deploy-${TIMESTAMP}_b64.txt"
|
BASE64_FILE="games-deploy-${TIMESTAMP}_b64.txt"
|
||||||
|
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
# Clean previous artifacts
|
# Clean previous artifacts
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
echo "--- Cleaning old artifacts ---"
|
echo "--- Cleaning old artifacts ---"
|
||||||
rm -f ./c4 c4-deploy-*.tar.gz c4-deploy-*_b64*.txt
|
rm -f ./games games-deploy-*.tar.gz games-deploy-*_b64*.txt
|
||||||
|
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
# Build
|
# Build
|
||||||
@@ -23,18 +23,18 @@ echo "--- Building CSS ---"
|
|||||||
go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
||||||
|
|
||||||
echo "--- Building binary (linux/amd64) ---"
|
echo "--- Building binary (linux/amd64) ---"
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o c4 .
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o games .
|
||||||
|
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
# Verify required files
|
# Verify required files
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
echo "--- Verifying files ---"
|
echo "--- Verifying files ---"
|
||||||
REQUIRED_FILES=(
|
REQUIRED_FILES=(
|
||||||
c4
|
games
|
||||||
deploy/setup.sh
|
deploy/setup.sh
|
||||||
deploy/deploy.sh
|
deploy/deploy.sh
|
||||||
deploy/reassemble.sh
|
deploy/reassemble.sh
|
||||||
deploy/c4.service
|
deploy/games.service
|
||||||
)
|
)
|
||||||
for f in "${REQUIRED_FILES[@]}"; do
|
for f in "${REQUIRED_FILES[@]}"; do
|
||||||
if [[ ! -f "$f" ]]; then
|
if [[ ! -f "$f" ]]; then
|
||||||
@@ -48,12 +48,12 @@ done
|
|||||||
# Create tarball
|
# Create tarball
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
echo "--- Creating tarball ---"
|
echo "--- Creating tarball ---"
|
||||||
tar -czf "/tmp/${TARBALL}" --transform 's,^,c4/,' \
|
tar -czf "/tmp/${TARBALL}" --transform 's,^,games/,' \
|
||||||
c4 \
|
games \
|
||||||
deploy/setup.sh \
|
deploy/setup.sh \
|
||||||
deploy/deploy.sh \
|
deploy/deploy.sh \
|
||||||
deploy/reassemble.sh \
|
deploy/reassemble.sh \
|
||||||
deploy/c4.service
|
deploy/games.service
|
||||||
|
|
||||||
mv "/tmp/${TARBALL}" .
|
mv "/tmp/${TARBALL}" .
|
||||||
echo " -> ${TARBALL} ($(du -h "${TARBALL}" | cut -f1))"
|
echo " -> ${TARBALL} ($(du -h "${TARBALL}" | cut -f1))"
|
||||||
@@ -66,10 +66,10 @@ base64 "${TARBALL}" > "${BASE64_FILE}"
|
|||||||
echo " -> ${BASE64_FILE} ($(du -h "${BASE64_FILE}" | cut -f1))"
|
echo " -> ${BASE64_FILE} ($(du -h "${BASE64_FILE}" | cut -f1))"
|
||||||
|
|
||||||
echo "--- Splitting into 25MB chunks ---"
|
echo "--- Splitting into 25MB chunks ---"
|
||||||
split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "c4-deploy-${TIMESTAMP}_b64_part"
|
split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "games-deploy-${TIMESTAMP}_b64_part"
|
||||||
rm -f "${BASE64_FILE}"
|
rm -f "${BASE64_FILE}"
|
||||||
|
|
||||||
CHUNKS=(c4-deploy-${TIMESTAMP}_b64_part*.txt)
|
CHUNKS=(games-deploy-${TIMESTAMP}_b64_part*.txt)
|
||||||
echo " -> ${#CHUNKS[@]} chunk(s):"
|
echo " -> ${#CHUNKS[@]} chunk(s):"
|
||||||
for chunk in "${CHUNKS[@]}"; do
|
for chunk in "${CHUNKS[@]}"; do
|
||||||
echo " $chunk ($(du -h "$chunk" | cut -f1))"
|
echo " $chunk ($(du -h "$chunk" | cut -f1))"
|
||||||
@@ -83,5 +83,5 @@ echo "=== Package Complete ==="
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Transfer the chunk files to the target server, then run:"
|
echo "Transfer the chunk files to the target server, then run:"
|
||||||
echo " ./reassemble.sh"
|
echo " ./reassemble.sh"
|
||||||
echo " cd ~/c4 && sudo ./deploy/setup.sh # first time only"
|
echo " cd ~/games && sudo ./deploy/setup.sh # first time only"
|
||||||
echo " cd ~/c4 && sudo ./deploy/deploy.sh"
|
echo " cd ~/games && sudo ./deploy/deploy.sh"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Reassembles base64 chunks and extracts the c4 deployment tarball.
|
# Reassembles base64 chunks and extracts the games deployment tarball.
|
||||||
# Expects chunk files in the current directory.
|
# Expects chunk files in the current directory.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$HOME"
|
cd "$HOME"
|
||||||
|
|
||||||
echo "=== C4 Deployment Reassembler ==="
|
echo "=== Games Deployment Reassembler ==="
|
||||||
echo "Working directory: $HOME"
|
echo "Working directory: $HOME"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -14,10 +14,10 @@ echo ""
|
|||||||
#==============================================================================
|
#==============================================================================
|
||||||
echo "--- Finding chunk files ---"
|
echo "--- Finding chunk files ---"
|
||||||
|
|
||||||
CHUNKS=($(ls -1 c4-deploy-*_b64_part*.txt 2>/dev/null | sort))
|
CHUNKS=($(ls -1 games-deploy-*_b64_part*.txt 2>/dev/null | sort))
|
||||||
|
|
||||||
if [[ ${#CHUNKS[@]} -eq 0 ]]; then
|
if [[ ${#CHUNKS[@]} -eq 0 ]]; then
|
||||||
echo "ERROR: No chunk files found matching c4-deploy-*_b64_part*.txt"
|
echo "ERROR: No chunk files found matching games-deploy-*_b64_part*.txt"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ done
|
|||||||
echo ""
|
echo ""
|
||||||
echo "--- Reassembling chunks ---"
|
echo "--- Reassembling chunks ---"
|
||||||
|
|
||||||
TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/c4-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/')
|
TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/games-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/')
|
||||||
TARBALL="c4-deploy-${TIMESTAMP}.tar.gz"
|
TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
|
||||||
COMBINED="combined_b64.txt"
|
COMBINED="combined_b64.txt"
|
||||||
|
|
||||||
echo "Concatenating chunks..."
|
echo "Concatenating chunks..."
|
||||||
@@ -58,12 +58,12 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo "--- Archiving existing source ---"
|
echo "--- Archiving existing source ---"
|
||||||
|
|
||||||
if [[ -d c4 ]]; then
|
if [[ -d games ]]; then
|
||||||
rm -rf c4.bak
|
rm -rf games.bak
|
||||||
mv c4 c4.bak
|
mv games games.bak
|
||||||
echo " -> Moved c4 -> c4.bak"
|
echo " -> Moved games -> games.bak"
|
||||||
else
|
else
|
||||||
echo " -> No existing c4 directory"
|
echo " -> No existing games directory"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
@@ -73,7 +73,7 @@ echo ""
|
|||||||
echo "--- Extracting tarball ---"
|
echo "--- Extracting tarball ---"
|
||||||
|
|
||||||
tar -xzf "$TARBALL"
|
tar -xzf "$TARBALL"
|
||||||
echo " -> Extracted to ~/c4"
|
echo " -> Extracted to ~/games"
|
||||||
|
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
# Cleanup
|
# Cleanup
|
||||||
@@ -91,6 +91,6 @@ echo ""
|
|||||||
echo "=== Reassembly Complete ==="
|
echo "=== Reassembly Complete ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " cd ~/c4"
|
echo " cd ~/games"
|
||||||
echo " sudo ./deploy/setup.sh # first time only"
|
echo " sudo ./deploy/setup.sh # first time only"
|
||||||
echo " sudo ./deploy/deploy.sh"
|
echo " sudo ./deploy/deploy.sh"
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ fi
|
|||||||
|
|
||||||
# Create system user if it doesn't exist
|
# Create system user if it doesn't exist
|
||||||
if ! id -u games &>/dev/null; then
|
if ! id -u games &>/dev/null; then
|
||||||
useradd --system --shell /usr/sbin/nologin --home-dir /opt/c4 --create-home games
|
useradd --system --shell /usr/sbin/nologin --home-dir /opt/games --create-home games
|
||||||
echo "Created system user: games"
|
echo "Created system user: games"
|
||||||
else
|
else
|
||||||
echo "User 'games' already exists"
|
echo "User 'games' already exists"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure install directory exists with correct ownership
|
# Ensure install directory exists with correct ownership
|
||||||
install -d -o games -g games -m 755 /opt/c4
|
install -d -o games -g games -m 755 /opt/games
|
||||||
install -d -o games -g games -m 755 /opt/c4/data
|
install -d -o games -g games -m 755 /opt/games/data
|
||||||
|
|
||||||
# Install systemd unit
|
# Install systemd unit
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
cp "$SCRIPT_DIR/c4.service" /etc/systemd/system/c4.service
|
cp "$SCRIPT_DIR/games.service" /etc/systemd/system/games.service
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable c4.service
|
systemctl enable games.service
|
||||||
|
|
||||||
echo "Setup complete. Run deploy.sh to build and start the service."
|
echo "Setup complete. Run deploy.sh to build and start the service."
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
c4:
|
games:
|
||||||
build: .
|
build: .
|
||||||
container_name: c4
|
container_name: games
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/auth"
|
"github.com/ryanhamamura/games/auth"
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/c4/features/auth/pages"
|
"github.com/ryanhamamura/games/features/auth/pages"
|
||||||
|
appsessions "github.com/ryanhamamura/games/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginSignals struct {
|
type LoginSignals struct {
|
||||||
@@ -65,9 +66,9 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessions.RenewToken(r.Context()) //nolint:errcheck
|
sessions.RenewToken(r.Context()) //nolint:errcheck
|
||||||
sessions.Put(r.Context(), "user_id", user.ID)
|
sessions.Put(r.Context(), appsessions.KeyUserID, user.ID)
|
||||||
sessions.Put(r.Context(), "username", user.Username)
|
sessions.Put(r.Context(), "username", user.Username)
|
||||||
sessions.Put(r.Context(), "nickname", user.Username)
|
sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
|
||||||
|
|
||||||
redirectURL := "/"
|
redirectURL := "/"
|
||||||
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
|
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
|
||||||
@@ -119,9 +120,9 @@ func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) h
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessions.RenewToken(r.Context()) //nolint:errcheck
|
sessions.RenewToken(r.Context()) //nolint:errcheck
|
||||||
sessions.Put(r.Context(), "user_id", user.ID)
|
sessions.Put(r.Context(), appsessions.KeyUserID, user.ID)
|
||||||
sessions.Put(r.Context(), "username", user.Username)
|
sessions.Put(r.Context(), "username", user.Username)
|
||||||
sessions.Put(r.Context(), "nickname", user.Username)
|
sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
|
||||||
|
|
||||||
redirectURL := "/"
|
redirectURL := "/"
|
||||||
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
|
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
"github.com/ryanhamamura/games/features/common/layouts"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
"github.com/ryanhamamura/games/features/common/layouts"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {
|
func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package components
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Board(g *game.Game, myColor int) {
|
templ Board(g *connect4.Game, myColor int) {
|
||||||
<div id="c4-board" class="board">
|
<div id="c4-board" class="board">
|
||||||
for col := 0; col < 7; col++ {
|
for col := 0; col < 7; col++ {
|
||||||
@column(g, col, myColor)
|
@column(g, col, myColor)
|
||||||
@@ -15,8 +15,8 @@ templ Board(g *game.Game, myColor int) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ column(g *game.Game, colIdx int, myColor int) {
|
templ column(g *connect4.Game, colIdx int, myColor int) {
|
||||||
if g.Status == game.StatusInProgress && myColor == g.CurrentTurn {
|
if g.Status == connect4.StatusInProgress && myColor == g.CurrentTurn {
|
||||||
<div
|
<div
|
||||||
class="column clickable"
|
class="column clickable"
|
||||||
data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) }
|
data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) }
|
||||||
@@ -34,14 +34,14 @@ templ column(g *game.Game, colIdx int, myColor int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ cell(g *game.Game, row int, col int) {
|
templ cell(g *connect4.Game, row int, col int) {
|
||||||
<div class={ cellClass(g, row, col) }></div>
|
<div class={ cellClass(g, row, col) }></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
func cellClass(g *game.Game, row, col int) string {
|
func cellClass(g *connect4.Game, row, col int) string {
|
||||||
color := g.Board[row][col]
|
color := g.Board[row][col]
|
||||||
activeTurn := 0
|
activeTurn := 0
|
||||||
if g.Status == game.StatusInProgress {
|
if g.Status == connect4.StatusInProgress {
|
||||||
activeTurn = g.CurrentTurn
|
activeTurn = g.CurrentTurn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package components
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ChatMessage struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Color int `json:"color"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatColors = map[int]string{
|
|
||||||
1: "#4a2a3a",
|
|
||||||
2: "#2a4545",
|
|
||||||
}
|
|
||||||
|
|
||||||
templ Chat(messages []ChatMessage, gameID string) {
|
|
||||||
<div id="c4-chat" class="c4-chat">
|
|
||||||
<div class="c4-chat-history">
|
|
||||||
for _, m := range messages {
|
|
||||||
<div class="c4-chat-msg">
|
|
||||||
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Color)) }>
|
|
||||||
{ m.Nickname }:
|
|
||||||
</span>
|
|
||||||
<span>{ m.Message }</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@chatAutoScroll()
|
|
||||||
</div>
|
|
||||||
<div class="c4-chat-input" data-morph-ignore>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Chat..."
|
|
||||||
autocomplete="off"
|
|
||||||
data-bind="chatMsg"
|
|
||||||
data-on:keydown.enter={ datastar.PostSSE("/games/%s/chat", gameID) }
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-on:click={ datastar.PostSSE("/games/%s/chat", gameID) }
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ chatAutoScroll() {
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var el = document.querySelector('.c4-chat-history');
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
|
||||||
.observe(el, {childList:true, subtree:true});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatColor(color int) string {
|
|
||||||
if c, ok := chatColors[color]; ok {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return "#666"
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ryanhamamura/c4/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ StatusBanner(g *game.Game, myColor int) {
|
templ StatusBanner(g *connect4.Game, myColor int) {
|
||||||
<div id="c4-status" class={ statusClass(g, myColor) }>
|
<div id="c4-status" class={ statusClass(g, myColor) }>
|
||||||
{ statusMessage(g, myColor) }
|
{ statusMessage(g, myColor) }
|
||||||
if g.IsFinished() {
|
if g.IsFinished() {
|
||||||
@@ -30,7 +30,7 @@ templ StatusBanner(g *game.Game, myColor int) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ PlayerInfo(g *game.Game, myColor int) {
|
templ PlayerInfo(g *connect4.Game, myColor int) {
|
||||||
<div id="c4-players" class="flex gap-8 mb-2">
|
<div id="c4-players" class="flex gap-8 mb-2">
|
||||||
for _, info := range playerInfoPairs(g, myColor) {
|
for _, info := range playerInfoPairs(g, myColor) {
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -61,36 +61,36 @@ script copyToClipboard(url string) {
|
|||||||
navigator.clipboard.writeText(url)
|
navigator.clipboard.writeText(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusClass(g *game.Game, myColor int) string {
|
func statusClass(g *connect4.Game, myColor int) string {
|
||||||
switch g.Status {
|
switch g.Status {
|
||||||
case game.StatusWaitingForPlayer:
|
case connect4.StatusWaitingForPlayer:
|
||||||
return "alert bg-base-200 text-xl font-bold"
|
return "alert bg-base-200 text-xl font-bold"
|
||||||
case game.StatusInProgress:
|
case connect4.StatusInProgress:
|
||||||
if g.CurrentTurn == myColor {
|
if g.CurrentTurn == myColor {
|
||||||
return "alert alert-success text-xl font-bold"
|
return "alert alert-success text-xl font-bold"
|
||||||
}
|
}
|
||||||
return "alert bg-base-200 text-xl font-bold"
|
return "alert bg-base-200 text-xl font-bold"
|
||||||
case game.StatusWon:
|
case connect4.StatusWon:
|
||||||
if g.Winner != nil && g.Winner.Color == myColor {
|
if g.Winner != nil && g.Winner.Color == myColor {
|
||||||
return "alert alert-success text-xl font-bold"
|
return "alert alert-success text-xl font-bold"
|
||||||
}
|
}
|
||||||
return "alert alert-error text-xl font-bold"
|
return "alert alert-error text-xl font-bold"
|
||||||
case game.StatusDraw:
|
case connect4.StatusDraw:
|
||||||
return "alert alert-warning text-xl font-bold"
|
return "alert alert-warning text-xl font-bold"
|
||||||
}
|
}
|
||||||
return "alert bg-base-200 text-xl font-bold"
|
return "alert bg-base-200 text-xl font-bold"
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusMessage(g *game.Game, myColor int) string {
|
func statusMessage(g *connect4.Game, myColor int) string {
|
||||||
switch g.Status {
|
switch g.Status {
|
||||||
case game.StatusWaitingForPlayer:
|
case connect4.StatusWaitingForPlayer:
|
||||||
return "Waiting for opponent..."
|
return "Waiting for opponent..."
|
||||||
case game.StatusInProgress:
|
case connect4.StatusInProgress:
|
||||||
if g.CurrentTurn == myColor {
|
if g.CurrentTurn == myColor {
|
||||||
return "Your turn!"
|
return "Your turn!"
|
||||||
}
|
}
|
||||||
return opponentName(g, myColor) + "'s turn"
|
return opponentName(g, myColor) + "'s turn"
|
||||||
case game.StatusWon:
|
case connect4.StatusWon:
|
||||||
if g.Winner != nil && g.Winner.Color == myColor {
|
if g.Winner != nil && g.Winner.Color == myColor {
|
||||||
return "You win!"
|
return "You win!"
|
||||||
}
|
}
|
||||||
@@ -98,13 +98,13 @@ func statusMessage(g *game.Game, myColor int) string {
|
|||||||
return g.Winner.Nickname + " wins!"
|
return g.Winner.Nickname + " wins!"
|
||||||
}
|
}
|
||||||
return "Game over"
|
return "Game over"
|
||||||
case game.StatusDraw:
|
case connect4.StatusDraw:
|
||||||
return "It's a draw!"
|
return "It's a draw!"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func opponentName(g *game.Game, myColor int) string {
|
func opponentName(g *connect4.Game, myColor int) string {
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
if p != nil && p.Color != myColor {
|
if p != nil && p.Color != myColor {
|
||||||
return p.Nickname
|
return p.Nickname
|
||||||
@@ -118,7 +118,7 @@ type playerInfoData struct {
|
|||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
func playerInfoPairs(g *game.Game, myColor int) []playerInfoData {
|
func playerInfoPairs(g *connect4.Game, myColor int) []playerInfoData {
|
||||||
var result []playerInfoData
|
var result []playerInfoData
|
||||||
|
|
||||||
var myName, oppName string
|
var myName, oppName string
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package c4game
|
package c4game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"fmt"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
@@ -14,13 +11,36 @@ import (
|
|||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/chat"
|
||||||
"github.com/ryanhamamura/c4/features/c4game/components"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
"github.com/ryanhamamura/c4/features/c4game/pages"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
|
"github.com/ryanhamamura/games/features/c4game/pages"
|
||||||
|
"github.com/ryanhamamura/games/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
|
||||||
|
var c4ChatColors = map[int]string{
|
||||||
|
0: "#4a2a3a", // color 1 stored as slot 0
|
||||||
|
1: "#2a4545", // color 2 stored as slot 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func c4ChatColor(slot int) string {
|
||||||
|
if c, ok := c4ChatColors[slot]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return "#666"
|
||||||
|
}
|
||||||
|
|
||||||
|
func c4ChatConfig(gameID string) chatcomponents.Config {
|
||||||
|
return chatcomponents.Config{
|
||||||
|
CSSPrefix: "c4",
|
||||||
|
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
|
||||||
|
Color: c4ChatColor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -30,29 +50,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
if playerID == "" {
|
userID := sessions.GetUserID(sm, r)
|
||||||
playerID = game.PlayerID(game.GenerateID(8))
|
nickname := sessions.GetNickname(sm, r)
|
||||||
sessions.Put(r.Context(), "player_id", string(playerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
|
||||||
if userID != "" {
|
|
||||||
playerID = game.PlayerID(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
nickname := sessions.GetString(r.Context(), "nickname")
|
|
||||||
|
|
||||||
// Auto-join if player has a nickname but isn't in the game yet
|
// Auto-join if player has a nickname but isn't in the game yet
|
||||||
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||||
player := &game.Player{
|
p := &connect4.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
Nickname: nickname,
|
Nickname: nickname,
|
||||||
}
|
}
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
player.UserID = &userID
|
p.UserID = &userID
|
||||||
}
|
}
|
||||||
gi.Join(&game.PlayerSession{Player: player})
|
gi.Join(&connect4.PlayerSession{Player: p})
|
||||||
}
|
}
|
||||||
|
|
||||||
myColor := gi.GetPlayerColor(playerID)
|
myColor := gi.GetPlayerColor(playerID)
|
||||||
@@ -61,31 +72,27 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
|
|||||||
// Player not in game
|
// Player not in game
|
||||||
isGuest := r.URL.Query().Get("guest") == "1"
|
isGuest := r.URL.Query().Get("guest") == "1"
|
||||||
if userID == "" && !isGuest {
|
if userID == "" && !isGuest {
|
||||||
// Show join prompt (login vs guest)
|
|
||||||
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Show nickname prompt
|
|
||||||
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player is in the game — render full game page
|
|
||||||
g := gi.GetGame()
|
g := gi.GetGame()
|
||||||
chatMsgs := loadChatMessages(queries, gameID)
|
room := chat.NewPersistentRoom(nil, "", queries, gameID)
|
||||||
msgs := chatToComponents(chatMsgs)
|
|
||||||
|
|
||||||
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
|
if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -95,37 +102,37 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
|
||||||
if userID != "" {
|
|
||||||
playerID = game.PlayerID(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
myColor := gi.GetPlayerColor(playerID)
|
myColor := gi.GetPlayerColor(playerID)
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Load initial chat messages
|
chatCfg := c4ChatConfig(gameID)
|
||||||
chatMsgs := loadChatMessages(queries, gameID)
|
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
|
||||||
var chatMu sync.Mutex
|
|
||||||
chatMessages := chatToComponents(chatMsgs)
|
|
||||||
|
|
||||||
// Send initial render of all components
|
patchAll := func() error {
|
||||||
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
|
myColor = gi.GetPlayerColor(playerID)
|
||||||
|
g := gi.GetGame()
|
||||||
|
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial render
|
||||||
|
if err := patchAll(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to game state updates
|
// Subscribe to game state updates
|
||||||
gameCh := make(chan *nats.Msg, 64)
|
gameCh := make(chan *nats.Msg, 64)
|
||||||
gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh)
|
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||||
|
|
||||||
// Subscribe to chat messages
|
// Subscribe to chat messages
|
||||||
chatCh := make(chan *nats.Msg, 64)
|
chatCh, chatSub, err := room.Subscribe()
|
||||||
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,30 +144,12 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-gameCh:
|
case <-gameCh:
|
||||||
// Re-read player color in case we just joined
|
if err := patchAll(); err != nil {
|
||||||
myColor = gi.GetPlayerColor(playerID)
|
return
|
||||||
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
|
}
|
||||||
case msg := <-chatCh:
|
case msg := <-chatCh:
|
||||||
var uiMsg game.ChatMessage
|
room.Receive(msg.Data)
|
||||||
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
|
if err := patchAll(); err != nil {
|
||||||
continue
|
|
||||||
}
|
|
||||||
cm := components.ChatMessage{
|
|
||||||
Nickname: uiMsg.Nickname,
|
|
||||||
Color: uiMsg.Color,
|
|
||||||
Message: uiMsg.Message,
|
|
||||||
Time: uiMsg.Time,
|
|
||||||
}
|
|
||||||
chatMu.Lock()
|
|
||||||
chatMessages = append(chatMessages, cm)
|
|
||||||
if len(chatMessages) > 50 {
|
|
||||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
|
||||||
}
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
|
||||||
copy(msgs, chatMessages)
|
|
||||||
chatMu.Unlock()
|
|
||||||
|
|
||||||
if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +157,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -185,12 +174,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
|
||||||
if userID != "" {
|
|
||||||
playerID = game.PlayerID(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
myColor := gi.GetPlayerColor(playerID)
|
myColor := gi.GetPlayerColor(playerID)
|
||||||
if myColor == 0 {
|
if myColor == 0 {
|
||||||
http.Error(w, "not in game", http.StatusForbidden)
|
http.Error(w, "not in game", http.StatusForbidden)
|
||||||
@@ -198,14 +182,11 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
|
|||||||
}
|
}
|
||||||
|
|
||||||
gi.DropPiece(col, myColor)
|
gi.DropPiece(col, myColor)
|
||||||
|
|
||||||
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
|
|
||||||
// Return empty SSE response.
|
|
||||||
datastar.NewSSE(w, r)
|
datastar.NewSSE(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -229,12 +210,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
|
||||||
if userID != "" {
|
|
||||||
playerID = game.PlayerID(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
myColor := gi.GetPlayerColor(playerID)
|
myColor := gi.GetPlayerColor(playerID)
|
||||||
if myColor == 0 {
|
if myColor == 0 {
|
||||||
datastar.NewSSE(w, r)
|
datastar.NewSSE(w, r)
|
||||||
@@ -250,28 +226,22 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cm := game.ChatMessage{
|
// Map color (1-based) to slot (0-based) for the unified chat message
|
||||||
|
msg := chat.Message{
|
||||||
Nickname: nick,
|
Nickname: nick,
|
||||||
Color: myColor,
|
Slot: myColor - 1,
|
||||||
Message: signals.ChatMsg,
|
Message: signals.ChatMsg,
|
||||||
Time: time.Now().UnixMilli(),
|
Time: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
saveChatMessage(queries, gameID, cm)
|
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
|
||||||
|
room.Send(msg)
|
||||||
|
|
||||||
data, err := json.Marshal(cm)
|
|
||||||
if err != nil {
|
|
||||||
datastar.NewSSE(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nc.Publish("game.chat."+gameID, data) //nolint:errcheck
|
|
||||||
|
|
||||||
// Clear the chat input
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -296,23 +266,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.Put(r.Context(), "nickname", signals.Nickname)
|
sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
|
||||||
|
|
||||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
userID := sessions.GetUserID(sm, r)
|
||||||
if userID != "" {
|
|
||||||
playerID = game.PlayerID(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if gi.GetPlayerColor(playerID) == 0 {
|
if gi.GetPlayerColor(playerID) == 0 {
|
||||||
player := &game.Player{
|
p := &connect4.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
Nickname: signals.Nickname,
|
Nickname: signals.Nickname,
|
||||||
}
|
}
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
player.UserID = &userID
|
p.UserID = &userID
|
||||||
}
|
}
|
||||||
gi.Join(&game.PlayerSession{Player: player})
|
gi.Join(&connect4.PlayerSession{Player: p})
|
||||||
}
|
}
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
@@ -320,7 +287,7 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -338,63 +305,3 @@ func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.Han
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendGameComponents patches all game-related SSE components.
|
|
||||||
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
|
|
||||||
g := gi.GetGame()
|
|
||||||
|
|
||||||
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
|
|
||||||
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
|
|
||||||
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
|
|
||||||
|
|
||||||
chatMu.Lock()
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
|
||||||
copy(msgs, chatMessages)
|
|
||||||
chatMu.Unlock()
|
|
||||||
|
|
||||||
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat persistence helpers — inlined from the former ChatPersister.
|
|
||||||
|
|
||||||
func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
|
|
||||||
queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck
|
|
||||||
GameID: gameID,
|
|
||||||
Nickname: msg.Nickname,
|
|
||||||
Color: int64(msg.Color),
|
|
||||||
Message: msg.Message,
|
|
||||||
CreatedAt: msg.Time,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage {
|
|
||||||
rows, err := queries.GetChatMessages(context.Background(), gameID)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
msgs := make([]game.ChatMessage, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
msgs[i] = game.ChatMessage{
|
|
||||||
Nickname: r.Nickname,
|
|
||||||
Color: int(r.Color),
|
|
||||||
Message: r.Message,
|
|
||||||
Time: r.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// DB returns newest-first; reverse for display
|
|
||||||
slices.Reverse(msgs)
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage {
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMsgs))
|
|
||||||
for i, m := range chatMsgs {
|
|
||||||
msgs[i] = components.ChatMessage{
|
|
||||||
Nickname: m.Nickname,
|
|
||||||
Color: m.Color,
|
|
||||||
Message: m.Message,
|
|
||||||
Time: m.Time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ryanhamamura/c4/features/c4game/components"
|
"github.com/ryanhamamura/games/chat"
|
||||||
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/features/c4game/components"
|
||||||
|
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||||
|
"github.com/ryanhamamura/games/features/common/layouts"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
|
templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
||||||
@layouts.Base("Connect 4") {
|
@layouts.Base("Connect 4") {
|
||||||
<main
|
<main
|
||||||
class="flex flex-col items-center gap-4 p-4"
|
class="flex flex-col items-center gap-4 p-4"
|
||||||
data-signals="{chatMsg: ''}"
|
data-signals="{chatMsg: ''}"
|
||||||
data-init={ datastar.GetSSE("/games/%s/events", g.ID) }
|
data-init={ datastar.GetSSE("/games/%s/events", g.ID) }
|
||||||
>
|
>
|
||||||
|
@GameContent(g, myColor, messages, chatCfg)
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
||||||
|
<div id="game-content">
|
||||||
@sharedcomponents.BackToLobby()
|
@sharedcomponents.BackToLobby()
|
||||||
@sharedcomponents.StealthTitle("text-3xl font-bold")
|
@sharedcomponents.StealthTitle("text-3xl font-bold")
|
||||||
@components.PlayerInfo(g, myColor)
|
@components.PlayerInfo(g, myColor)
|
||||||
@components.StatusBanner(g, myColor)
|
@components.StatusBanner(g, myColor)
|
||||||
<div class="c4-game-area">
|
<div class="c4-game-area">
|
||||||
@components.Board(g, myColor)
|
@components.Board(g, myColor)
|
||||||
@components.Chat(messages, g.ID)
|
@chatcomponents.Chat(messages, chatCfg)
|
||||||
</div>
|
</div>
|
||||||
if g.Status == game.StatusWaitingForPlayer {
|
if g.Status == connect4.StatusWaitingForPlayer {
|
||||||
@components.InviteLink(g.ID)
|
@components.InviteLink(g.ID)
|
||||||
}
|
}
|
||||||
</main>
|
</div>
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
templ JoinPage(gameID string) {
|
templ JoinPage(gameID string) {
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(
|
func SetupRoutes(
|
||||||
router chi.Router,
|
router chi.Router,
|
||||||
store *game.GameStore,
|
store *connect4.Store,
|
||||||
nc *nats.Conn,
|
nc *nats.Conn,
|
||||||
sessions *scs.SessionManager,
|
sessions *scs.SessionManager,
|
||||||
queries *repository.Queries,
|
queries *repository.Queries,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package layouts
|
package layouts
|
||||||
|
|
||||||
import "github.com/ryanhamamura/c4/config"
|
import "github.com/ryanhamamura/games/config"
|
||||||
|
|
||||||
templ Base(title string) {
|
templ Base(title string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,10 +46,10 @@ templ gameListEntry(g GameListItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func statusText(g GameListItem) string {
|
func statusText(g GameListItem) string {
|
||||||
switch game.GameStatus(g.Status) {
|
switch connect4.Status(g.Status) {
|
||||||
case game.StatusWaitingForPlayer:
|
case connect4.StatusWaitingForPlayer:
|
||||||
return "Waiting for opponent"
|
return "Waiting for opponent"
|
||||||
case game.StatusInProgress:
|
case connect4.StatusInProgress:
|
||||||
if g.IsMyTurn {
|
if g.IsMyTurn {
|
||||||
return "Your turn!"
|
return "Your turn!"
|
||||||
}
|
}
|
||||||
@@ -59,10 +59,10 @@ func statusText(g GameListItem) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func statusClass(g GameListItem) string {
|
func statusClass(g GameListItem) string {
|
||||||
switch game.GameStatus(g.Status) {
|
switch connect4.Status(g.Status) {
|
||||||
case game.StatusWaitingForPlayer:
|
case connect4.StatusWaitingForPlayer:
|
||||||
return "text-sm opacity-60"
|
return "text-sm opacity-60"
|
||||||
case game.StatusInProgress:
|
case connect4.StatusInProgress:
|
||||||
if g.IsMyTurn {
|
if g.IsMyTurn {
|
||||||
return "text-sm text-success font-bold"
|
return "text-sm text-success font-bold"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/c4/features/lobby/pages"
|
lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/features/lobby/pages"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
appsessions "github.com/ryanhamamura/games/sessions"
|
||||||
|
"github.com/ryanhamamura/games/snake"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -21,7 +22,7 @@ import (
|
|||||||
// HandleLobbyPage renders the main lobby page with active games for logged-in users.
|
// HandleLobbyPage renders the main lobby page with active games for logged-in users.
|
||||||
func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, snakeStore *snake.SnakeStore) http.HandlerFunc {
|
func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, snakeStore *snake.SnakeStore) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
userID := sessions.GetString(r.Context(), appsessions.KeyUserID)
|
||||||
username := sessions.GetString(r.Context(), "username")
|
username := sessions.GetString(r.Context(), "username")
|
||||||
isLoggedIn := userID != ""
|
isLoggedIn := userID != ""
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleCreateGame reads the nickname signal, creates a connect4 game, and redirects via SSE.
|
// HandleCreateGame reads the nickname signal, creates a connect4 game, and redirects via SSE.
|
||||||
func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleCreateGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
type Signals struct {
|
type Signals struct {
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
@@ -95,7 +96,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.Put(r.Context(), "nickname", signals.Nickname)
|
sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
|
||||||
|
|
||||||
gi := store.Create()
|
gi := store.Create()
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
@@ -104,7 +105,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleDeleteGame deletes a connect4 game and redirects to the lobby.
|
// HandleDeleteGame deletes a connect4 game and redirects to the lobby.
|
||||||
func HandleDeleteGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleDeleteGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
if gameID == "" {
|
if gameID == "" {
|
||||||
@@ -137,7 +138,7 @@ func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionMa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.Put(r.Context(), "nickname", signals.Nickname)
|
sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
|
||||||
|
|
||||||
mode := snake.ModeMultiplayer
|
mode := snake.ModeMultiplayer
|
||||||
if r.URL.Query().Get("mode") == "solo" {
|
if r.URL.Query().Get("mode") == "solo" {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package pages
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/features/common/components"
|
"github.com/ryanhamamura/games/features/common/components"
|
||||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
"github.com/ryanhamamura/games/features/common/layouts"
|
||||||
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
|
lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import "github.com/ryanhamamura/c4/features/lobby/components"
|
import "github.com/ryanhamamura/games/features/lobby/components"
|
||||||
|
|
||||||
// SnakeGameListItem represents a joinable snake game in the lobby.
|
// SnakeGameListItem represents a joinable snake game in the lobby.
|
||||||
type SnakeGameListItem struct {
|
type SnakeGameListItem struct {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
package lobby
|
package lobby
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -14,7 +14,7 @@ func SetupRoutes(
|
|||||||
router chi.Router,
|
router chi.Router,
|
||||||
queries *repository.Queries,
|
queries *repository.Queries,
|
||||||
sessions *scs.SessionManager,
|
sessions *scs.SessionManager,
|
||||||
store *game.GameStore,
|
store *connect4.Store,
|
||||||
snakeStore *snake.SnakeStore,
|
snakeStore *snake.SnakeStore,
|
||||||
) {
|
) {
|
||||||
router.Get("/", HandleLobbyPage(queries, sessions, snakeStore))
|
router.Get("/", HandleLobbyPage(queries, sessions, snakeStore))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package components
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
)
|
)
|
||||||
|
|
||||||
func cellSizeForGrid(width, height int) int {
|
func cellSizeForGrid(width, height int) int {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package components
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/snake"
|
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ChatMessage struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Slot int `json:"slot"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
templ Chat(messages []ChatMessage, gameID string) {
|
|
||||||
<div id="snake-chat" class="snake-chat">
|
|
||||||
<div class="snake-chat-history">
|
|
||||||
for _, m := range messages {
|
|
||||||
<div class="snake-chat-msg">
|
|
||||||
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Slot)) }>
|
|
||||||
{ m.Nickname + ": " }
|
|
||||||
</span>
|
|
||||||
<span>{ m.Message }</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="snake-chat-input" data-morph-ignore>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Chat..."
|
|
||||||
autocomplete="off"
|
|
||||||
data-bind="chatMsg"
|
|
||||||
data-on:keydown.stop=""
|
|
||||||
data-on:keydown.key_enter={ datastar.PostSSE("/snake/%s/chat", gameID) }
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-on:click={ datastar.PostSSE("/snake/%s/chat", gameID) }
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@chatAutoScroll()
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ chatAutoScroll() {
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var el = document.querySelector('.snake-chat-history');
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
|
||||||
.observe(el, {childList:true, subtree:true});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatColor(slot int) string {
|
|
||||||
if slot >= 0 && slot < len(snake.SnakeColors) {
|
|
||||||
return snake.SnakeColors[slot]
|
|
||||||
}
|
|
||||||
return "#666"
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,39 @@
|
|||||||
package snakegame
|
package snakegame
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/features/snakegame/components"
|
"github.com/ryanhamamura/games/chat"
|
||||||
"github.com/ryanhamamura/c4/features/snakegame/pages"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/features/snakegame/pages"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
|
"github.com/ryanhamamura/games/snake"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID {
|
func snakeChatColor(slot int) string {
|
||||||
pid := sessions.GetString(r.Context(), "player_id")
|
if slot >= 0 && slot < len(snake.SnakeColors) {
|
||||||
if pid == "" {
|
return snake.SnakeColors[slot]
|
||||||
pid = game.GenerateID(8)
|
|
||||||
sessions.Put(r.Context(), "player_id", pid)
|
|
||||||
}
|
}
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
return "#666"
|
||||||
if userID != "" {
|
|
||||||
return snake.PlayerID(userID)
|
|
||||||
}
|
|
||||||
return snake.PlayerID(pid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func snakeChatConfig(gameID string) chatcomponents.Config {
|
||||||
|
return chatcomponents.Config{
|
||||||
|
CSSPrefix: "snake",
|
||||||
|
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
|
||||||
|
Color: snakeChatColor,
|
||||||
|
StopKeyPropagation: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
si, ok := snakeStore.Get(gameID)
|
si, ok := snakeStore.Get(gameID)
|
||||||
@@ -39,26 +42,25 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := getPlayerID(sessions, r)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
nickname := sessions.GetString(r.Context(), "nickname")
|
nickname := sessions.GetNickname(sm, r)
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
userID := sessions.GetUserID(sm, r)
|
||||||
|
|
||||||
// Auto-join if nickname exists and not already in game
|
// Auto-join if nickname exists and not already in game
|
||||||
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
||||||
player := &snake.Player{
|
p := &snake.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
Nickname: nickname,
|
Nickname: nickname,
|
||||||
}
|
}
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
player.UserID = &userID
|
p.UserID = &userID
|
||||||
}
|
}
|
||||||
si.Join(player)
|
si.Join(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
mySlot := si.GetPlayerSlot(playerID)
|
mySlot := si.GetPlayerSlot(playerID)
|
||||||
|
|
||||||
if mySlot < 0 {
|
if mySlot < 0 {
|
||||||
// Not in game yet
|
|
||||||
isGuest := r.URL.Query().Get("guest") == "1"
|
isGuest := r.URL.Query().Get("guest") == "1"
|
||||||
if userID == "" && !isGuest {
|
if userID == "" && !isGuest {
|
||||||
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
||||||
@@ -73,13 +75,13 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
|
|||||||
}
|
}
|
||||||
|
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil {
|
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
si, ok := snakeStore.Get(gameID)
|
si, ok := snakeStore.Get(gameID)
|
||||||
@@ -88,28 +90,47 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := getPlayerID(sessions, r)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
mySlot := si.GetPlayerSlot(playerID)
|
mySlot := si.GetPlayerSlot(playerID)
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Send initial render
|
chatCfg := snakeChatConfig(gameID)
|
||||||
|
|
||||||
|
// Chat room (multiplayer only)
|
||||||
|
var room *chat.Room
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
|
|
||||||
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
|
|
||||||
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
|
|
||||||
if sg.Mode == snake.ModeMultiplayer {
|
if sg.Mode == snake.ModeMultiplayer {
|
||||||
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck
|
room = chat.NewRoom(nc, snake.ChatSubject(gameID))
|
||||||
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
|
||||||
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatMessages := func() []chat.Message {
|
||||||
|
if room == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return room.Messages()
|
||||||
|
}
|
||||||
|
|
||||||
|
patchAll := func() error {
|
||||||
|
si, ok = snakeStore.Get(gameID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("game not found")
|
||||||
|
}
|
||||||
|
mySlot = si.GetPlayerSlot(playerID)
|
||||||
|
sg = si.GetGame()
|
||||||
|
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial render
|
||||||
|
if err := patchAll(); err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to game updates via NATS
|
// Subscribe to game updates via NATS
|
||||||
gameCh := make(chan *nats.Msg, 64)
|
gameCh := make(chan *nats.Msg, 64)
|
||||||
gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh)
|
gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -118,12 +139,9 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
|
|||||||
// Chat subscription (multiplayer only)
|
// Chat subscription (multiplayer only)
|
||||||
var chatCh chan *nats.Msg
|
var chatCh chan *nats.Msg
|
||||||
var chatSub *nats.Subscription
|
var chatSub *nats.Subscription
|
||||||
var chatMessages []components.ChatMessage
|
|
||||||
var chatMu sync.Mutex
|
|
||||||
|
|
||||||
if sg.Mode == snake.ModeMultiplayer {
|
if room != nil {
|
||||||
chatCh = make(chan *nats.Msg, 64)
|
chatCh, chatSub, err = room.Subscribe()
|
||||||
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,19 +164,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
drained:
|
drained:
|
||||||
si, ok = snakeStore.Get(gameID)
|
if err := patchAll(); err != nil {
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mySlot = si.GetPlayerSlot(playerID)
|
|
||||||
sg = si.GetGame()
|
|
||||||
if err := sse.PatchElementTempl(components.Board(sg)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := sse.PatchElementTempl(components.PlayerList(sg, mySlot)); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,20 +172,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
|
|||||||
if msg == nil {
|
if msg == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var cm components.ChatMessage
|
room.Receive(msg.Data)
|
||||||
if err := json.Unmarshal(msg.Data, &cm); err != nil {
|
if err := patchAll(); err != nil {
|
||||||
continue
|
|
||||||
}
|
|
||||||
chatMu.Lock()
|
|
||||||
chatMessages = append(chatMessages, cm)
|
|
||||||
if len(chatMessages) > 50 {
|
|
||||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
|
||||||
}
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
|
||||||
copy(msgs, chatMessages)
|
|
||||||
chatMu.Unlock()
|
|
||||||
|
|
||||||
if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +181,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleSetDirection(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
si, ok := snakeStore.Get(gameID)
|
si, ok := snakeStore.Get(gameID)
|
||||||
@@ -196,7 +190,7 @@ func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManag
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := getPlayerID(sessions, r)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
slot := si.GetPlayerSlot(playerID)
|
slot := si.GetPlayerSlot(playerID)
|
||||||
if slot < 0 {
|
if slot < 0 {
|
||||||
http.Error(w, "not in game", http.StatusForbidden)
|
http.Error(w, "not in game", http.StatusForbidden)
|
||||||
@@ -219,7 +213,7 @@ type chatSignals struct {
|
|||||||
ChatMsg string `json:"chatMsg"`
|
ChatMsg string `json:"chatMsg"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
si, ok := snakeStore.Get(gameID)
|
si, ok := snakeStore.Get(gameID)
|
||||||
@@ -238,7 +232,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerID := getPlayerID(sessions, r)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
slot := si.GetPlayerSlot(playerID)
|
slot := si.GetPlayerSlot(playerID)
|
||||||
if slot < 0 {
|
if slot < 0 {
|
||||||
http.Error(w, "not in game", http.StatusForbidden)
|
http.Error(w, "not in game", http.StatusForbidden)
|
||||||
@@ -246,16 +240,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
|
|||||||
}
|
}
|
||||||
|
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
cm := components.ChatMessage{
|
msg := chat.Message{
|
||||||
Nickname: sg.Players[slot].Nickname,
|
Nickname: sg.Players[slot].Nickname,
|
||||||
Slot: slot,
|
Slot: slot,
|
||||||
Message: signals.ChatMsg,
|
Message: signals.ChatMsg,
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(cm)
|
|
||||||
if err != nil {
|
room := chat.NewRoom(nc, snake.ChatSubject(gameID))
|
||||||
return
|
room.Send(msg)
|
||||||
}
|
|
||||||
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
|
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
||||||
@@ -266,7 +258,7 @@ type nicknameSignals struct {
|
|||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
si, ok := snakeStore.Get(gameID)
|
si, ok := snakeStore.Get(gameID)
|
||||||
@@ -285,20 +277,20 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.Put(r.Context(), "nickname", signals.Nickname)
|
sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
|
||||||
|
|
||||||
playerID := getPlayerID(sessions, r)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
userID := sessions.GetString(r.Context(), "user_id")
|
userID := sessions.GetUserID(sm, r)
|
||||||
|
|
||||||
if si.GetPlayerSlot(playerID) < 0 {
|
if si.GetPlayerSlot(playerID) < 0 {
|
||||||
player := &snake.Player{
|
p := &snake.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
Nickname: signals.Nickname,
|
Nickname: signals.Nickname,
|
||||||
}
|
}
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
player.UserID = &userID
|
p.UserID = &userID
|
||||||
}
|
}
|
||||||
si.Join(player)
|
si.Join(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
@@ -306,7 +298,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleRematch(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
func HandleRematch(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
si, ok := snakeStore.Get(gameID)
|
si, ok := snakeStore.Get(gameID)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package pages
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/features/common/components"
|
"github.com/ryanhamamura/games/chat"
|
||||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
|
"github.com/ryanhamamura/games/features/common/components"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/features/common/layouts"
|
||||||
|
snakecomponents "github.com/ryanhamamura/games/features/snakegame/components"
|
||||||
|
"github.com/ryanhamamura/games/snake"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ func keydownScript(gameID string) string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) {
|
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
||||||
@layouts.Base("Snake") {
|
@layouts.Base("Snake") {
|
||||||
<main
|
<main
|
||||||
class="snake-wrapper flex flex-col items-center gap-4 p-4"
|
class="snake-wrapper flex flex-col items-center gap-4 p-4"
|
||||||
@@ -35,6 +37,13 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
|
|||||||
data-on:keydown.throttle_100ms={ keydownScript(gameID) }
|
data-on:keydown.throttle_100ms={ keydownScript(gameID) }
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
@GameContent(sg, mySlot, messages, chatCfg, gameID)
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
||||||
|
<div id="game-content">
|
||||||
@components.BackToLobby()
|
@components.BackToLobby()
|
||||||
<h1 class="text-3xl font-bold">~~~~</h1>
|
<h1 class="text-3xl font-bold">~~~~</h1>
|
||||||
@snakecomponents.PlayerList(sg, mySlot)
|
@snakecomponents.PlayerList(sg, mySlot)
|
||||||
@@ -43,19 +52,18 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
|
|||||||
if sg.Mode == snake.ModeMultiplayer {
|
if sg.Mode == snake.ModeMultiplayer {
|
||||||
<div class="snake-game-area">
|
<div class="snake-game-area">
|
||||||
@snakecomponents.Board(sg)
|
@snakecomponents.Board(sg)
|
||||||
@snakecomponents.Chat(messages, gameID)
|
@chatcomponents.Chat(messages, chatCfg)
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
@snakecomponents.Board(sg)
|
@snakecomponents.Board(sg)
|
||||||
}
|
}
|
||||||
} else if sg.Mode == snake.ModeMultiplayer {
|
} else if sg.Mode == snake.ModeMultiplayer {
|
||||||
@snakecomponents.Chat(messages, gameID)
|
@chatcomponents.Chat(messages, chatCfg)
|
||||||
}
|
}
|
||||||
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||||
@snakecomponents.InviteLink(gameID)
|
@snakecomponents.InviteLink(gameID)
|
||||||
}
|
}
|
||||||
</main>
|
</div>
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
templ JoinPage(gameID string) {
|
templ JoinPage(gameID string) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {
|
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module github.com/ryanhamamura/c4
|
module github.com/ryanhamamura/games
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
stdlog "log"
|
stdlog "log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|||||||
24
main.go
24
main.go
@@ -11,15 +11,15 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
"github.com/ryanhamamura/c4/db"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/c4/logging"
|
"github.com/ryanhamamura/games/logging"
|
||||||
appnats "github.com/ryanhamamura/c4/nats"
|
appnats "github.com/ryanhamamura/games/nats"
|
||||||
"github.com/ryanhamamura/c4/router"
|
"github.com/ryanhamamura/games/router"
|
||||||
"github.com/ryanhamamura/c4/sessions"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -71,14 +71,14 @@ func run(ctx context.Context) error {
|
|||||||
defer cleanupNATS()
|
defer cleanupNATS()
|
||||||
|
|
||||||
// Game stores
|
// Game stores
|
||||||
store := game.NewGameStore(queries)
|
store := connect4.NewStore(queries)
|
||||||
store.SetNotifyFunc(func(gameID string) {
|
store.SetNotifyFunc(func(gameID string) {
|
||||||
nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification
|
nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
|
||||||
})
|
})
|
||||||
|
|
||||||
snakeStore := snake.NewSnakeStore(queries)
|
snakeStore := snake.NewSnakeStore(queries)
|
||||||
snakeStore.SetNotifyFunc(func(gameID string) {
|
snakeStore.SetNotifyFunc(func(gameID string) {
|
||||||
nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification
|
nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
|
||||||
})
|
})
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
|
|||||||
18
player/player.go
Normal file
18
player/player.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Package player provides shared identity types used across game packages.
|
||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID uniquely identifies a player within a session. For authenticated users
|
||||||
|
// this is their user UUID; for guests it's a random hex string.
|
||||||
|
type ID string
|
||||||
|
|
||||||
|
// GenerateID returns a random hex string of 2*size characters.
|
||||||
|
func GenerateID(size int) string {
|
||||||
|
b := make([]byte, size)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -7,14 +7,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/c4/features/auth"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/c4/features/c4game"
|
"github.com/ryanhamamura/games/features/auth"
|
||||||
"github.com/ryanhamamura/c4/features/lobby"
|
"github.com/ryanhamamura/games/features/c4game"
|
||||||
"github.com/ryanhamamura/c4/features/snakegame"
|
"github.com/ryanhamamura/games/features/lobby"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/features/snakegame"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -27,7 +27,7 @@ func SetupRoutes(
|
|||||||
queries *repository.Queries,
|
queries *repository.Queries,
|
||||||
sessions *scs.SessionManager,
|
sessions *scs.SessionManager,
|
||||||
nc *nats.Conn,
|
nc *nats.Conn,
|
||||||
store *game.GameStore,
|
store *connect4.Store,
|
||||||
snakeStore *snake.SnakeStore,
|
snakeStore *snake.SnakeStore,
|
||||||
assets embed.FS,
|
assets embed.FS,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Package sessions configures the SCS session manager backed by SQLite.
|
// Package sessions configures the SCS session manager and provides
|
||||||
|
// helpers for resolving player identity from the session.
|
||||||
package sessions
|
package sessions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,10 +8,19 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/games/player"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/sqlite3store"
|
"github.com/alexedwards/scs/sqlite3store"
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Session key names.
|
||||||
|
const (
|
||||||
|
KeyPlayerID = "player_id"
|
||||||
|
KeyUserID = "user_id"
|
||||||
|
KeyNickname = "nickname"
|
||||||
|
)
|
||||||
|
|
||||||
// SetupSessionManager creates a configured session manager backed by SQLite.
|
// SetupSessionManager creates a configured session manager backed by SQLite.
|
||||||
// Returns the manager and a cleanup function the caller should defer.
|
// Returns the manager and a cleanup function the caller should defer.
|
||||||
func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
||||||
@@ -20,7 +30,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
|||||||
sessionManager := scs.New()
|
sessionManager := scs.New()
|
||||||
sessionManager.Store = store
|
sessionManager.Store = store
|
||||||
sessionManager.Lifetime = 30 * 24 * time.Hour
|
sessionManager.Lifetime = 30 * 24 * time.Hour
|
||||||
sessionManager.Cookie.Name = "c4_session"
|
sessionManager.Cookie.Name = "games_session"
|
||||||
sessionManager.Cookie.Path = "/"
|
sessionManager.Cookie.Path = "/"
|
||||||
sessionManager.Cookie.HttpOnly = true
|
sessionManager.Cookie.HttpOnly = true
|
||||||
sessionManager.Cookie.Secure = true
|
sessionManager.Cookie.Secure = true
|
||||||
@@ -30,3 +40,28 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
|||||||
|
|
||||||
return sessionManager, cleanup
|
return sessionManager, cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPlayerID returns the current player's identity from the session.
|
||||||
|
// Authenticated users get their user UUID; guests get a random ID that
|
||||||
|
// is generated and persisted on first access.
|
||||||
|
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
|
||||||
|
pid := sm.GetString(r.Context(), KeyPlayerID)
|
||||||
|
if pid == "" {
|
||||||
|
pid = player.GenerateID(8)
|
||||||
|
sm.Put(r.Context(), KeyPlayerID, pid)
|
||||||
|
}
|
||||||
|
if userID := sm.GetString(r.Context(), KeyUserID); userID != "" {
|
||||||
|
return player.ID(userID)
|
||||||
|
}
|
||||||
|
return player.ID(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserID returns the authenticated user's UUID, or empty string for guests.
|
||||||
|
func GetUserID(sm *scs.SessionManager, r *http.Request) string {
|
||||||
|
return sm.GetString(r.Context(), KeyUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNickname returns the player's display name from the session.
|
||||||
|
func GetNickname(sm *scs.SessionManager, r *http.Request) string {
|
||||||
|
return sm.GetString(r.Context(), KeyNickname)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package snake
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
|
"github.com/ryanhamamura/games/player"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -122,19 +123,19 @@ func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
|
|||||||
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
|
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
|
||||||
players := make([]*Player, 0, len(rows))
|
players := make([]*Player, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
player := &Player{
|
p := &Player{
|
||||||
Nickname: row.Nickname,
|
Nickname: row.Nickname,
|
||||||
Slot: int(row.Slot),
|
Slot: int(row.Slot),
|
||||||
}
|
}
|
||||||
|
|
||||||
if row.UserID != nil {
|
if row.UserID != nil {
|
||||||
player.UserID = row.UserID
|
p.UserID = row.UserID
|
||||||
player.ID = PlayerID(*row.UserID)
|
p.ID = player.ID(*row.UserID)
|
||||||
} else if row.GuestPlayerID != nil {
|
} else if row.GuestPlayerID != nil {
|
||||||
player.ID = PlayerID(*row.GuestPlayerID)
|
p.ID = player.ID(*row.GuestPlayerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
players = append(players, player)
|
players = append(players, p)
|
||||||
}
|
}
|
||||||
return players
|
return players
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/games/player"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SnakeStore struct {
|
type SnakeStore struct {
|
||||||
@@ -38,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
|||||||
if speed <= 0 {
|
if speed <= 0 {
|
||||||
speed = DefaultSpeed
|
speed = DefaultSpeed
|
||||||
}
|
}
|
||||||
id := game.GenerateID(4)
|
id := player.GenerateID(4)
|
||||||
sg := &SnakeGame{
|
sg := &SnakeGame{
|
||||||
ID: id,
|
ID: id,
|
||||||
State: &GameState{
|
State: &GameState{
|
||||||
@@ -172,7 +172,7 @@ func (si *SnakeGameInstance) GetGame() *SnakeGame {
|
|||||||
return si.game.snapshot()
|
return si.game.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
|
func (si *SnakeGameInstance) GetPlayerSlot(pid player.ID) int {
|
||||||
si.gameMu.RLock()
|
si.gameMu.RLock()
|
||||||
defer si.gameMu.RUnlock()
|
defer si.gameMu.RUnlock()
|
||||||
for i, p := range si.game.Players {
|
for i, p := range si.game.Players {
|
||||||
|
|||||||
@@ -3,8 +3,19 @@ package snake
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/games/player"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SubjectPrefix is the NATS subject namespace for snake games.
|
||||||
|
const SubjectPrefix = "snake"
|
||||||
|
|
||||||
|
// GameSubject returns the NATS subject for game state updates.
|
||||||
|
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
|
||||||
|
|
||||||
|
// ChatSubject returns the NATS subject for chat messages.
|
||||||
|
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
|
||||||
|
|
||||||
type Direction int
|
type Direction int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -78,10 +89,8 @@ const (
|
|||||||
StatusFinished
|
StatusFinished
|
||||||
)
|
)
|
||||||
|
|
||||||
type PlayerID string
|
|
||||||
|
|
||||||
type Player struct {
|
type Player struct {
|
||||||
ID PlayerID
|
ID player.ID
|
||||||
UserID *string
|
UserID *string
|
||||||
Nickname string
|
Nickname string
|
||||||
Slot int // 0-7
|
Slot int // 0-7
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db"
|
"github.com/ryanhamamura/games/db"
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
|
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|||||||
Reference in New Issue
Block a user