Files
noitu/server/internal/wsapi/convert.go
T
tiennm99 4e2e9e1433 feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.

A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.

A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.

The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.

Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.

BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
2026-09-08 10:56:14 +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 = 2
// 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),
}
}