mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
Four client messages and two server ones, all additive. ClaimDeadEnd lets the player to act say the syllable has no answer instead of waiting out the clock; the server verifies. ReportWord files a refused word for the maintainers and is acknowledged with WordReported. QuickMatch and CancelQuickMatch join and leave a pairing queue, answered by QuickMatchStatus. PlayedWord gains parts, the named terms that sum to its points, and MoveRejected a suggestion for a word that differs from a real one by diacritics alone. Generated code and cross-language fixtures regenerated.
496 lines
19 KiB
Protocol Buffer
496 lines
19 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;
|
|
}
|
|
|
|
// PointKind names one term of a word's score. The engine adds several and
|
|
// the client cannot re-derive any of them — it has no wordlist by design — so
|
|
// each term travels named, and a new term is a new value rather than a wire
|
|
// break.
|
|
enum PointKind {
|
|
POINT_KIND_UNSPECIFIED = 0;
|
|
// Every accepted word.
|
|
POINT_KIND_BASE = 1;
|
|
// For the length of the chain the word extends.
|
|
POINT_KIND_CHAIN = 2;
|
|
// For each syllable past the minimum.
|
|
POINT_KIND_SYLLABLES = 3;
|
|
// For the share of the turn left on the clock.
|
|
POINT_KIND_SPEED = 4;
|
|
// For how few words the dictionary has on the syllable it answered.
|
|
POINT_KIND_RARITY = 5;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
// ClaimDeadEnd is the player to act saying the syllable has no answer left.
|
|
// The server checks. A true claim takes them out at once with
|
|
// GAME_END_REASON_NO_LEGAL_MOVE, exactly as the clock would have, so the
|
|
// rule is unchanged and only the waiting is gone. A false one is refused with
|
|
// the error not_a_dead_end and the clock keeps running: the server has just
|
|
// confirmed a word exists, which is hint enough to be the whole cost.
|
|
message ClaimDeadEnd {}
|
|
|
|
// ReportWord is a player saying a word the dictionary refused is real. The
|
|
// server records it for the maintainers and acknowledges with WordReported;
|
|
// nothing about the current game changes. Only words of at least two
|
|
// syllables are recorded, and a session is bounded in how many it may file.
|
|
message ReportWord {
|
|
string word = 1;
|
|
}
|
|
|
|
// QuickMatch asks to be paired with the next stranger who asks the same. The
|
|
// two are seated in an ordinary room whose first game starts by itself; from
|
|
// then on it is a room like any other. CancelQuickMatch leaves the queue.
|
|
// Both are answered with QuickMatchStatus.
|
|
message QuickMatch {}
|
|
|
|
message CancelQuickMatch {}
|
|
|
|
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;
|
|
ClaimDeadEnd claim_dead_end = 14;
|
|
ReportWord report_word = 15;
|
|
QuickMatch quick_match = 16;
|
|
CancelQuickMatch cancel_quick_match = 17;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
// How points was arrived at, one entry per non-zero term, summing exactly
|
|
// to points. The client shows why a word scored what it did rather than
|
|
// only that it did.
|
|
repeated PointPart parts = 8;
|
|
}
|
|
|
|
// PointPart is one named term of a word's score.
|
|
message PointPart {
|
|
PointKind kind = 1;
|
|
uint32 value = 2;
|
|
}
|
|
|
|
// 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. It is also absent when the turn did not move at
|
|
// all — a player behind the one to act forfeiting, by leaving the room or by
|
|
// never coming back to it — and then turn_seq is unchanged too, because the
|
|
// word the player to act is already sending still answers this position.
|
|
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;
|
|
// For REJECT_REASON_NOT_IN_DICTIONARY only: the one real word that differs
|
|
// from what was typed by diacritics alone, when exactly one does. It
|
|
// corrects typing, never vocabulary — a word the player did not know is
|
|
// never offered — and the move is still refused; the player retypes it.
|
|
string suggestion = 4;
|
|
}
|
|
|
|
// WordReported acknowledges a ReportWord, echoing the word as the server
|
|
// recorded it so the player sees that it was heard.
|
|
message WordReported {
|
|
string word = 1;
|
|
}
|
|
|
|
// QuickMatchStatus is where the sender stands with the queue: queued after a
|
|
// QuickMatch, not queued after a CancelQuickMatch or once a room has seated
|
|
// them — the RoomState that follows is the match itself.
|
|
message QuickMatchStatus {
|
|
bool queued = 1;
|
|
}
|
|
|
|
// 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;
|
|
WordReported word_reported = 16;
|
|
QuickMatchStatus quick_match_status = 17;
|
|
}
|
|
}
|