Files
noitu/proto/noitu/v1/game.proto
T
tiennm99 9e74f6f959 feat(wire): carry a word's senses on PlayedWord and GameStarted
`message Sense {pos, gloss}`; fields 7 and 9, wire-compatible; `wsapi.Dictionary` gains `Meanings`; looked up once per move.
2026-09-08 22:48:26 +07:00

417 lines
15 KiB
Protocol Buffer

// The nối từ wire contract.
//
// This file is the single source of truth for everything crossing the
// WebSocket. Both the Go server and the JavaScript client are generated from
// it; neither side hand-writes a message type. Every frame is a binary
// protobuf message — there is no JSON fallback and no text frame.
//
// Compatibility rules, because a deployed client outlives a deploy:
// - changes are additive only;
// - a removed field's tag goes to `reserved`, never to another field;
// - Hello.protocol_version lets the server refuse an incompatible client
// with a readable error instead of failing to decode.
syntax = "proto3";
package noitu.v1;
option go_package = "github.com/tiennm99dev/noitu/server/gen/noitu/v1;noituv1";
// Difficulty selects the bot's strategy in a vs-bot game.
enum Difficulty {
DIFFICULTY_UNSPECIFIED = 0;
DIFFICULTY_EASY = 1;
DIFFICULTY_MEDIUM = 2;
DIFFICULTY_HARD = 3;
}
// RejectReason says why a submitted word was not accepted. It is deliberately
// a separate type from the engine's internal game.RejectReason: the wire
// contract must not change every time the engine is refactored. The mapping
// lives in server/internal/wsapi/convert.go.
enum RejectReason {
REJECT_REASON_UNSPECIFIED = 0;
// Fewer than two syllables.
REJECT_REASON_TOO_FEW_SYLLABLES = 1;
// First syllable does not match the required syllable.
REJECT_REASON_WRONG_LINK = 2;
REJECT_REASON_NOT_IN_DICTIONARY = 3;
REJECT_REASON_ALREADY_USED = 4;
REJECT_REASON_NOT_YOUR_TURN = 5;
// The turn deadline passed before the word arrived.
REJECT_REASON_TIMEOUT = 6;
// The game had already finished.
REJECT_REASON_GAME_OVER = 7;
}
// GameEndReason says how a finished game ended.
enum GameEndReason {
GAME_END_REASON_UNSPECIFIED = 0;
GAME_END_REASON_TIMEOUT = 1;
GAME_END_REASON_NO_LEGAL_MOVE = 2;
GAME_END_REASON_OPPONENT_LEFT = 3;
GAME_END_REASON_RESIGNED = 4;
}
// ---------------------------------------------------------------------------
// Client -> server
// ---------------------------------------------------------------------------
// Hello opens a session. nickname is a *request*: the server sanitizes it and
// reports what it actually stored in Welcome.accepted_nickname.
message Hello {
uint32 protocol_version = 1;
// Empty on a fresh session; a token from a previous Welcome to resume one.
string resume_token = 2;
string nickname = 3;
}
message StartBotGame {
Difficulty difficulty = 1;
}
message CreateRoom {}
message JoinRoom {
string room_code = 1;
}
// SubmitWord carries the turn it was typed for. The server rejects a
// turn_seq that is not the current one, which makes a double-submit or a
// submission racing the timeout detectable rather than silently applied.
message SubmitWord {
string word = 1;
uint32 turn_seq = 2;
}
message Resign {}
// SetReady is a guest declaring themselves ready, or taking it back.
//
// Only guests have a readiness to set. The owner's is implied by StartGame:
// asking for the game to begin is the same statement, and a second flag they
// would always have to set first buys nothing.
message SetReady {
bool ready = 1;
}
// StartGame is the owner beginning the game the lobby has agreed on. It is
// refused unless at least one guest is seated and every seated guest is
// connected and ready.
message StartGame {}
// KickPlayer is the owner freeing one seat. Refused while that player is
// ready: readiness is a commitment, and a player who has made it is not
// something the owner gets to overrule.
message KickPlayer {
// Which seat, from RoomState.players. A room holds up to four people, so
// "the other one" stopped being an answer.
string player_id = 1;
}
// LeaveRoom gives up a seat without dropping the connection, which is what
// makes a room outlive one game rather than one visit. Refused while the
// sender is ready — unreadying first is the deliberate friction.
message LeaveRoom {}
// SendChat is one line of text from a seated player to the other.
message SendChat {
string text = 1;
}
// Ping echoes the client clock so Pong can expose the offset between the two.
message Ping {
int64 client_time_ms = 1;
}
message ClientMessage {
// 8 was RequestRematch, retired with the rematch handshake: the lobby is
// where a next game is agreed now. Reserved at message level because a
// oneof cannot hold the statement itself.
reserved 8;
oneof payload {
Hello hello = 1;
StartBotGame start_bot_game = 2;
CreateRoom create_room = 3;
JoinRoom join_room = 4;
SubmitWord submit_word = 5;
Resign resign = 6;
Ping ping = 7;
SetReady set_ready = 9;
StartGame start_game = 10;
KickPlayer kick_player = 11;
LeaveRoom leave_room = 12;
SendChat send_chat = 13;
}
}
// ---------------------------------------------------------------------------
// Server -> client
// ---------------------------------------------------------------------------
message Welcome {
string session_id = 1;
string resume_token = 2;
uint32 protocol_version = 3;
// What the server stored after sanitizing Hello.nickname. The client must
// display this, not the string it sent.
string accepted_nickname = 4;
}
// PlayedWord is one accepted move. word is the canonical spelling, which can
// differ from what the player typed; typed preserves the raw input so the UI
// can show that a correction happened instead of silently rewriting the text.
message PlayedWord {
string word = 1;
bool by_me = 2;
uint32 points = 3;
uint32 syllables = 4;
string typed = 5;
// Which seat played it. by_me answers "was this mine"; with four people at
// the table the chain also has to say whose the other words were, and a seat
// id says that without the client matching display names.
string player_id = 6;
// What the word means, at most five senses. Empty when the dictionary has
// no definition for the word; the client then says so rather than hiding
// the row's panel, so every word in the chain behaves the same.
repeated Sense meanings = 7;
}
// Sense is one definition of a word as Wiktionary gives it: the part of
// speech it sits under, in Vietnamese ("danh từ"), empty when the heading was
// not one the builder knows, and the definition stripped of markup at at most
// 200 characters. Plain text both: the client renders them as text, never as
// markup.
message Sense {
string pos = 1;
string gloss = 2;
}
// PlayerSlot is one seat in the room, rendered for one recipient.
//
// The recipient's own row is in the list like everybody else's, marked by
// is_me. That is deliberately the only way to find yourself: a separate
// i_am_owner alongside an is_owner in the list would be two encodings of one
// fact, and two ways for a client to disagree with the server.
message PlayerSlot {
// Stable for as long as this player holds the seat. Not stable across a
// seat being vacated and refilled, which is exactly when a name stops
// meaning the same person too.
string player_id = 1;
// Always server-sanitized, as everywhere else another player's name appears.
string name = 2;
bool is_me = 3;
bool is_owner = 4;
// Always false for the owner, whose readiness is StartGame itself.
bool ready = 5;
// False while this player is inside their reconnect window.
bool connected = 6;
// How many games this seat has won since the room opened. A room outlives
// its games, so the tally belongs to the seat rather than to any one of
// them; it starts again when the seat is vacated, because by then the name
// on it no longer means the same person.
uint32 wins = 7;
}
// PlayerScore is one player in a running or finished game.
//
// Separate from PlayerSlot because they answer different questions: a slot is
// about the room, a score is about the game being played in it. A player who
// has been eliminated still has both — being out of the game is not being out
// of the room.
message PlayerScore {
string player_id = 1;
string name = 2;
bool is_me = 3;
uint32 score = 4;
// True once this player has been knocked out. They keep their seat, their
// score and their words; they simply no longer get a turn.
bool eliminated = 5;
bool connected = 6;
// Final placing, 1 for the winner. Zero while the game is still running,
// which is what tells the two apart without a second field.
uint32 rank = 7;
}
// GameStarted is rendered per recipient: my_turn is true for exactly one
// player.
message GameStarted {
string opening_word = 1;
string current_syllable = 2;
bool my_turn = 3;
// Absolute server timestamp. The client counts down to it and never trusts
// its own clock for authority.
int64 deadline_unix_ms = 4;
uint32 turn_seq = 5;
uint32 turn_limit_ms = 6;
// Everyone playing, in turn order.
repeated PlayerScore players = 7;
// Whose turn it is. my_turn above says whether it is yours; this says whose
// it is when it is not, which a two-player game never had to.
string turn_player_id = 8;
// The opening word's senses, as PlayedWord.meanings for a played word.
repeated Sense opening_meanings = 9;
}
// TurnUpdate follows every turn change and goes to every player, serialized
// once per recipient so by_me and my_turn are correct for each.
//
// played is absent when the turn moved without a word being played, which is
// what an elimination does: the syllable and the used set survive the player
// who could not answer them.
message TurnUpdate {
// 6 was my_score and 7 was opponent_score. Both are in players below now,
// where a four-way game can express them.
reserved 6, 7;
PlayedWord played = 1;
string current_syllable = 2;
bool my_turn = 3;
int64 deadline_unix_ms = 4;
uint32 turn_seq = 5;
uint32 chain_length = 8;
// Everyone playing, in turn order, with scores as they stand.
repeated PlayerScore players = 9;
string turn_player_id = 10;
}
message MoveRejected {
RejectReason reason = 1;
string word = 2;
uint32 turn_seq = 3;
}
// GameOver is rendered per recipient: i_won is true for exactly one player,
// the one still standing when everybody else had been eliminated.
message GameOver {
// 3 was my_score, which is now the standings row where is_me is true. 5 was
// suggestions, which moved to PlayerEliminated: they describe the position a
// player was stuck on, and by the time a game ends that is no longer the
// position anyone but the last player out was looking at.
reserved 3, 5;
bool i_won = 1;
GameEndReason reason = 2;
uint32 chain_length = 4;
// The final table, best first: the player left standing, then the others in
// reverse order of elimination. Outlasting somebody is what beats them, so
// the ranking is finishing order and each score is reported beside it rather
// than deciding it.
repeated PlayerScore standings = 6;
}
// PlayerEliminated is one player knocked out of a game that is still running.
//
// Rendered per recipient like everything else in a room, and the only message
// whose contents differ by more than a flag: suggestions are filled in solely
// for the player who went out, because they are the one who was stuck.
message PlayerEliminated {
string player_id = 1;
string name = 2;
bool is_me = 3;
GameEndReason reason = 4;
// A few words the position still had, for the player who just lost it. An
// empty list is itself the answer: nobody could have answered that syllable.
repeated string suggestions = 5;
}
// ServerError.message is a UI key such as "room_not_found", never prose: all
// Vietnamese copy lives in the frontend so it stays in one place.
message ServerError {
string code = 1;
string message = 2;
}
message Pong {
int64 client_time_ms = 1;
int64 server_time_ms = 2;
}
// RoomState is the whole room, rendered for one recipient, and it is the only
// thing the lobby screen is built from. Sent on every change a player could
// see — a seat filled or freed, a readiness set, an owner promoted, somebody
// dropping or coming back — and again on resume, so a client that missed a
// frame recovers by being told the state rather than by replaying the events
// that led to it.
//
// It describes the room, not the game, so it is meaningful during one too:
// while a game runs this is what carries presence, which is why there is no
// separate message for a player disconnecting.
message RoomState {
// 2 was i_am_owner and 4 was i_am_ready: both are in the recipient's own
// row in players now, and two encodings of one fact are two ways for a
// client to disagree with the server. 5 through 8 were the opponent_*
// fields, which could only ever describe a second player.
reserved 2, 4, 5, 6, 7, 8;
string room_code = 1;
// Whether StartGame would be accepted right now. The server decides this
// because it owns every condition that feeds it.
bool can_start = 3;
// Everyone seated, in seat order, which is also the turn order a game will
// use. Always includes the recipient, marked is_me.
repeated PlayerSlot players = 9;
// How many seats the room has and how many players a game needs. Sent
// rather than compiled in, so the lobby draws whatever the server allows and
// raising the limit does not need a client deploy.
uint32 max_players = 10;
uint32 min_players = 11;
// How long a seat is held for a player who has dropped. The client counts
// down against it for anybody whose connected is false; the server still
// decides when the seat is actually forfeit.
uint32 grace_ms = 12;
}
// ChatMessage is one line as one recipient sees it. Rendered per recipient
// like every other room message: from_me is the only field that differs, and
// it is what lets the client style its own words without matching names.
message ChatMessage {
bool from_me = 1;
// Server-sanitized, as everywhere a name is shown. Empty when the author has
// left the room: the seat they spoke from may be somebody else's now, and a
// name outlives neither. The client labels an empty author itself.
string author = 2;
string text = 3;
// Server clock. The client renders it; it never orders by its own clock.
int64 sent_unix_ms = 4;
// Which seat spoke, so the client can colour a line by its author instead
// of by matching display names. Cleared with the author for a line whose
// seat has been vacated.
string player_id = 5;
}
// ChatHistory is the whole panel, oldest first, sent when a player is seated in
// a room or resumes into one. A snapshot rather than a replayed stream, for the
// same reason RoomState is one: a client that missed frames is correct again
// from the next one instead of having to catch up on events.
//
// Scoped to the recipient: it carries only what was said while they held their
// seat, which is why it is built per seat rather than broadcast.
message ChatHistory {
repeated ChatMessage messages = 1;
}
message ServerMessage {
// 2 was RoomCreated and 3 was RoomJoined: both said part of what RoomState
// now says in full, and three messages describing one lobby is three ways
// for a client to hold a different view of it. 11 was RematchState, retired
// with the rematch handshake. 8 was OpponentLeft, retired with the second
// seat: presence is per player now, and RoomState already carries it to
// everyone in the room whether or not a game is running.
reserved 2, 3, 8, 11;
oneof payload {
Welcome welcome = 1;
GameStarted game_started = 4;
TurnUpdate turn_update = 5;
MoveRejected move_rejected = 6;
GameOver game_over = 7;
ServerError error = 9;
Pong pong = 10;
RoomState room_state = 12;
ChatMessage chat_message = 13;
ChatHistory chat_history = 14;
PlayerEliminated player_eliminated = 15;
}
}