package main import ( "context" "database/sql" _ "embed" "log" "net/http" "os" "github.com/google/uuid" "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" "github.com/ryanhamamura/via/vianats" ) var ( store = game.NewGameStore() snakeStore = snake.NewSnakeStore() queries *gen.Queries ) //go:embed assets/css/output.css var daisyUICSS []byte func DaisyUIPlugin(v *via.V) { v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") _, _ = w.Write(daisyUICSS) }) v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/_plugins/daisyui/style.css"))) } func port() string { if p := os.Getenv("PORT"); p != "" { return p } return "7331" } func main() { if err := db.Init("c4.db"); err != nil { log.Fatal(err) } queries = gen.New(db.DB) store.SetPersister(db.NewGamePersister(queries)) snakeStore.SetPersister(db.NewSnakePersister(queries)) sessionManager, err := via.NewSQLiteSessionManager(db.DB) if err != nil { log.Fatal(err) } ctx := context.Background() ns, err := vianats.New(ctx, "./data/nats") if err != nil { log.Fatal(err) } store.SetPubSub(ns) snakeStore.SetPubSub(ns) v := via.New() v.Config(via.Options{ LogLevel: via.LogLevelDebug, DocumentTitle: "Game Lobby", ServerAddress: ":" + port(), SessionManager: sessionManager, PubSub: ns, Plugins: []via.Plugin{DaisyUIPlugin}, }) // 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) } // Snake create game actions — one per preset var snakePresetClicks []h.H for _, preset := range snake.GridPresets { w, ht := preset.Width, preset.Height snakePresetClicks = append(snakePresetClicks, c.Action(func() { name := snakeNickname.String() if name == "" { return } c.Session().Set("nickname", name) si := snakeStore.Create(w, ht) 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(), SnakePresetClicks: snakePresetClicks, ActiveSnakeGames: snakeStore.ActiveGames(), }) }) }) // 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().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().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) 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()) } }) if gameExists { c.Subscribe("game."+gameID, func(data []byte) { 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)) } var content []h.H content = append(content, h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")), ui.PlayerInfo(g, myColor), ui.StatusBanner(g, myColor, createRematch.OnClick()), ui.BoardComponent(g, columnClick, myColor), ) 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()) } }) if gameExists { c.Subscribe("snake."+gameID, func(data []byte) { 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, h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")), ui.SnakePlayerList(sg, mySlot), ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()), ) if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { content = append(content, ui.SnakeBoard(sg)) } if 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.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft))), via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown))), via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight))), via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault()), via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault()), via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault()), via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault()), ), } wrapperAttrs = append(wrapperAttrs, content...) return h.Main(wrapperAttrs...) }) }) v.Start() }