Feat: works on my machine

This commit is contained in:
2025-10-16 05:07:56 +08:00
commit 5bbab63a2c
37 changed files with 4553 additions and 0 deletions

23
stages/base.go Normal file
View File

@@ -0,0 +1,23 @@
package stages
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/go-resty/resty/v2"
)
type BaseModel struct {
queue *[]*tea.Program
client *resty.Client
}
func NewBaseModel(
queue *[]*tea.Program,
endpoint string,
) *BaseModel {
return &BaseModel{
queue: queue,
client: resty.New().
SetBaseURL(endpoint).
SetDisableWarn(true),
}
}

241
stages/landing.go Normal file
View File

@@ -0,0 +1,241 @@
package stages
import (
"fmt"
"net/http"
"strings"
"gitea.konchin.com/ytshih/inp2025/models"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type landingOperationType int
const (
landingOperationChoose landingOperationType = iota
landingOperationLoginCred
landingOperationRegistCred
)
type focusTargetType int
const (
focusTargetUsername focusTargetType = iota
focusTargetPassword
)
type LandingModel struct {
*BaseModel
op landingOperationType
focus focusTargetType
username textinput.Model
password textinput.Model
info string
err error
}
func NewLandingModel(base *BaseModel) *LandingModel {
username := textinput.New()
username.Placeholder = "Username"
username.CharLimit = 32
username.Width = 32
username.Blur()
password := textinput.New()
password.Placeholder = "Password"
password.CharLimit = 32
password.Width = 32
password.Blur()
password.EchoMode = textinput.EchoPassword
password.EchoCharacter = '•'
return &LandingModel{
BaseModel: base,
op: landingOperationChoose,
username: username,
password: password,
}
}
func (m *LandingModel) reset() {
m.op = landingOperationChoose
m.username.Blur()
m.username.Reset()
m.password.Blur()
m.password.Reset()
}
type postLoginMsg struct{}
func (m *LandingModel) postLogin() tea.Cmd {
return func() tea.Msg {
resp, err := m.BaseModel.client.R().
SetBasicAuth(m.username.Value(), m.password.Value()).
Post("/auth/login")
if err == nil {
switch resp.StatusCode() {
case http.StatusOK:
m.BaseModel.client.SetBasicAuth(
m.username.Value(), m.password.Value())
m.info = "login success.\n"
m.err = nil
case http.StatusUnauthorized:
m.err = fmt.Errorf("user not exist or password incorrect, %s",
string(resp.Body()))
default:
m.err = fmt.Errorf("unknown server error, %s",
string(resp.Body()))
}
} else {
m.err = fmt.Errorf("failed to post login, %w", err)
}
return postLoginMsg{}
}
}
type postRegisterMsg struct{}
func (m *LandingModel) postRegister() tea.Cmd {
return func() tea.Msg {
resp, err := m.BaseModel.client.R().
SetBody(models.User{
Username: m.username.Value(),
Password: m.password.Value(),
}).
Post("/auth/register")
if err == nil {
switch resp.StatusCode() {
case http.StatusOK:
m.info = "register success.\n"
m.err = nil
case http.StatusBadRequest:
m.err = fmt.Errorf("username already exist, %s",
string(resp.Body()))
default:
m.err = fmt.Errorf("unknown server error, %s",
string(resp.Body()))
}
} else {
m.err = fmt.Errorf("failed to post register, %s",
string(resp.Body()))
}
return postRegisterMsg{}
}
}
func (m *LandingModel) Init() tea.Cmd {
return tea.ClearScreen
}
func (m *LandingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "L", "l":
if m.op == landingOperationChoose {
m.op = landingOperationLoginCred
m.focus = focusTargetUsername
cmds = append(cmds, m.username.Focus())
return m, tea.Batch(cmds...)
}
case "R", "r":
if m.op == landingOperationChoose {
m.op = landingOperationRegistCred
m.focus = focusTargetUsername
cmds = append(cmds, m.username.Focus())
return m, tea.Batch(cmds...)
}
case "shift+tab", "up":
if m.op == landingOperationLoginCred ||
m.op == landingOperationRegistCred {
switch m.focus {
case focusTargetUsername:
m.username.Blur()
m.focus = focusTargetPassword
cmds = append(cmds, m.password.Focus())
case focusTargetPassword:
m.password.Blur()
m.focus = focusTargetUsername
cmds = append(cmds, m.username.Focus())
}
}
case "tab", "down", "enter":
if m.op == landingOperationLoginCred ||
m.op == landingOperationRegistCred {
switch m.focus {
case focusTargetUsername:
m.username.Blur()
m.focus = focusTargetPassword
cmds = append(cmds, m.password.Focus())
case focusTargetPassword:
if msg.String() == "enter" {
switch m.op {
case landingOperationLoginCred:
cmds = append(cmds, m.postLogin())
case landingOperationRegistCred:
cmds = append(cmds, m.postRegister())
}
} else {
m.password.Blur()
m.focus = focusTargetUsername
cmds = append(cmds, m.username.Focus())
}
}
}
}
case postLoginMsg:
if m.err == nil {
*m.queue = append(*m.queue,
tea.NewProgram(NewLobbyModel(m.BaseModel)))
return m, tea.Quit
} else {
m.reset()
}
case postRegisterMsg:
m.reset()
}
var cmd tea.Cmd
m.username, cmd = m.username.Update(msg)
cmds = append(cmds, cmd)
m.password, cmd = m.password.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *LandingModel) View() string {
var b strings.Builder
switch m.op {
case landingOperationChoose:
fmt.Fprintf(&b, "Choose Operation\n(L)ogin / (R)egister\n")
case landingOperationLoginCred:
fmt.Fprintf(&b, "Login: \n")
b.WriteString(m.username.View() + "\n")
b.WriteString(m.password.View() + "\n")
case landingOperationRegistCred:
fmt.Fprintf(&b, "Register: \n")
b.WriteString(m.username.View() + "\n")
b.WriteString(m.password.View() + "\n")
}
if m.info != "" {
b.WriteString(m.info + "\n")
}
if m.err != nil {
b.WriteString("----------\n")
b.WriteString(m.err.Error() + "\n")
}
return b.String()
}

283
stages/lobby.go Normal file
View File

@@ -0,0 +1,283 @@
package stages
import (
"fmt"
"net"
"strings"
"time"
"gitea.konchin.com/ytshih/inp2025/types"
"gitea.konchin.com/ytshih/inp2025/utils"
"gitea.konchin.com/ytshih/inp2025/workflows"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/viper"
"github.com/vmihailenco/msgpack/v5"
)
const (
REFRESH_TIME = time.Second
)
type lobbyOperationType int
const (
lobbyOperationChoose lobbyOperationType = iota
lobbyOperationServerWaiting
lobbyOperationServerChoose
lobbyOperationClientScannning
)
type LobbyModel struct {
*BaseModel
op lobbyOperationType
shutdown types.ShutdownFunc
local string
remote string
// server
remoteUser string
listener net.Listener
// client
cursor int
endpoints []string
info string
err error
}
func NewLobbyModel(base *BaseModel) *LobbyModel {
return &LobbyModel{
BaseModel: base,
op: lobbyOperationChoose,
shutdown: func() {},
}
}
type serverListenMsg struct{}
func (m *LobbyModel) serverListen() tea.Cmd {
return func() tea.Msg {
dataCh := make(chan string)
m.local, m.shutdown, m.err = utils.ListenUDPData(
viper.GetInt("udp-listen-port"), dataCh)
if m.err != nil {
m.err = fmt.Errorf("failed to listen, %w", m.err)
return serverListenMsg{}
}
req := <-dataCh
m.shutdown()
m.shutdown = func() {}
var joinRequest types.JoinRequest
if err := msgpack.Unmarshal([]byte(req), &joinRequest); err != nil {
m.err = fmt.Errorf("failed to unmarshal msgpack, %w", err)
return serverListenMsg{}
}
m.remote = joinRequest.Endpoint
m.remoteUser = joinRequest.Username
return serverListenMsg{}
}
}
type serverSendReplyMsg struct{}
func (m *LobbyModel) serverSendReply(response bool) tea.Cmd {
return func() tea.Msg {
if response {
// Start Wordle Server listener
m.listener, m.err = net.Listen("tcp4", ":0")
if m.err != nil {
m.err = fmt.Errorf("failed to listen on anonymous port, %w", m.err)
return serverSendReplyMsg{}
}
local := fmt.Sprintf("%s:%d",
m.listener.Addr().(*net.TCPAddr).IP.String(),
m.listener.Addr().(*net.TCPAddr).Port)
m.err = utils.SendPayload(local, m.remote,
types.JoinResponse{Endpoint: local})
// Store wordle server endpoint
m.remote = local
} else {
m.err = utils.SendPayload("", m.remote,
types.JoinResponse{Endpoint: ""})
}
return serverSendReplyMsg{}
}
}
type clientScanMsg time.Time
func (m *LobbyModel) clientScan() tea.Cmd {
return tea.Tick(REFRESH_TIME, func(t time.Time) tea.Msg {
m.endpoints = []string{}
for _, endpoint := range viper.GetStringSlice("udp-endpoints") {
if err := utils.Ping(endpoint); err == nil {
m.endpoints = append(m.endpoints, endpoint)
}
}
return clientScanMsg(t)
})
}
type clientJoinMsg struct{}
func (m *LobbyModel) clientJoin() tea.Cmd {
return func() tea.Msg {
dataCh := make(chan string)
m.local, m.shutdown, m.err = utils.ListenUDPData(0, dataCh)
if m.err != nil {
m.err = fmt.Errorf("failed to listen udp data, %w", m.err)
return clientJoinMsg{}
}
m.err = utils.SendPayload(m.local, m.remote, types.JoinRequest{
Endpoint: m.local,
Username: m.client.UserInfo.Username,
})
if m.err != nil {
m.err = fmt.Errorf("failed to send invitation, %w", m.err)
return clientJoinMsg{}
}
data := <-dataCh
m.shutdown()
m.shutdown = func() {}
var joinResponse types.JoinResponse
if err := msgpack.Unmarshal([]byte(data), &joinResponse); err != nil {
m.err = fmt.Errorf("failed to unmarshal msgpack, %w", err)
return clientJoinMsg{}
}
m.remote = joinResponse.Endpoint
return clientJoinMsg{}
}
}
func (m *LobbyModel) Init() tea.Cmd {
return tea.ClearScreen
}
func (m *LobbyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
}
}
switch m.op {
case lobbyOperationChoose:
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "S", "s":
m.op = lobbyOperationServerWaiting
cmds = append(cmds, m.serverListen())
case "C", "c":
m.op = lobbyOperationClientScannning
cmds = append(cmds, m.clientScan())
}
}
case lobbyOperationServerWaiting:
switch msg.(type) {
case serverListenMsg:
m.op = lobbyOperationServerChoose
}
case lobbyOperationServerChoose:
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "Y", "y", "enter":
cmds = append(cmds, m.serverSendReply(true))
case "N", "n":
m.op = lobbyOperationServerWaiting
cmds = append(cmds, m.serverSendReply(false))
}
case serverSendReplyMsg:
if m.err == nil {
m.shutdown()
m.client.SetBaseURL("http://" + m.remote)
shutdown := workflows.WordleServer(m.listener)
*m.queue = append(*m.queue,
tea.NewProgram(NewWordleClientModel(m.BaseModel, shutdown)))
return m, tea.Quit
} else {
m.op = lobbyOperationServerWaiting
}
}
case lobbyOperationClientScannning:
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "shift+tab", "up":
if n := len(m.endpoints); n > 0 {
m.cursor = (m.cursor - 1 + n) % n
}
case "tab", "down":
if n := len(m.endpoints); n > 0 {
m.cursor = (m.cursor + 1) % n
}
case "enter":
m.remote = m.endpoints[m.cursor]
cmds = append(cmds, m.clientJoin())
}
case clientScanMsg:
cmds = append(cmds, m.clientScan())
case clientJoinMsg:
if m.err == nil && m.remote != "" {
m.shutdown()
m.client.SetBaseURL("http://" + m.remote)
*m.BaseModel.queue = append(*m.BaseModel.queue,
tea.NewProgram(NewWordleClientModel(m.BaseModel, func() {})))
return m, tea.Quit
}
}
}
return m, tea.Batch(cmds...)
}
func (m *LobbyModel) View() string {
var b strings.Builder
switch m.op {
case lobbyOperationChoose:
b.WriteString("Choose Role\n(S)erver / (C)lient\n")
case lobbyOperationServerWaiting:
b.WriteString("Wait for user to join...\n")
case lobbyOperationServerChoose:
fmt.Fprintf(&b, "Receive Join Request by '%s'\nAccept (Y)/N\n",
m.remoteUser)
case lobbyOperationClientScannning:
b.WriteString("Scanning server...\nChoose one to join\n")
for i, endpoint := range m.endpoints {
if i == m.cursor {
fmt.Fprintf(&b, "(•) %s\n", endpoint)
} else {
fmt.Fprintf(&b, "( ) %s\n", endpoint)
}
}
}
if m.info != "" {
b.WriteString(m.info + "\n")
}
if m.err != nil {
b.WriteString("----------\n")
b.WriteString(m.err.Error() + "\n")
}
return b.String()
}

1
stages/udp.go Normal file
View File

@@ -0,0 +1 @@
package stages

179
stages/wordle.go Normal file
View File

@@ -0,0 +1,179 @@
package stages
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"gitea.konchin.com/ytshih/inp2025/handlers/wordle"
"gitea.konchin.com/ytshih/inp2025/types"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/gorilla/websocket"
"github.com/vmihailenco/msgpack/v5"
)
const (
WEBSOCKET_RETRY int = 5
WEBSOCKET_BACKOFF time.Duration = 100 * time.Millisecond
)
type WordleClientModel struct {
*BaseModel
conn *websocket.Conn
state types.WordleState
shutdown types.ShutdownFunc
input textinput.Model
err error
}
func NewWordleClientModel(
base *BaseModel,
shutdown types.ShutdownFunc,
) *WordleClientModel {
input := textinput.New()
input.Focus()
input.CharLimit = types.GUESS_WORD_LENGTH
input.Width = types.GUESS_WORD_LENGTH
return &WordleClientModel{
BaseModel: base,
input: input,
shutdown: shutdown,
}
}
func (m *WordleClientModel) getState() tea.Cmd {
return func() tea.Msg {
if m.conn == nil {
u, err := url.Parse(m.client.BaseURL)
if err != nil {
m.err = fmt.Errorf("failed to parse BaseURL, %w", err)
}
u.Scheme = "ws"
u.Path = "/api/state"
for try := 0; try < WEBSOCKET_RETRY; try++ {
req, _ := http.NewRequest("GET",
/*placeholder*/ "http://localhost", nil)
req.SetBasicAuth(
m.client.UserInfo.Username,
m.client.UserInfo.Password)
m.conn, _, err = websocket.DefaultDialer.Dial(
u.String(), req.Header)
if err == nil {
break
}
time.Sleep(WEBSOCKET_BACKOFF)
}
if err != nil {
m.err = fmt.Errorf("failed to dial, %w", err)
}
}
_, b, err := m.conn.ReadMessage()
if err == nil {
var state types.WordleState
if err := msgpack.Unmarshal(b, &state); err != nil {
m.err = fmt.Errorf("failed to unmarshal state, %w", err)
}
return state
} else {
m.err = fmt.Errorf("failed to read message, %w", err)
return nil
}
}
}
func (m *WordleClientModel) postGuess(guess string) tea.Cmd {
return func() tea.Msg {
b, err := msgpack.Marshal(wordle.OperationGuess{
Username: m.client.UserInfo.Username,
Guess: guess,
})
if err != nil {
m.err = fmt.Errorf("failed to post guess, %w", err)
}
_, err = m.client.R().
SetBody(b).
Post("/api/guess")
if err != nil {
m.err = fmt.Errorf("failed to post guess, %w", err)
}
return nil
}
}
func (m *WordleClientModel) Init() tea.Cmd {
return tea.Sequence(tea.ClearScreen,
tea.Batch(m.getState(), textinput.Blink))
}
func (m *WordleClientModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
m.conn.Close()
m.shutdown()
return m, tea.Quit
case "enter":
if m.state.GameEnd {
m.conn.Close()
m.shutdown()
*m.queue = append(*m.queue,
tea.NewProgram(NewLobbyModel(m.BaseModel)))
return m, tea.Quit
}
if len(m.input.Value()) == types.GUESS_WORD_LENGTH {
input := strings.ToUpper(m.input.Value())
m.input.Reset()
cmds = append(cmds, m.postGuess(input))
}
}
case types.WordleState:
m.state = msg
if m.state.GameEnd {
m.input.Blur()
}
if _, ok := m.state.CurrentGuess[m.client.UserInfo.Username]; ok {
m.input.Blur()
} else {
cmds = append(cmds, m.input.Focus())
}
cmds = append(cmds, m.getState())
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *WordleClientModel) View() string {
var b strings.Builder
fmt.Fprintf(&b, "Login as '%s'\n", m.client.UserInfo.Username)
fmt.Fprintf(&b, "remote addr: %s\n", m.client.BaseURL)
b.WriteString(m.state.View() + "\n")
b.WriteString(types.NewWordBank(m.state).View() + "\n")
if m.state.GameEnd {
fmt.Fprintf(&b, "Game End. Press 'Enter' to lobby.\n")
} else {
if guess, ok := m.state.CurrentGuess[m.client.UserInfo.Username]; ok {
fmt.Fprintf(&b, "Current guess for the round: %s\n", guess)
} else {
fmt.Fprintf(&b, "guess: %s\n", m.input.View())
}
if m.err != nil {
fmt.Fprintf(&b, "error: %+v\n", m.err)
}
}
return b.String()
}