mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-18 12:14:35 +00:00
feat(wsapi): add websocket server with rooms, turn timers, and bot play
One goroutine owns each room and its engine. The room goroutine starts before anyone is seated and seating is itself a message, so reading run() is a complete proof of the concurrency contract rather than a convention to uphold. The bot searches a frozen copy of the board instead of the live engine. Reads carry no deadline; liveness is ping-based, because a read timeout cannot distinguish a healthy player idling in the lobby from a dead socket. The hub no longer binds a joiner to a seat before the room decides whether to seat them. Anyone holding a room code could previously resign or play on a seated player's behalf, and the room code is the only credential online 1v1 has. cmd/noitu-server serves the API and, when NOITU_WEB_DIR is set, the built frontend, with unknown paths falling back to index.html for client routes. All configuration is environment-only and every variable has a working default.
This commit is contained in:
1 parent
48b3b3c8ac
commit
5935f5c879
15 files changed
+3486
-22
No files matched your search
@@ -0,0 +1,99 @@
|
||||
package wsapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// maxFrameBytes caps what a client may send.
|
||||
//
|
||||
// The largest legitimate client frame is a Hello carrying a 20-rune nickname,
|
||||
// well under 200 bytes. 4 KiB leaves room for the protocol to grow while still
|
||||
// making a memory-exhaustion attempt fail at the socket rather than in the
|
||||
// decoder.
|
||||
const maxFrameBytes = 4096
|
||||
|
||||
// ErrNotBinary rejects text frames. The protocol is protobuf over binary
|
||||
// frames only; a text frame means the peer is speaking something else, and
|
||||
// guessing at it is how a parser becomes an attack surface.
|
||||
var ErrNotBinary = errors.New("wsapi: expected a binary frame")
|
||||
|
||||
// Encode marshals a server message for the wire.
|
||||
func Encode(m *noituv1.ServerMessage) ([]byte, error) {
|
||||
raw, err := proto.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wsapi: encode: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// Decode parses a client frame.
|
||||
//
|
||||
// typ is checked here rather than at the call site so every entry point gets
|
||||
// the same rejection.
|
||||
func Decode(typ websocket.MessageType, raw []byte) (*noituv1.ClientMessage, error) {
|
||||
if typ != websocket.MessageBinary {
|
||||
return nil, ErrNotBinary
|
||||
}
|
||||
var m noituv1.ClientMessage
|
||||
if err := proto.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("wsapi: decode: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Message-building helpers. Every server message is constructed through one of
|
||||
// these, so the oneof wrapper is written once per variant instead of at each
|
||||
// call site.
|
||||
|
||||
func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{
|
||||
SessionId: sessionID,
|
||||
ResumeToken: resumeToken,
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
AcceptedNickname: nickname,
|
||||
}}}
|
||||
}
|
||||
|
||||
func roomCreatedMsg(code string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomCreated{
|
||||
RoomCreated: &noituv1.RoomCreated{RoomCode: code},
|
||||
}}
|
||||
}
|
||||
|
||||
func roomJoinedMsg(code, opponent string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomJoined{
|
||||
RoomJoined: &noituv1.RoomJoined{RoomCode: code, OpponentName: opponent},
|
||||
}}
|
||||
}
|
||||
|
||||
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{
|
||||
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq},
|
||||
}}
|
||||
}
|
||||
|
||||
func opponentLeftMsg(canReconnect bool, graceMs uint32) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_OpponentLeft{
|
||||
OpponentLeft: &noituv1.OpponentLeft{CanReconnect: canReconnect, GraceMs: graceMs},
|
||||
}}
|
||||
}
|
||||
|
||||
// errorMsg carries a UI key, never prose and never an internal error string.
|
||||
// The Vietnamese copy lives in the frontend so all wording stays in one place,
|
||||
// and a raw error would leak server internals to anyone with a socket.
|
||||
func errorMsg(code string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Error{
|
||||
Error: &noituv1.ServerError{Code: code, Message: code},
|
||||
}}
|
||||
}
|
||||
|
||||
func pongMsg(clientTimeMs, serverTimeMs int64) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Pong{
|
||||
Pong: &noituv1.Pong{ClientTimeMs: clientTimeMs, ServerTimeMs: serverTimeMs},
|
||||
}}
|
||||
}
|
||||
Reference in new issue
Block a user