Files
noitu/server/internal/wsapi/convert.go
T
tiennm99 48b3b3c8ac feat(proto): add protobuf wire contract and cross-language codegen
proto/noitu/v1/game.proto is the single source of truth for every WebSocket
message. buf generates Go types into server/gen and JavaScript types into
web/src/lib/proto; both trees are committed so building needs no codegen
toolchain.

The Go suite emits binary fixtures into proto/testdata and the JavaScript suite
decodes the same bytes, so the two generated clients are checked against one
artifact rather than against each other's assumptions. CI lints the schema,
rejects breaking changes against main, and fails when the committed generated
trees drift from the schema.

game.NumRejectReasons and game.NumEndReasons let the mapping tests prove every
engine reason has a wire value without guessing where the enum ends.
2026-09-05 12:06:58 +07:00

113 lines
4.3 KiB
Go

// Package wsapi is the transport layer: it translates between the game engine
// and the generated protobuf wire types, and (from phase 5 on) owns the
// WebSocket sessions and rooms built on top of them.
//
// The engine's reason enums and the wire enums are deliberately distinct
// types. Renumbering an internal constant must never silently change what a
// deployed client decodes, so every crossing goes through an explicit switch
// here rather than a cast.
package wsapi
import (
"log"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"github.com/tiennm99dev/noitu/server/internal/bot"
"github.com/tiennm99dev/noitu/server/internal/game"
)
// ProtocolVersion is the version this build speaks, sent in Welcome and
// expected in Hello.
//
// It is a whole-protocol number, not a per-message one: the wire contract only
// ever grows by addition, so a client and server that agree on this integer
// agree on everything they both know how to decode. Bump it when a change
// would make an older client misread a frame rather than merely ignore an
// unknown field.
const ProtocolVersion uint32 = 1
// RejectReason maps an engine rejection onto the wire enum.
//
// game.ReasonNone means the move was accepted and so has no wire counterpart;
// it maps to UNSPECIFIED, which callers must not put in a MoveRejected. Any
// other unmapped value is a bug — a reason was added to the engine without
// extending the contract — so it is logged rather than quietly flattened.
func RejectReason(r game.RejectReason) noituv1.RejectReason {
switch r {
case game.ReasonNone:
return noituv1.RejectReason_REJECT_REASON_UNSPECIFIED
case game.ReasonNotYourTurn:
return noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN
case game.ReasonTooFewSyllables:
return noituv1.RejectReason_REJECT_REASON_TOO_FEW_SYLLABLES
case game.ReasonNotInDictionary:
return noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY
case game.ReasonWrongLink:
return noituv1.RejectReason_REJECT_REASON_WRONG_LINK
case game.ReasonAlreadyUsed:
return noituv1.RejectReason_REJECT_REASON_ALREADY_USED
case game.ReasonTimeout:
return noituv1.RejectReason_REJECT_REASON_TIMEOUT
case game.ReasonGameOver:
return noituv1.RejectReason_REJECT_REASON_GAME_OVER
}
log.Printf("wsapi: no wire mapping for game.RejectReason(%d) %q", int(r), r)
return noituv1.RejectReason_REJECT_REASON_UNSPECIFIED
}
// EndReason maps an engine end condition onto the wire enum.
//
// GAME_END_REASON_OPPONENT_LEFT has no engine counterpart on purpose: a player
// disconnecting is a transport event, not a rule, so the room emits that value
// directly and the engine never learns about it.
func EndReason(r game.EndReason) noituv1.GameEndReason {
switch r {
case game.EndNone:
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
case game.EndTimeout:
return noituv1.GameEndReason_GAME_END_REASON_TIMEOUT
case game.EndNoLegalMove:
return noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE
case game.EndResigned:
return noituv1.GameEndReason_GAME_END_REASON_RESIGNED
}
log.Printf("wsapi: no wire mapping for game.EndReason(%d) %q", int(r), r)
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
}
// Difficulty maps a wire difficulty onto a bot strategy selector.
//
// This one runs client to server, so an unrecognized value is untrusted input
// rather than a bug: it is not logged, and the caller decides what to do with
// the false.
func Difficulty(d noituv1.Difficulty) (bot.Difficulty, bool) {
switch d {
case noituv1.Difficulty_DIFFICULTY_EASY:
return bot.Easy, true
case noituv1.Difficulty_DIFFICULTY_MEDIUM:
return bot.Medium, true
case noituv1.Difficulty_DIFFICULTY_HARD:
return bot.Hard, true
}
return 0, false
}
// There is deliberately no server-to-client difficulty mapping: no message in
// the schema carries a Difficulty, so a client learns the difficulty only from
// the one it asked for.
// PlayedWord renders an accepted move for one recipient.
//
// byMe is the caller's business: the same move is sent to both players and
// only this flag differs, so the room serializes one message per player rather
// than broadcasting a single shared frame.
func PlayedWord(m game.Move, byMe bool) *noituv1.PlayedWord {
return &noituv1.PlayedWord{
Word: m.Word,
Typed: m.Typed,
ByMe: byMe,
Points: uint32(m.Points),
Syllables: uint32(m.Syllables),
}
}