package main import ( "context" "database/sql" _ "embed" "log" "net/http" "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/ui" "github.com/ryanhamamura/via" "github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/vianats" ) var store = game.NewGameStore() var 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 main() { if err := db.Init("c4.db"); err != nil { log.Fatal(err) } queries = gen.New(db.DB) store.SetPersister(db.NewGamePersister(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) v := via.New() v.Config(via.Options{ LogLevel: via.LogLevelDebug, DocumentTitle: "Connect 4", ServerAddress: ":7331", SessionManager: sessionManager, PubSub: ns, Plugins: []via.Plugin{DaisyUIPlugin}, }) // Home page - enter nickname and create game 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) } 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() } c.View(func() h.H { return ui.LobbyView( nickname.Bind(), createGame.OnKeyDown("Enter"), createGame.OnClick(), isLoggedIn, username, logout.OnClick(), userGames, deleteGame, ) }) }) // 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(), ) }) }) // 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 // Look up game (may not exist during warmup or invalid ID) if gameID != "" { gi, gameExists = store.Get(gameID) } // Generate a stable player ID for this session playerID := game.PlayerID(c.Session().GetString("player_id")) if playerID == "" { playerID = game.PlayerID(game.GenerateID(8)) c.Session().Set("player_id", string(playerID)) } // Use user_id as player_id if logged in 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) // Try to join if not already in game 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()) } }) // Subscribe to game updates so the opponent's moves trigger a re-render if gameExists { c.Subscribe("game."+gameID, func(data []byte) { c.Sync() }) } // If nickname exists in session and game exists, join immediately 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 { // Game not found - redirect to home if !gameExists { c.Redirect("/") return h.Div() } myColor := gi.GetPlayerColor(playerID) // Need nickname first / not joined yet if myColor == 0 { // Unauthenticated user who hasn't chosen to continue as guest 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() // Create column click function 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), ) // Show invite link when waiting for opponent 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...) }) }) v.Start() }