Files
games/main.go
Ryan Hamamura 91b5f2b80c
All checks were successful
Deploy c4 / deploy (push) Successful in 57s
deps: update ryanhamamura/via to v0.23.0
Remove ContextSuspendAfter and ContextTTL options, which were
deleted upstream. Contexts now persist until SSE close beacon
or server shutdown.
2026-02-20 12:06:51 -10:00

789 lines
19 KiB
Go

package main
import (
"context"
"crypto/md5"
"database/sql"
"embed"
"encoding/hex"
"encoding/json"
"io/fs"
"log"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/ryanhamamura/c4/auth"
"github.com/ryanhamamura/c4/db"
"github.com/ryanhamamura/c4/db/gen"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/c4/ui"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var (
store = game.NewGameStore()
snakeStore = snake.NewSnakeStore()
queries *gen.Queries
chatPersister *db.ChatPersister
)
//go:embed assets
var assets embed.FS
func DaisyUIPlugin(v *via.V) {
css, _ := fs.ReadFile(assets, "assets/css/output.css")
sum := md5.Sum(css)
version := hex.EncodeToString(sum[:4])
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/assets/css/output.css?v="+version)))
}
func port() string {
if p := os.Getenv("PORT"); p != "" {
return p
}
return "7331"
}
func main() {
_ = godotenv.Load()
if err := os.MkdirAll("data", 0o755); err != nil {
log.Fatal(err)
}
if err := db.Init("data/c4.db"); err != nil {
log.Fatal(err)
}
queries = gen.New(db.DB)
store.SetPersister(db.NewGamePersister(queries))
snakeStore.SetPersister(db.NewSnakePersister(queries))
chatPersister = db.NewChatPersister(queries)
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
if err != nil {
log.Fatal(err)
}
v := via.New()
v.Config(via.Options{
LogLevel: via.LogLevelDebug,
DocumentTitle: "Game Lobby",
ServerAddress: ":" + port(),
SessionManager: sessionManager,
Plugins: []via.Plugin{DaisyUIPlugin},
})
subFS, _ := fs.Sub(assets, "assets")
v.StaticFS("/assets/", subFS)
store.SetPubSub(v.PubSub())
snakeStore.SetPubSub(v.PubSub())
// Home page - tabbed lobby
v.Page("/", func(c *via.Context) {
userID := c.Session().GetString("user_id")
username := c.Session().GetString("username")
isLoggedIn := userID != ""
var userGames []ui.GameListItem
if isLoggedIn {
ctx := context.Background()
games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true})
if err == nil {
for _, g := range games {
isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor
userGames = append(userGames, ui.GameListItem{
ID: g.ID,
Status: int(g.Status),
OpponentName: g.OpponentNickname.String,
IsMyTurn: isMyTurn,
LastPlayed: g.UpdatedAt.Time,
})
}
}
}
nickname := c.Signal("")
if isLoggedIn {
nickname = c.Signal(username)
}
activeTab := c.Signal("connect4")
logout := c.Action(func() {
c.Session().Clear()
c.Redirect("/")
})
createGame := c.Action(func() {
name := nickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
gi := store.Create()
c.Redirectf("/game/%s", gi.ID())
})
deleteGame := func(id string) h.H {
return c.Action(func() {
for _, g := range userGames {
if g.ID == id {
store.Delete(id)
break
}
}
c.Redirect("/")
}).OnClick()
}
tabClickConnect4 := c.Action(func() {
activeTab.SetValue("connect4")
c.Sync()
})
tabClickSnake := c.Action(func() {
activeTab.SetValue("snake")
c.Sync()
})
snakeNickname := c.Signal("")
if isLoggedIn {
snakeNickname = c.Signal(username)
}
// Speed selection signal (index into SpeedPresets, default to Normal which is index 1)
selectedSpeedIndex := c.Signal(1)
// Speed selector actions
var speedSelectClicks []h.H
for i := range snake.SpeedPresets {
idx := i
speedSelectClicks = append(speedSelectClicks, c.Action(func() {
selectedSpeedIndex.SetValue(idx)
c.Sync()
}).OnClick())
}
// Snake create game actions — one per preset for solo and multiplayer
var snakeSoloClicks []h.H
var snakeMultiClicks []h.H
for _, preset := range snake.GridPresets {
w, ht := preset.Width, preset.Height
snakeSoloClicks = append(snakeSoloClicks, c.Action(func() {
name := snakeNickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
speedIdx := selectedSpeedIndex.Int()
speed := snake.DefaultSpeed
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
speed = snake.SpeedPresets[speedIdx].Speed
}
si := snakeStore.Create(w, ht, snake.ModeSinglePlayer, speed)
c.Redirectf("/snake/%s", si.ID())
}).OnClick())
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
name := snakeNickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
speedIdx := selectedSpeedIndex.Int()
speed := snake.DefaultSpeed
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
speed = snake.SpeedPresets[speedIdx].Speed
}
si := snakeStore.Create(w, ht, snake.ModeMultiplayer, speed)
c.Redirectf("/snake/%s", si.ID())
}).OnClick())
}
c.View(func() h.H {
return ui.LobbyView(ui.LobbyProps{
NicknameBind: nickname.Bind(),
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
CreateGameClick: createGame.OnClick(),
IsLoggedIn: isLoggedIn,
Username: username,
LogoutClick: logout.OnClick(),
UserGames: userGames,
DeleteGameClick: deleteGame,
ActiveTab: activeTab.String(),
TabClickConnect4: tabClickConnect4.OnClick(),
TabClickSnake: tabClickSnake.OnClick(),
SnakeNicknameBind: snakeNickname.Bind(),
SnakeSoloClicks: snakeSoloClicks,
SnakeMultiClicks: snakeMultiClicks,
ActiveSnakeGames: snakeStore.ActiveGames(),
SelectedSpeedIndex: selectedSpeedIndex.Int(),
SpeedSelectClicks: speedSelectClicks,
})
})
})
// Login page
v.Page("/login", func(c *via.Context) {
username := c.Signal("")
password := c.Signal("")
errorMsg := c.Signal("")
login := c.Action(func() {
ctx := context.Background()
user, err := queries.GetUserByUsername(ctx, username.String())
if err == sql.ErrNoRows {
errorMsg.SetValue("Invalid username or password")
c.Sync()
return
}
if err != nil {
errorMsg.SetValue("An error occurred")
c.Sync()
return
}
if !auth.CheckPassword(password.String(), user.PasswordHash) {
errorMsg.SetValue("Invalid username or password")
c.Sync()
return
}
c.Session().RenewToken()
c.Session().Set("user_id", user.ID)
c.Session().Set("username", user.Username)
c.Session().Set("nickname", user.Username)
returnURL := c.Session().GetString("return_url")
if returnURL != "" {
c.Session().Set("return_url", "")
c.Redirect(returnURL)
} else {
c.Redirect("/")
}
})
c.View(func() h.H {
return ui.LoginView(
username.Bind(),
password.Bind(),
login.OnKeyDown("Enter"),
login.OnClick(),
errorMsg.String(),
)
})
})
// Register page
v.Page("/register", func(c *via.Context) {
username := c.Signal("")
password := c.Signal("")
confirm := c.Signal("")
errorMsg := c.Signal("")
register := c.Action(func() {
if err := auth.ValidateUsername(username.String()); err != nil {
errorMsg.SetValue(err.Error())
c.Sync()
return
}
if err := auth.ValidatePassword(password.String()); err != nil {
errorMsg.SetValue(err.Error())
c.Sync()
return
}
if password.String() != confirm.String() {
errorMsg.SetValue("Passwords do not match")
c.Sync()
return
}
hash, err := auth.HashPassword(password.String())
if err != nil {
errorMsg.SetValue("An error occurred")
c.Sync()
return
}
ctx := context.Background()
id := uuid.New().String()
user, err := queries.CreateUser(ctx, gen.CreateUserParams{
ID: id,
Username: username.String(),
PasswordHash: hash,
})
if err != nil {
errorMsg.SetValue("Username already taken")
c.Sync()
return
}
c.Session().RenewToken()
c.Session().Set("user_id", user.ID)
c.Session().Set("username", user.Username)
c.Session().Set("nickname", user.Username)
returnURL := c.Session().GetString("return_url")
if returnURL != "" {
c.Session().Set("return_url", "")
c.Redirect(returnURL)
} else {
c.Redirect("/")
}
})
c.View(func() h.H {
return ui.RegisterView(
username.Bind(),
password.Bind(),
confirm.Bind(),
register.OnKeyDown("Enter"),
register.OnClick(),
errorMsg.String(),
)
})
})
// Connect 4 game page
v.Page("/game/{game_id}", func(c *via.Context) {
gameID := c.GetPathParam("game_id")
sessionNickname := c.Session().GetString("nickname")
sessionUserID := c.Session().GetString("user_id")
nickname := c.Signal(sessionNickname)
colSignal := c.Signal(0)
showGuestPrompt := c.Signal(false)
chatMsg := c.Signal("")
chatMessages, _ := chatPersister.LoadChatMessages(gameID)
var chatMu sync.Mutex
goToLogin := c.Action(func() {
c.Session().Set("return_url", "/game/"+gameID)
c.Redirect("/login")
})
goToRegister := c.Action(func() {
c.Session().Set("return_url", "/game/"+gameID)
c.Redirect("/register")
})
continueAsGuest := c.Action(func() {
showGuestPrompt.SetValue(true)
c.Sync()
})
var gi *game.GameInstance
var gameExists bool
if gameID != "" {
gi, gameExists = store.Get(gameID)
}
playerID := game.PlayerID(c.Session().GetString("player_id"))
if playerID == "" {
playerID = game.PlayerID(game.GenerateID(8))
c.Session().Set("player_id", string(playerID))
}
if sessionUserID != "" {
playerID = game.PlayerID(sessionUserID)
}
setNickname := c.Action(func() {
if gi == nil {
return
}
name := nickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
ID: playerID,
Nickname: name,
}
if sessionUserID != "" {
player.UserID = &sessionUserID
}
gi.Join(&game.PlayerSession{
Player: player,
})
}
c.Sync()
})
dropPiece := c.Action(func() {
if gi == nil {
return
}
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
return
}
col := colSignal.Int()
gi.DropPiece(col, myColor)
c.Sync()
})
createRematch := c.Action(func() {
if gi == nil {
return
}
newGI := gi.CreateRematch(store)
if newGI != nil {
c.Redirectf("/game/%s", newGI.ID())
}
})
sendChat := c.Action(func() {
msg := chatMsg.String()
if msg == "" || gi == nil {
return
}
color := gi.GetPlayerColor(playerID)
if color == 0 {
return
}
g := gi.GetGame()
nick := ""
for _, p := range g.Players {
if p != nil && p.ID == playerID {
nick = p.Nickname
break
}
}
cm := ui.C4ChatMessage{
Nickname: nick,
Color: color,
Message: msg,
Time: time.Now().UnixMilli(),
}
chatPersister.SaveChatMessage(gameID, cm)
data, err := json.Marshal(cm)
if err != nil {
return
}
c.Publish("game.chat."+gameID, data)
chatMsg.SetValue("")
})
if gameExists {
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
c.Subscribe("game.chat."+gameID, func(data []byte) {
var cm ui.C4ChatMessage
if err := json.Unmarshal(data, &cm); err != nil {
return
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
chatMu.Unlock()
c.Sync()
})
}
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
ID: playerID,
Nickname: sessionNickname,
}
if sessionUserID != "" {
player.UserID = &sessionUserID
}
gi.Join(&game.PlayerSession{
Player: player,
})
}
c.View(func() h.H {
if !gameExists {
c.Redirect("/")
return h.Div()
}
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
if sessionUserID == "" && !showGuestPrompt.Bool() {
return ui.GameJoinPrompt(
goToLogin.OnClick(),
continueAsGuest.OnClick(),
goToRegister.OnClick(),
)
}
return ui.NicknamePrompt(
nickname.Bind(),
setNickname.OnKeyDown("Enter"),
setNickname.OnClick(),
)
}
g := gi.GetGame()
columnClick := func(col int) h.H {
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
}
chatMu.Lock()
msgs := make([]ui.C4ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
chat := ui.C4Chat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))
var content []h.H
content = append(content,
ui.BackToLobby(),
ui.StealthTitle("text-3xl font-bold"),
ui.PlayerInfo(g, myColor),
ui.StatusBanner(g, myColor, createRematch.OnClick()),
h.Div(h.Class("c4-game-area"), ui.BoardComponent(g, columnClick, myColor), chat),
)
if g.Status == game.StatusWaitingForPlayer {
content = append(content, ui.InviteLink(g.ID))
}
mainAttrs := []h.H{h.Class("flex flex-col items-center gap-4 p-4")}
mainAttrs = append(mainAttrs, content...)
return h.Main(mainAttrs...)
})
})
// Snake game page
v.Page("/snake/{game_id}", func(c *via.Context) {
gameID := c.GetPathParam("game_id")
sessionNickname := c.Session().GetString("nickname")
sessionUserID := c.Session().GetString("user_id")
nickname := c.Signal(sessionNickname)
showGuestPrompt := c.Signal(false)
goToLogin := c.Action(func() {
c.Session().Set("return_url", "/snake/"+gameID)
c.Redirect("/login")
})
goToRegister := c.Action(func() {
c.Session().Set("return_url", "/snake/"+gameID)
c.Redirect("/register")
})
continueAsGuest := c.Action(func() {
showGuestPrompt.SetValue(true)
c.Sync()
})
var si *snake.SnakeGameInstance
var gameExists bool
if gameID != "" {
si, gameExists = snakeStore.Get(gameID)
}
playerID := snake.PlayerID(c.Session().GetString("player_id"))
if playerID == "" {
pid := game.GenerateID(8)
playerID = snake.PlayerID(pid)
c.Session().Set("player_id", pid)
}
if sessionUserID != "" {
playerID = snake.PlayerID(sessionUserID)
}
setNickname := c.Action(func() {
if si == nil {
return
}
name := nickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
ID: playerID,
Nickname: name,
}
if sessionUserID != "" {
player.UserID = &sessionUserID
}
si.Join(player)
}
c.Sync()
})
// Direction input: single action with a direction signal
dirSignal := c.Signal(-1)
handleDir := c.Action(func() {
if si == nil {
return
}
slot := si.GetPlayerSlot(playerID)
if slot < 0 {
return
}
dir := snake.Direction(dirSignal.Int())
si.SetDirection(slot, dir)
})
createRematch := c.Action(func() {
if si == nil {
return
}
newSI := si.CreateRematch()
if newSI != nil {
c.Redirectf("/snake/%s", newSI.ID())
}
})
chatMsg := c.Signal("")
var chatMessages []ui.ChatMessage
var chatMu sync.Mutex
sendChat := c.Action(func() {
msg := chatMsg.String()
if msg == "" || si == nil {
return
}
slot := si.GetPlayerSlot(playerID)
if slot < 0 {
return
}
cm := ui.ChatMessage{
Nickname: si.GetGame().Players[slot].Nickname,
Slot: slot,
Message: msg,
Time: time.Now().UnixMilli(),
}
data, err := json.Marshal(cm)
if err != nil {
return
}
c.Publish("snake.chat."+gameID, data)
chatMsg.SetValue("")
})
if gameExists {
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() })
if si.GetGame().Mode == snake.ModeMultiplayer {
c.Subscribe("snake.chat."+gameID, func(data []byte) {
var cm ui.ChatMessage
if err := json.Unmarshal(data, &cm); err != nil {
return
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
chatMu.Unlock()
c.Sync()
})
}
}
// Auto-join if nickname exists
if gameExists && sessionNickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
ID: playerID,
Nickname: sessionNickname,
}
if sessionUserID != "" {
player.UserID = &sessionUserID
}
si.Join(player)
}
c.View(func() h.H {
if !gameExists {
c.Redirect("/")
return h.Div()
}
mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 {
if sessionUserID == "" && !showGuestPrompt.Bool() {
return ui.GameJoinPrompt(
goToLogin.OnClick(),
continueAsGuest.OnClick(),
goToRegister.OnClick(),
)
}
return ui.NicknamePrompt(
nickname.Bind(),
setNickname.OnKeyDown("Enter"),
setNickname.OnClick(),
)
}
sg := si.GetGame()
var content []h.H
content = append(content,
ui.BackToLobby(),
h.H1(h.Class("text-3xl font-bold"), h.Text("~~~~")),
ui.SnakePlayerList(sg, mySlot),
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
)
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
board := ui.SnakeBoard(sg)
if sg.Mode == snake.ModeMultiplayer {
chatMu.Lock()
msgs := make([]ui.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
chat := ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))
content = append(content, h.Div(h.Class("snake-game-area"), board, chat))
} else {
content = append(content, board)
}
} else if sg.Mode == snake.ModeMultiplayer {
// Show chat even before game starts (waiting/countdown)
chatMu.Lock()
msgs := make([]ui.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
content = append(content, ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")))
}
// Only show invite link for multiplayer games
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
content = append(content, ui.SnakeInviteLink(sg.ID))
}
wrapperAttrs := []h.H{
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
via.OnKeyDownMap(
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithThrottle(100*time.Millisecond)),
via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithThrottle(100*time.Millisecond)),
via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithThrottle(100*time.Millisecond)),
via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithThrottle(100*time.Millisecond)),
via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
),
}
wrapperAttrs = append(wrapperAttrs, content...)
return h.Main(wrapperAttrs...)
})
})
v.Start()
}