From 9e74f6f959fce9ee382f68a96d140a643107dc2b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 8 Sep 2026 22:48:26 +0700 Subject: [PATCH] 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. --- proto/noitu/v1/game.proto | 16 ++ proto/testdata/server_game_started.bin | 5 +- proto/testdata/server_turn_update.bin | 8 +- server/gen/noitu/v1/game.pb.go | 261 ++++++++++++++++-------- server/internal/wsapi/convert.go | 19 +- server/internal/wsapi/convert_test.go | 4 +- server/internal/wsapi/meanings_test.go | 113 ++++++++++ server/internal/wsapi/room.go | 29 ++- server/internal/wsapi/wire_test.go | 5 + server/internal/wsapi/wsapi_test.go | 14 +- web/src/lib/proto/noitu/v1/game_pb.d.ts | 43 ++++ web/src/lib/proto/noitu/v1/game_pb.js | 35 ++-- web/tests/game-wire.test.js | 16 ++ 13 files changed, 450 insertions(+), 118 deletions(-) create mode 100644 server/internal/wsapi/meanings_test.go diff --git a/proto/noitu/v1/game.proto b/proto/noitu/v1/game.proto index f2350e9..9b51488 100644 --- a/proto/noitu/v1/game.proto +++ b/proto/noitu/v1/game.proto @@ -171,6 +171,20 @@ message PlayedWord { // 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. @@ -235,6 +249,8 @@ message GameStarted { // 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 diff --git a/proto/testdata/server_game_started.bin b/proto/testdata/server_game_started.bin index d9fc40d..b6289f8 100644 --- a/proto/testdata/server_game_started.bin +++ b/proto/testdata/server_game_started.bin @@ -1,6 +1,7 @@ -"n +"¤ hòa bìnhbình  ×ø©‘3(0°ê: p1Người chơi 0: p2 Khách mời *(0: -p3Khách 2 Bp1 \ No newline at end of file +p3Khách 2 Bp1J4 + danh từ'Tình trạng không có chiến tranh. \ No newline at end of file diff --git a/proto/testdata/server_turn_update.bin b/proto/testdata/server_turn_update.bin index 103971d..9a8c43a 100644 --- a/proto/testdata/server_turn_update.bin +++ b/proto/testdata/server_turn_update.bin @@ -1,7 +1,9 @@ -*€ - +*É +h -bình yên * binh yên2p2yên Àóù©‘3(@J +bình yên * binh yên2p2:6 + +tính từ(Yên lành, không có gì xáo động.: Xem bình an.yên Àóù©‘3(@J p1Người chơi 0J p2 Khách mời *(0J p3Khách 2 Rp3 \ No newline at end of file diff --git a/server/gen/noitu/v1/game.pb.go b/server/gen/noitu/v1/game.pb.go index e1b9a38..53b2bd9 100644 --- a/server/gen/noitu/v1/game.pb.go +++ b/server/gen/noitu/v1/game.pb.go @@ -1084,7 +1084,11 @@ type PlayedWord struct { // 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. - PlayerId string `protobuf:"bytes,6,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` + PlayerId string `protobuf:"bytes,6,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` + // 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. + Meanings []*Sense `protobuf:"bytes,7,rep,name=meanings,proto3" json:"meanings,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1161,6 +1165,70 @@ func (x *PlayedWord) GetPlayerId() string { return "" } +func (x *PlayedWord) GetMeanings() []*Sense { + if x != nil { + return x.Meanings + } + return nil +} + +// 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. +type Sense struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pos string `protobuf:"bytes,1,opt,name=pos,proto3" json:"pos,omitempty"` + Gloss string `protobuf:"bytes,2,opt,name=gloss,proto3" json:"gloss,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Sense) Reset() { + *x = Sense{} + mi := &file_noitu_v1_game_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Sense) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Sense) ProtoMessage() {} + +func (x *Sense) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Sense.ProtoReflect.Descriptor instead. +func (*Sense) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{15} +} + +func (x *Sense) GetPos() string { + if x != nil { + return x.Pos + } + return "" +} + +func (x *Sense) GetGloss() string { + if x != nil { + return x.Gloss + } + return "" +} + // 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 @@ -1192,7 +1260,7 @@ type PlayerSlot struct { func (x *PlayerSlot) Reset() { *x = PlayerSlot{} - mi := &file_noitu_v1_game_proto_msgTypes[15] + mi := &file_noitu_v1_game_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1204,7 +1272,7 @@ func (x *PlayerSlot) String() string { func (*PlayerSlot) ProtoMessage() {} func (x *PlayerSlot) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[15] + mi := &file_noitu_v1_game_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1217,7 +1285,7 @@ func (x *PlayerSlot) ProtoReflect() protoreflect.Message { // Deprecated: Use PlayerSlot.ProtoReflect.Descriptor instead. func (*PlayerSlot) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{15} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{16} } func (x *PlayerSlot) GetPlayerId() string { @@ -1294,7 +1362,7 @@ type PlayerScore struct { func (x *PlayerScore) Reset() { *x = PlayerScore{} - mi := &file_noitu_v1_game_proto_msgTypes[16] + mi := &file_noitu_v1_game_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1306,7 +1374,7 @@ func (x *PlayerScore) String() string { func (*PlayerScore) ProtoMessage() {} func (x *PlayerScore) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[16] + mi := &file_noitu_v1_game_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1319,7 +1387,7 @@ func (x *PlayerScore) ProtoReflect() protoreflect.Message { // Deprecated: Use PlayerScore.ProtoReflect.Descriptor instead. func (*PlayerScore) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{16} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{17} } func (x *PlayerScore) GetPlayerId() string { @@ -1387,14 +1455,16 @@ type GameStarted struct { Players []*PlayerScore `protobuf:"bytes,7,rep,name=players,proto3" json:"players,omitempty"` // 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. - TurnPlayerId string `protobuf:"bytes,8,opt,name=turn_player_id,json=turnPlayerId,proto3" json:"turn_player_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + TurnPlayerId string `protobuf:"bytes,8,opt,name=turn_player_id,json=turnPlayerId,proto3" json:"turn_player_id,omitempty"` + // The opening word's senses, as PlayedWord.meanings for a played word. + OpeningMeanings []*Sense `protobuf:"bytes,9,rep,name=opening_meanings,json=openingMeanings,proto3" json:"opening_meanings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GameStarted) Reset() { *x = GameStarted{} - mi := &file_noitu_v1_game_proto_msgTypes[17] + mi := &file_noitu_v1_game_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1406,7 +1476,7 @@ func (x *GameStarted) String() string { func (*GameStarted) ProtoMessage() {} func (x *GameStarted) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[17] + mi := &file_noitu_v1_game_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1419,7 +1489,7 @@ func (x *GameStarted) ProtoReflect() protoreflect.Message { // Deprecated: Use GameStarted.ProtoReflect.Descriptor instead. func (*GameStarted) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{17} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{18} } func (x *GameStarted) GetOpeningWord() string { @@ -1478,6 +1548,13 @@ func (x *GameStarted) GetTurnPlayerId() string { return "" } +func (x *GameStarted) GetOpeningMeanings() []*Sense { + if x != nil { + return x.OpeningMeanings + } + return nil +} + // TurnUpdate follows every turn change and goes to every player, serialized // once per recipient so by_me and my_turn are correct for each. // @@ -1501,7 +1578,7 @@ type TurnUpdate struct { func (x *TurnUpdate) Reset() { *x = TurnUpdate{} - mi := &file_noitu_v1_game_proto_msgTypes[18] + mi := &file_noitu_v1_game_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1513,7 +1590,7 @@ func (x *TurnUpdate) String() string { func (*TurnUpdate) ProtoMessage() {} func (x *TurnUpdate) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[18] + mi := &file_noitu_v1_game_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1526,7 +1603,7 @@ func (x *TurnUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use TurnUpdate.ProtoReflect.Descriptor instead. func (*TurnUpdate) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{18} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{19} } func (x *TurnUpdate) GetPlayed() *PlayedWord { @@ -1596,7 +1673,7 @@ type MoveRejected struct { func (x *MoveRejected) Reset() { *x = MoveRejected{} - mi := &file_noitu_v1_game_proto_msgTypes[19] + mi := &file_noitu_v1_game_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1608,7 +1685,7 @@ func (x *MoveRejected) String() string { func (*MoveRejected) ProtoMessage() {} func (x *MoveRejected) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[19] + mi := &file_noitu_v1_game_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1621,7 +1698,7 @@ func (x *MoveRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use MoveRejected.ProtoReflect.Descriptor instead. func (*MoveRejected) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{19} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{20} } func (x *MoveRejected) GetReason() RejectReason { @@ -1663,7 +1740,7 @@ type GameOver struct { func (x *GameOver) Reset() { *x = GameOver{} - mi := &file_noitu_v1_game_proto_msgTypes[20] + mi := &file_noitu_v1_game_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1675,7 +1752,7 @@ func (x *GameOver) String() string { func (*GameOver) ProtoMessage() {} func (x *GameOver) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[20] + mi := &file_noitu_v1_game_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1688,7 +1765,7 @@ func (x *GameOver) ProtoReflect() protoreflect.Message { // Deprecated: Use GameOver.ProtoReflect.Descriptor instead. func (*GameOver) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{20} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{21} } func (x *GameOver) GetIWon() bool { @@ -1739,7 +1816,7 @@ type PlayerEliminated struct { func (x *PlayerEliminated) Reset() { *x = PlayerEliminated{} - mi := &file_noitu_v1_game_proto_msgTypes[21] + mi := &file_noitu_v1_game_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1751,7 +1828,7 @@ func (x *PlayerEliminated) String() string { func (*PlayerEliminated) ProtoMessage() {} func (x *PlayerEliminated) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[21] + mi := &file_noitu_v1_game_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1764,7 +1841,7 @@ func (x *PlayerEliminated) ProtoReflect() protoreflect.Message { // Deprecated: Use PlayerEliminated.ProtoReflect.Descriptor instead. func (*PlayerEliminated) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{21} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{22} } func (x *PlayerEliminated) GetPlayerId() string { @@ -1814,7 +1891,7 @@ type ServerError struct { func (x *ServerError) Reset() { *x = ServerError{} - mi := &file_noitu_v1_game_proto_msgTypes[22] + mi := &file_noitu_v1_game_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1826,7 +1903,7 @@ func (x *ServerError) String() string { func (*ServerError) ProtoMessage() {} func (x *ServerError) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[22] + mi := &file_noitu_v1_game_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1839,7 +1916,7 @@ func (x *ServerError) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerError.ProtoReflect.Descriptor instead. func (*ServerError) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{22} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{23} } func (x *ServerError) GetCode() string { @@ -1866,7 +1943,7 @@ type Pong struct { func (x *Pong) Reset() { *x = Pong{} - mi := &file_noitu_v1_game_proto_msgTypes[23] + mi := &file_noitu_v1_game_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1878,7 +1955,7 @@ func (x *Pong) String() string { func (*Pong) ProtoMessage() {} func (x *Pong) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[23] + mi := &file_noitu_v1_game_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1891,7 +1968,7 @@ func (x *Pong) ProtoReflect() protoreflect.Message { // Deprecated: Use Pong.ProtoReflect.Descriptor instead. func (*Pong) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{23} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{24} } func (x *Pong) GetClientTimeMs() int64 { @@ -1942,7 +2019,7 @@ type RoomState struct { func (x *RoomState) Reset() { *x = RoomState{} - mi := &file_noitu_v1_game_proto_msgTypes[24] + mi := &file_noitu_v1_game_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1954,7 +2031,7 @@ func (x *RoomState) String() string { func (*RoomState) ProtoMessage() {} func (x *RoomState) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[24] + mi := &file_noitu_v1_game_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1967,7 +2044,7 @@ func (x *RoomState) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomState.ProtoReflect.Descriptor instead. func (*RoomState) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{24} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{25} } func (x *RoomState) GetRoomCode() string { @@ -2035,7 +2112,7 @@ type ChatMessage struct { func (x *ChatMessage) Reset() { *x = ChatMessage{} - mi := &file_noitu_v1_game_proto_msgTypes[25] + mi := &file_noitu_v1_game_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2047,7 +2124,7 @@ func (x *ChatMessage) String() string { func (*ChatMessage) ProtoMessage() {} func (x *ChatMessage) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[25] + mi := &file_noitu_v1_game_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2060,7 +2137,7 @@ func (x *ChatMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatMessage.ProtoReflect.Descriptor instead. func (*ChatMessage) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{25} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{26} } func (x *ChatMessage) GetFromMe() bool { @@ -2114,7 +2191,7 @@ type ChatHistory struct { func (x *ChatHistory) Reset() { *x = ChatHistory{} - mi := &file_noitu_v1_game_proto_msgTypes[26] + mi := &file_noitu_v1_game_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2126,7 +2203,7 @@ func (x *ChatHistory) String() string { func (*ChatHistory) ProtoMessage() {} func (x *ChatHistory) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[26] + mi := &file_noitu_v1_game_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2139,7 +2216,7 @@ func (x *ChatHistory) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatHistory.ProtoReflect.Descriptor instead. func (*ChatHistory) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{26} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{27} } func (x *ChatHistory) GetMessages() []*ChatMessage { @@ -2171,7 +2248,7 @@ type ServerMessage struct { func (x *ServerMessage) Reset() { *x = ServerMessage{} - mi := &file_noitu_v1_game_proto_msgTypes[27] + mi := &file_noitu_v1_game_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2183,7 +2260,7 @@ func (x *ServerMessage) String() string { func (*ServerMessage) ProtoMessage() {} func (x *ServerMessage) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[27] + mi := &file_noitu_v1_game_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2196,7 +2273,7 @@ func (x *ServerMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerMessage.ProtoReflect.Descriptor instead. func (*ServerMessage) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{27} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{28} } func (x *ServerMessage) GetPayload() isServerMessage_Payload { @@ -2433,7 +2510,7 @@ const file_noitu_v1_game_proto_rawDesc = "" + "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" + "\fresume_token\x18\x02 \x01(\tR\vresumeToken\x12)\n" + "\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x12+\n" + - "\x11accepted_nickname\x18\x04 \x01(\tR\x10acceptedNickname\"\x9e\x01\n" + + "\x11accepted_nickname\x18\x04 \x01(\tR\x10acceptedNickname\"\xcb\x01\n" + "\n" + "PlayedWord\x12\x12\n" + "\x04word\x18\x01 \x01(\tR\x04word\x12\x13\n" + @@ -2441,7 +2518,11 @@ const file_noitu_v1_game_proto_rawDesc = "" + "\x06points\x18\x03 \x01(\rR\x06points\x12\x1c\n" + "\tsyllables\x18\x04 \x01(\rR\tsyllables\x12\x14\n" + "\x05typed\x18\x05 \x01(\tR\x05typed\x12\x1b\n" + - "\tplayer_id\x18\x06 \x01(\tR\bplayerId\"\xb5\x01\n" + + "\tplayer_id\x18\x06 \x01(\tR\bplayerId\x12+\n" + + "\bmeanings\x18\a \x03(\v2\x0f.noitu.v1.SenseR\bmeanings\"/\n" + + "\x05Sense\x12\x10\n" + + "\x03pos\x18\x01 \x01(\tR\x03pos\x12\x14\n" + + "\x05gloss\x18\x02 \x01(\tR\x05gloss\"\xb5\x01\n" + "\n" + "PlayerSlot\x12\x1b\n" + "\tplayer_id\x18\x01 \x01(\tR\bplayerId\x12\x12\n" + @@ -2460,7 +2541,7 @@ const file_noitu_v1_game_proto_rawDesc = "" + "eliminated\x18\x05 \x01(\bR\n" + "eliminated\x12\x1c\n" + "\tconnected\x18\x06 \x01(\bR\tconnected\x12\x12\n" + - "\x04rank\x18\a \x01(\rR\x04rank\"\xb4\x02\n" + + "\x04rank\x18\a \x01(\rR\x04rank\"\xf0\x02\n" + "\vGameStarted\x12!\n" + "\fopening_word\x18\x01 \x01(\tR\vopeningWord\x12)\n" + "\x10current_syllable\x18\x02 \x01(\tR\x0fcurrentSyllable\x12\x17\n" + @@ -2469,7 +2550,8 @@ const file_noitu_v1_game_proto_rawDesc = "" + "\bturn_seq\x18\x05 \x01(\rR\aturnSeq\x12\"\n" + "\rturn_limit_ms\x18\x06 \x01(\rR\vturnLimitMs\x12/\n" + "\aplayers\x18\a \x03(\v2\x15.noitu.v1.PlayerScoreR\aplayers\x12$\n" + - "\x0eturn_player_id\x18\b \x01(\tR\fturnPlayerId\"\xc9\x02\n" + + "\x0eturn_player_id\x18\b \x01(\tR\fturnPlayerId\x12:\n" + + "\x10opening_meanings\x18\t \x03(\v2\x0f.noitu.v1.SenseR\x0fopeningMeanings\"\xc9\x02\n" + "\n" + "TurnUpdate\x12,\n" + "\x06played\x18\x01 \x01(\v2\x14.noitu.v1.PlayedWordR\x06played\x12)\n" + @@ -2572,7 +2654,7 @@ func file_noitu_v1_game_proto_rawDescGZIP() []byte { } var file_noitu_v1_game_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_noitu_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_noitu_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_noitu_v1_game_proto_goTypes = []any{ (Difficulty)(0), // 0: noitu.v1.Difficulty (RejectReason)(0), // 1: noitu.v1.RejectReason @@ -2592,19 +2674,20 @@ var file_noitu_v1_game_proto_goTypes = []any{ (*ClientMessage)(nil), // 15: noitu.v1.ClientMessage (*Welcome)(nil), // 16: noitu.v1.Welcome (*PlayedWord)(nil), // 17: noitu.v1.PlayedWord - (*PlayerSlot)(nil), // 18: noitu.v1.PlayerSlot - (*PlayerScore)(nil), // 19: noitu.v1.PlayerScore - (*GameStarted)(nil), // 20: noitu.v1.GameStarted - (*TurnUpdate)(nil), // 21: noitu.v1.TurnUpdate - (*MoveRejected)(nil), // 22: noitu.v1.MoveRejected - (*GameOver)(nil), // 23: noitu.v1.GameOver - (*PlayerEliminated)(nil), // 24: noitu.v1.PlayerEliminated - (*ServerError)(nil), // 25: noitu.v1.ServerError - (*Pong)(nil), // 26: noitu.v1.Pong - (*RoomState)(nil), // 27: noitu.v1.RoomState - (*ChatMessage)(nil), // 28: noitu.v1.ChatMessage - (*ChatHistory)(nil), // 29: noitu.v1.ChatHistory - (*ServerMessage)(nil), // 30: noitu.v1.ServerMessage + (*Sense)(nil), // 18: noitu.v1.Sense + (*PlayerSlot)(nil), // 19: noitu.v1.PlayerSlot + (*PlayerScore)(nil), // 20: noitu.v1.PlayerScore + (*GameStarted)(nil), // 21: noitu.v1.GameStarted + (*TurnUpdate)(nil), // 22: noitu.v1.TurnUpdate + (*MoveRejected)(nil), // 23: noitu.v1.MoveRejected + (*GameOver)(nil), // 24: noitu.v1.GameOver + (*PlayerEliminated)(nil), // 25: noitu.v1.PlayerEliminated + (*ServerError)(nil), // 26: noitu.v1.ServerError + (*Pong)(nil), // 27: noitu.v1.Pong + (*RoomState)(nil), // 28: noitu.v1.RoomState + (*ChatMessage)(nil), // 29: noitu.v1.ChatMessage + (*ChatHistory)(nil), // 30: noitu.v1.ChatHistory + (*ServerMessage)(nil), // 31: noitu.v1.ServerMessage } var file_noitu_v1_game_proto_depIdxs = []int32{ 0, // 0: noitu.v1.StartBotGame.difficulty:type_name -> noitu.v1.Difficulty @@ -2620,31 +2703,33 @@ var file_noitu_v1_game_proto_depIdxs = []int32{ 11, // 10: noitu.v1.ClientMessage.kick_player:type_name -> noitu.v1.KickPlayer 12, // 11: noitu.v1.ClientMessage.leave_room:type_name -> noitu.v1.LeaveRoom 13, // 12: noitu.v1.ClientMessage.send_chat:type_name -> noitu.v1.SendChat - 19, // 13: noitu.v1.GameStarted.players:type_name -> noitu.v1.PlayerScore - 17, // 14: noitu.v1.TurnUpdate.played:type_name -> noitu.v1.PlayedWord - 19, // 15: noitu.v1.TurnUpdate.players:type_name -> noitu.v1.PlayerScore - 1, // 16: noitu.v1.MoveRejected.reason:type_name -> noitu.v1.RejectReason - 2, // 17: noitu.v1.GameOver.reason:type_name -> noitu.v1.GameEndReason - 19, // 18: noitu.v1.GameOver.standings:type_name -> noitu.v1.PlayerScore - 2, // 19: noitu.v1.PlayerEliminated.reason:type_name -> noitu.v1.GameEndReason - 18, // 20: noitu.v1.RoomState.players:type_name -> noitu.v1.PlayerSlot - 28, // 21: noitu.v1.ChatHistory.messages:type_name -> noitu.v1.ChatMessage - 16, // 22: noitu.v1.ServerMessage.welcome:type_name -> noitu.v1.Welcome - 20, // 23: noitu.v1.ServerMessage.game_started:type_name -> noitu.v1.GameStarted - 21, // 24: noitu.v1.ServerMessage.turn_update:type_name -> noitu.v1.TurnUpdate - 22, // 25: noitu.v1.ServerMessage.move_rejected:type_name -> noitu.v1.MoveRejected - 23, // 26: noitu.v1.ServerMessage.game_over:type_name -> noitu.v1.GameOver - 25, // 27: noitu.v1.ServerMessage.error:type_name -> noitu.v1.ServerError - 26, // 28: noitu.v1.ServerMessage.pong:type_name -> noitu.v1.Pong - 27, // 29: noitu.v1.ServerMessage.room_state:type_name -> noitu.v1.RoomState - 28, // 30: noitu.v1.ServerMessage.chat_message:type_name -> noitu.v1.ChatMessage - 29, // 31: noitu.v1.ServerMessage.chat_history:type_name -> noitu.v1.ChatHistory - 24, // 32: noitu.v1.ServerMessage.player_eliminated:type_name -> noitu.v1.PlayerEliminated - 33, // [33:33] is the sub-list for method output_type - 33, // [33:33] is the sub-list for method input_type - 33, // [33:33] is the sub-list for extension type_name - 33, // [33:33] is the sub-list for extension extendee - 0, // [0:33] is the sub-list for field type_name + 18, // 13: noitu.v1.PlayedWord.meanings:type_name -> noitu.v1.Sense + 20, // 14: noitu.v1.GameStarted.players:type_name -> noitu.v1.PlayerScore + 18, // 15: noitu.v1.GameStarted.opening_meanings:type_name -> noitu.v1.Sense + 17, // 16: noitu.v1.TurnUpdate.played:type_name -> noitu.v1.PlayedWord + 20, // 17: noitu.v1.TurnUpdate.players:type_name -> noitu.v1.PlayerScore + 1, // 18: noitu.v1.MoveRejected.reason:type_name -> noitu.v1.RejectReason + 2, // 19: noitu.v1.GameOver.reason:type_name -> noitu.v1.GameEndReason + 20, // 20: noitu.v1.GameOver.standings:type_name -> noitu.v1.PlayerScore + 2, // 21: noitu.v1.PlayerEliminated.reason:type_name -> noitu.v1.GameEndReason + 19, // 22: noitu.v1.RoomState.players:type_name -> noitu.v1.PlayerSlot + 29, // 23: noitu.v1.ChatHistory.messages:type_name -> noitu.v1.ChatMessage + 16, // 24: noitu.v1.ServerMessage.welcome:type_name -> noitu.v1.Welcome + 21, // 25: noitu.v1.ServerMessage.game_started:type_name -> noitu.v1.GameStarted + 22, // 26: noitu.v1.ServerMessage.turn_update:type_name -> noitu.v1.TurnUpdate + 23, // 27: noitu.v1.ServerMessage.move_rejected:type_name -> noitu.v1.MoveRejected + 24, // 28: noitu.v1.ServerMessage.game_over:type_name -> noitu.v1.GameOver + 26, // 29: noitu.v1.ServerMessage.error:type_name -> noitu.v1.ServerError + 27, // 30: noitu.v1.ServerMessage.pong:type_name -> noitu.v1.Pong + 28, // 31: noitu.v1.ServerMessage.room_state:type_name -> noitu.v1.RoomState + 29, // 32: noitu.v1.ServerMessage.chat_message:type_name -> noitu.v1.ChatMessage + 30, // 33: noitu.v1.ServerMessage.chat_history:type_name -> noitu.v1.ChatHistory + 25, // 34: noitu.v1.ServerMessage.player_eliminated:type_name -> noitu.v1.PlayerEliminated + 35, // [35:35] is the sub-list for method output_type + 35, // [35:35] is the sub-list for method input_type + 35, // [35:35] is the sub-list for extension type_name + 35, // [35:35] is the sub-list for extension extendee + 0, // [0:35] is the sub-list for field type_name } func init() { file_noitu_v1_game_proto_init() } @@ -2666,7 +2751,7 @@ func file_noitu_v1_game_proto_init() { (*ClientMessage_LeaveRoom)(nil), (*ClientMessage_SendChat)(nil), } - file_noitu_v1_game_proto_msgTypes[27].OneofWrappers = []any{ + file_noitu_v1_game_proto_msgTypes[28].OneofWrappers = []any{ (*ServerMessage_Welcome)(nil), (*ServerMessage_GameStarted)(nil), (*ServerMessage_TurnUpdate)(nil), @@ -2685,7 +2770,7 @@ func file_noitu_v1_game_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_noitu_v1_game_proto_rawDesc), len(file_noitu_v1_game_proto_rawDesc)), NumEnums: 3, - NumMessages: 28, + NumMessages: 29, NumExtensions: 0, NumServices: 0, }, diff --git a/server/internal/wsapi/convert.go b/server/internal/wsapi/convert.go index ac938f4..643cf2f 100644 --- a/server/internal/wsapi/convert.go +++ b/server/internal/wsapi/convert.go @@ -13,6 +13,7 @@ import ( noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/dictionary" "github.com/tiennm99dev/noitu/server/internal/game" ) @@ -100,13 +101,27 @@ func Difficulty(d noituv1.Difficulty) (bot.Difficulty, bool) { // // 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 { +// than broadcasting a single shared frame. meanings is the word's senses from +// the dictionary, the same for every recipient; the room looks them up once +// per move and passes them in. +func PlayedWord(m game.Move, byMe bool, meanings []dictionary.Sense) *noituv1.PlayedWord { return &noituv1.PlayedWord{ Word: m.Word, Typed: m.Typed, ByMe: byMe, Points: uint32(m.Points), Syllables: uint32(m.Syllables), + Meanings: Senses(meanings), } } + +// Senses renders a word's dictionary senses for the wire. A word with none +// yields an empty list, which the client shows as "no meaning yet" rather than +// as a row without a panel. +func Senses(senses []dictionary.Sense) []*noituv1.Sense { + out := make([]*noituv1.Sense, 0, len(senses)) + for _, s := range senses { + out = append(out, &noituv1.Sense{Pos: s.Pos, Gloss: s.Gloss}) + } + return out +} diff --git a/server/internal/wsapi/convert_test.go b/server/internal/wsapi/convert_test.go index b72fba8..f65d4a9 100644 --- a/server/internal/wsapi/convert_test.go +++ b/server/internal/wsapi/convert_test.go @@ -172,14 +172,14 @@ func TestDifficultyMapping(t *testing.T) { func TestPlayedWordKeepsTypedInput(t *testing.T) { m := game.Move{Word: "hòa bình", Typed: "hoà bình", Syllables: 2, Points: 2} - mine := PlayedWord(m, true) + mine := PlayedWord(m, true, nil) if mine.GetWord() != "hòa bình" || mine.GetTyped() != "hoà bình" { t.Errorf("canonical/typed pair lost: word=%q typed=%q", mine.GetWord(), mine.GetTyped()) } if !mine.GetByMe() || mine.GetSyllables() != 2 || mine.GetPoints() != 2 { t.Errorf("unexpected rendering: %+v", mine) } - if theirs := PlayedWord(m, false); theirs.GetByMe() { + if theirs := PlayedWord(m, false, nil); theirs.GetByMe() { t.Error("by_me must be false for the opponent's copy of the same move") } } diff --git a/server/internal/wsapi/meanings_test.go b/server/internal/wsapi/meanings_test.go new file mode 100644 index 0000000..f26d0bf --- /dev/null +++ b/server/internal/wsapi/meanings_test.go @@ -0,0 +1,113 @@ +package wsapi + +import ( + "testing" + "time" + + "github.com/coder/websocket" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// definedChainDict is the forced chain with a meaning on the opening word and +// on the human's first reply, and none on the rest: both shapes must cross +// the wire, because the client shows a panel for every word. +func definedChainDict() *testDict { + return chainDict(). + define("a b", "danh từ", "Từ mở đầu."). + define("b c", "động từ", "Nối tiếp."). + define("b c", "", "Nghĩa thứ hai.") +} + +func assertSenses(t *testing.T, got []*noituv1.Sense, want ...[2]string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d senses %v, want %d", len(got), got, len(want)) + } + for i, w := range want { + if got[i].GetPos() != w[0] || got[i].GetGloss() != w[1] { + t.Errorf("sense %d = (%q, %q), want (%q, %q)", i, got[i].GetPos(), got[i].GetGloss(), w[0], w[1]) + } + } +} + +// TestBotGameCarriesMeanings checks the two messages that introduce a word to +// the client: the opening in GameStarted and each move in TurnUpdate, for a +// word with senses and for one without. +func TestBotGameCarriesMeanings(t *testing.T) { + _, url := newTestServer(t, definedChainDict(), Config{}) + c := dial(t, url) + c.hello("Ăn") + + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + started := c.await("game_started").GetGameStarted() + assertSenses(t, started.GetOpeningMeanings(), [2]string{"danh từ", "Từ mở đầu."}) + + c.submit("b c", started.GetTurnSeq()) + own := c.await("turn_update").GetTurnUpdate() + if !own.GetPlayed().GetByMe() { + t.Fatal("first update should be the player's own move") + } + assertSenses(t, own.GetPlayed().GetMeanings(), [2]string{"động từ", "Nối tiếp."}, [2]string{"", "Nghĩa thứ hai."}) + + // The bot's only move, "c d", has no definition: an empty list, not a + // missing message. + reply := c.await("turn_update").GetTurnUpdate() + if reply.GetPlayed().GetWord() != "c d" { + t.Fatalf("bot played %q, want c d", reply.GetPlayed().GetWord()) + } + if n := len(reply.GetPlayed().GetMeanings()); n != 0 { + t.Errorf("a word without a definition carried %d senses", n) + } +} + +// TestResumeReplaysMeanings drops a player after a move and checks that the +// replayed opening and last move both carry their senses, with no code path +// of their own. +func TestResumeReplaysMeanings(t *testing.T) { + _, url := newTestServer(t, definedChainDict(), Config{TurnLimit: 10 * time.Second, GraceFor: 5 * time.Second}) + + host := dial(t, url) + welcome := host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + readyAndStart(t, host, guest) + hostStart := host.await("game_started").GetGameStarted() + guest.await("game_started") + + // Whoever was dealt the turn plays the only legal word. + lead := guest + if hostStart.GetMyTurn() { + lead = host + } + lead.submit("b c", hostStart.GetTurnSeq()) + host.await("turn_update") + guest.await("turn_update") + + _ = host.conn.Close(websocket.StatusGoingAway, "") + guest.await("room_state") + + back := dial(t, url) + back.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ + ProtocolVersion: ProtocolVersion, + Nickname: "Chủ phòng", + ResumeToken: welcome.GetResumeToken(), + }}}) + back.await("welcome") + + restored := back.await("game_started").GetGameStarted() + assertSenses(t, restored.GetOpeningMeanings(), [2]string{"danh từ", "Từ mở đầu."}) + replayed := back.await("turn_update").GetTurnUpdate() + if replayed.GetPlayed().GetWord() != "b c" { + t.Fatalf("replayed move = %q, want b c", replayed.GetPlayed().GetWord()) + } + assertSenses(t, replayed.GetPlayed().GetMeanings(), [2]string{"động từ", "Nối tiếp."}, [2]string{"", "Nghĩa thứ hai."}) +} diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go index 03d5151..a7f0b10 100644 --- a/server/internal/wsapi/room.go +++ b/server/internal/wsapi/room.go @@ -10,6 +10,7 @@ import ( noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/dictionary" "github.com/tiennm99dev/noitu/server/internal/game" ) @@ -259,7 +260,9 @@ type room struct { } // Dictionary is everything the transport layer needs from the wordlist: the -// engine's own contract, plus a way to pick an opening. +// engine's own contract, a way to pick an opening, and the meanings a word +// travels to the client with. The engine never sees a meaning; only the room +// attaches them, where it renders a word for a recipient. // // An interface rather than *dictionary.Store so a test can play a whole game // against a hand-built graph of a dozen words, where the expected outcome is @@ -268,6 +271,8 @@ type room struct { type Dictionary interface { game.Dictionary RandomOpeningWord(minOutDegree int) (string, error) + // Meanings returns a canonical word's senses in order, nil for none. + Meanings(word string) []dictionary.Sense } func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration) *room { @@ -749,6 +754,7 @@ func (r *room) sendGameStarted(s *seat, state game.State) { s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameStarted{ GameStarted: &noituv1.GameStarted{ OpeningWord: r.opening, + OpeningMeanings: Senses(r.dict.Meanings(r.opening)), CurrentSyllable: state.Current, MyTurn: state.Turn == s.id, DeadlineUnixMs: state.Deadline.UnixMilli(), @@ -879,14 +885,25 @@ func (r *room) maybeScheduleBot() { // new player to act. func (r *room) broadcastTurn(move *game.Move) { state := r.engine.Snapshot() + meanings := r.moveMeanings(move) for _, s := range r.seats { - r.sendTurnUpdate(s, state, move) + r.sendTurnUpdate(s, state, move, meanings) } } +// moveMeanings looks up a played word's senses once per move; they are the +// same for every recipient. nil for no move. +func (r *room) moveMeanings(move *game.Move) []dictionary.Sense { + if move == nil { + return nil + } + return r.dict.Meanings(move.Word) +} + // sendTurnUpdate renders one position for one seat. by_me, my_turn and is_me -// are all per-recipient, which is why there is no single shared frame. -func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move) { +// are all per-recipient, which is why there is no single shared frame; the +// move's meanings are not, and arrive looked up. +func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move, meanings []dictionary.Sense) { if s == nil || s.sess == nil { return } @@ -900,7 +917,7 @@ func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move) { TurnPlayerId: string(state.Turn), } if move != nil { - update.Played = PlayedWord(*move, move.Player == s.id) + update.Played = PlayedWord(*move, move.Player == s.id, meanings) } s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: update}}) } @@ -1191,7 +1208,7 @@ func (r *room) handleResume(m resumeInput) { r.sendGameStarted(s, state) if len(state.History) > 0 { last := state.History[len(state.History)-1] - r.sendTurnUpdate(s, state, &last) + r.sendTurnUpdate(s, state, &last, r.moveMeanings(&last)) } } diff --git a/server/internal/wsapi/wire_test.go b/server/internal/wsapi/wire_test.go index 6686b78..e9f6357 100644 --- a/server/internal/wsapi/wire_test.go +++ b/server/internal/wsapi/wire_test.go @@ -72,6 +72,7 @@ func serverVariants() map[string]*noituv1.ServerMessage { }}}, "server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{ OpeningWord: "hòa bình", + OpeningMeanings: []*noituv1.Sense{{Pos: "danh từ", Gloss: "Tình trạng không có chiến tranh."}}, CurrentSyllable: "bình", MyTurn: true, DeadlineUnixMs: 1756998020000, @@ -88,6 +89,10 @@ func serverVariants() map[string]*noituv1.ServerMessage { Points: 2, Syllables: 2, PlayerId: "p2", + Meanings: []*noituv1.Sense{ + {Pos: "tính từ", Gloss: "Yên lành, không có gì xáo động."}, + {Pos: "", Gloss: "Xem bình an."}, + }, }, CurrentSyllable: "yên", MyTurn: true, diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index aa50fa4..43a41ee 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -17,6 +17,7 @@ import ( "github.com/coder/websocket" noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/dictionary" "github.com/tiennm99dev/noitu/server/internal/game" "google.golang.org/protobuf/proto" ) @@ -30,10 +31,13 @@ type testDict struct { // words maps a canonical word to its first and last syllable. words map[string][2]string opening string + // senses holds the meanings of the few words a test gives one to; every + // other word has none, which is also a case the client must handle. + senses map[string][]dictionary.Sense } func newTestDict(opening string, words ...string) *testDict { - d := &testDict{words: map[string][2]string{}, opening: opening} + d := &testDict{words: map[string][2]string{}, opening: opening, senses: map[string][]dictionary.Sense{}} for _, w := range append(words, opening) { parts := strings.Fields(w) d.words[w] = [2]string{parts[0], parts[len(parts)-1]} @@ -41,6 +45,14 @@ func newTestDict(opening string, words ...string) *testDict { return d } +// define gives a word one sense, so a test can see it arrive on the wire. +func (d *testDict) define(word, pos, gloss string) *testDict { + d.senses[word] = append(d.senses[word], dictionary.Sense{Pos: pos, Gloss: gloss}) + return d +} + +func (d *testDict) Meanings(word string) []dictionary.Sense { return d.senses[word] } + func (d *testDict) Resolve(word string) (string, bool) { _, ok := d.words[word] return word, ok diff --git a/web/src/lib/proto/noitu/v1/game_pb.d.ts b/web/src/lib/proto/noitu/v1/game_pb.d.ts index ff01c45..0ac1c78 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.d.ts +++ b/web/src/lib/proto/noitu/v1/game_pb.d.ts @@ -411,6 +411,15 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & { * @generated from field: string player_id = 6; */ playerId: string; + + /** + * 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. + * + * @generated from field: repeated noitu.v1.Sense meanings = 7; + */ + meanings: Sense[]; }; /** @@ -419,6 +428,33 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & { */ export declare const PlayedWordSchema: GenMessage; +/** + * 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. + * + * @generated from message noitu.v1.Sense + */ +export declare type Sense = Message<"noitu.v1.Sense"> & { + /** + * @generated from field: string pos = 1; + */ + pos: string; + + /** + * @generated from field: string gloss = 2; + */ + gloss: string; +}; + +/** + * Describes the message noitu.v1.Sense. + * Use `create(SenseSchema)` to create a new message. + */ +export declare const SenseSchema: GenMessage; + /** * PlayerSlot is one seat in the room, rendered for one recipient. * @@ -600,6 +636,13 @@ export declare type GameStarted = Message<"noitu.v1.GameStarted"> & { * @generated from field: string turn_player_id = 8; */ turnPlayerId: string; + + /** + * The opening word's senses, as PlayedWord.meanings for a played word. + * + * @generated from field: repeated noitu.v1.Sense opening_meanings = 9; + */ + openingMeanings: Sense[]; }; /** diff --git a/web/src/lib/proto/noitu/v1/game_pb.js b/web/src/lib/proto/noitu/v1/game_pb.js index 1e66f86..bae0720 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.js +++ b/web/src/lib/proto/noitu/v1/game_pb.js @@ -21,7 +21,7 @@ import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/code * Describes the file noitu/v1/game.proto. */ export const file_noitu_v1_game = /*@__PURE__*/ - fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiHwoKS2lja1BsYXllchIRCglwbGF5ZXJfaWQYASABKAkiCwoJTGVhdmVSb29tIhgKCFNlbmRDaGF0EgwKBHRleHQYASABKAkiHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyKQBAoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAEicKCXNlbmRfY2hhdBgNIAEoCzISLm5vaXR1LnYxLlNlbmRDaGF0SABCCQoHcGF5bG9hZEoECAgQCSJoCgdXZWxjb21lEhIKCnNlc3Npb25faWQYASABKAkSFAoMcmVzdW1lX3Rva2VuGAIgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YAyABKA0SGQoRYWNjZXB0ZWRfbmlja25hbWUYBCABKAkibgoKUGxheWVkV29yZBIMCgR3b3JkGAEgASgJEg0KBWJ5X21lGAIgASgIEg4KBnBvaW50cxgDIAEoDRIRCglzeWxsYWJsZXMYBCABKA0SDQoFdHlwZWQYBSABKAkSEQoJcGxheWVyX2lkGAYgASgJIn4KClBsYXllclNsb3QSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSEAoIaXNfb3duZXIYBCABKAgSDQoFcmVhZHkYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIEgwKBHdpbnMYByABKA0igQEKC1BsYXllclNjb3JlEhEKCXBsYXllcl9pZBgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWlzX21lGAMgASgIEg0KBXNjb3JlGAQgASgNEhIKCmVsaW1pbmF0ZWQYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIEgwKBHJhbmsYByABKA0i0QEKC0dhbWVTdGFydGVkEhQKDG9wZW5pbmdfd29yZBgBIAEoCRIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIVCg10dXJuX2xpbWl0X21zGAYgASgNEiYKB3BsYXllcnMYByADKAsyFS5ub2l0dS52MS5QbGF5ZXJTY29yZRIWCg50dXJuX3BsYXllcl9pZBgIIAEoCSLrAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDRImCgdwbGF5ZXJzGAkgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmUSFgoOdHVybl9wbGF5ZXJfaWQYCiABKAlKBAgGEAdKBAgHEAgiVgoMTW92ZVJlamVjdGVkEiYKBnJlYXNvbhgBIAEoDjIWLm5vaXR1LnYxLlJlamVjdFJlYXNvbhIMCgR3b3JkGAIgASgJEhAKCHR1cm5fc2VxGAMgASgNIo4BCghHYW1lT3ZlchINCgVpX3dvbhgBIAEoCBInCgZyZWFzb24YAiABKA4yFy5ub2l0dS52MS5HYW1lRW5kUmVhc29uEhQKDGNoYWluX2xlbmd0aBgEIAEoDRIoCglzdGFuZGluZ3MYBiADKAsyFS5ub2l0dS52MS5QbGF5ZXJTY29yZUoECAMQBEoECAUQBiKAAQoQUGxheWVyRWxpbWluYXRlZBIRCglwbGF5ZXJfaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgVpc19tZRgDIAEoCBInCgZyZWFzb24YBCABKA4yFy5ub2l0dS52MS5HYW1lRW5kUmVhc29uEhMKC3N1Z2dlc3Rpb25zGAUgAygJIiwKC1NlcnZlckVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI2CgRQb25nEhYKDmNsaWVudF90aW1lX21zGAEgASgDEhYKDnNlcnZlcl90aW1lX21zGAIgASgDIrgBCglSb29tU3RhdGUSEQoJcm9vbV9jb2RlGAEgASgJEhEKCWNhbl9zdGFydBgDIAEoCBIlCgdwbGF5ZXJzGAkgAygLMhQubm9pdHUudjEuUGxheWVyU2xvdBITCgttYXhfcGxheWVycxgKIAEoDRITCgttaW5fcGxheWVycxgLIAEoDRIQCghncmFjZV9tcxgMIAEoDUoECAIQA0oECAQQBUoECAUQBkoECAYQB0oECAcQCEoECAgQCSJlCgtDaGF0TWVzc2FnZRIPCgdmcm9tX21lGAEgASgIEg4KBmF1dGhvchgCIAEoCRIMCgR0ZXh0GAMgASgJEhQKDHNlbnRfdW5peF9tcxgEIAEoAxIRCglwbGF5ZXJfaWQYBSABKAkiNgoLQ2hhdEhpc3RvcnkSJwoIbWVzc2FnZXMYASADKAsyFS5ub2l0dS52MS5DaGF0TWVzc2FnZSKYBAoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDGdhbWVfc3RhcnRlZBgEIAEoCzIVLm5vaXR1LnYxLkdhbWVTdGFydGVkSAASKwoLdHVybl91cGRhdGUYBSABKAsyFC5ub2l0dS52MS5UdXJuVXBkYXRlSAASLwoNbW92ZV9yZWplY3RlZBgGIAEoCzIWLm5vaXR1LnYxLk1vdmVSZWplY3RlZEgAEicKCWdhbWVfb3ZlchgHIAEoCzISLm5vaXR1LnYxLkdhbWVPdmVySAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIABI3ChFwbGF5ZXJfZWxpbWluYXRlZBgPIAEoCzIaLm5vaXR1LnYxLlBsYXllckVsaW1pbmF0ZWRIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICBAJSgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lHTkVEEARCOlo4Z2l0aHViLmNvbS90aWVubm05OWRldi9ub2l0dS9zZXJ2ZXIvZ2VuL25vaXR1L3YxO25vaXR1djFiBnByb3RvMw"); + fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiHwoKS2lja1BsYXllchIRCglwbGF5ZXJfaWQYASABKAkiCwoJTGVhdmVSb29tIhgKCFNlbmRDaGF0EgwKBHRleHQYASABKAkiHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyKQBAoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAEicKCXNlbmRfY2hhdBgNIAEoCzISLm5vaXR1LnYxLlNlbmRDaGF0SABCCQoHcGF5bG9hZEoECAgQCSJoCgdXZWxjb21lEhIKCnNlc3Npb25faWQYASABKAkSFAoMcmVzdW1lX3Rva2VuGAIgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YAyABKA0SGQoRYWNjZXB0ZWRfbmlja25hbWUYBCABKAkikQEKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJEhEKCXBsYXllcl9pZBgGIAEoCRIhCghtZWFuaW5ncxgHIAMoCzIPLm5vaXR1LnYxLlNlbnNlIiMKBVNlbnNlEgsKA3BvcxgBIAEoCRINCgVnbG9zcxgCIAEoCSJ+CgpQbGF5ZXJTbG90EhEKCXBsYXllcl9pZBgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWlzX21lGAMgASgIEhAKCGlzX293bmVyGAQgASgIEg0KBXJlYWR5GAUgASgIEhEKCWNvbm5lY3RlZBgGIAEoCBIMCgR3aW5zGAcgASgNIoEBCgtQbGF5ZXJTY29yZRIRCglwbGF5ZXJfaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgVpc19tZRgDIAEoCBINCgVzY29yZRgEIAEoDRISCgplbGltaW5hdGVkGAUgASgIEhEKCWNvbm5lY3RlZBgGIAEoCBIMCgRyYW5rGAcgASgNIvwBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDRImCgdwbGF5ZXJzGAcgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmUSFgoOdHVybl9wbGF5ZXJfaWQYCCABKAkSKQoQb3BlbmluZ19tZWFuaW5ncxgJIAMoCzIPLm5vaXR1LnYxLlNlbnNlIusBCgpUdXJuVXBkYXRlEiQKBnBsYXllZBgBIAEoCzIULm5vaXR1LnYxLlBsYXllZFdvcmQSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFAoMY2hhaW5fbGVuZ3RoGAggASgNEiYKB3BsYXllcnMYCSADKAsyFS5ub2l0dS52MS5QbGF5ZXJTY29yZRIWCg50dXJuX3BsYXllcl9pZBgKIAEoCUoECAYQB0oECAcQCCJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0ijgEKCEdhbWVPdmVyEg0KBWlfd29uGAEgASgIEicKBnJlYXNvbhgCIAEoDjIXLm5vaXR1LnYxLkdhbWVFbmRSZWFzb24SFAoMY2hhaW5fbGVuZ3RoGAQgASgNEigKCXN0YW5kaW5ncxgGIAMoCzIVLm5vaXR1LnYxLlBsYXllclNjb3JlSgQIAxAESgQIBRAGIoABChBQbGF5ZXJFbGltaW5hdGVkEhEKCXBsYXllcl9pZBgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWlzX21lGAMgASgIEicKBnJlYXNvbhgEIAEoDjIXLm5vaXR1LnYxLkdhbWVFbmRSZWFzb24SEwoLc3VnZ2VzdGlvbnMYBSADKAkiLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMiuAEKCVJvb21TdGF0ZRIRCglyb29tX2NvZGUYASABKAkSEQoJY2FuX3N0YXJ0GAMgASgIEiUKB3BsYXllcnMYCSADKAsyFC5ub2l0dS52MS5QbGF5ZXJTbG90EhMKC21heF9wbGF5ZXJzGAogASgNEhMKC21pbl9wbGF5ZXJzGAsgASgNEhAKCGdyYWNlX21zGAwgASgNSgQIAhADSgQIBBAFSgQIBRAGSgQIBhAHSgQIBxAISgQICBAJImUKC0NoYXRNZXNzYWdlEg8KB2Zyb21fbWUYASABKAgSDgoGYXV0aG9yGAIgASgJEgwKBHRleHQYAyABKAkSFAoMc2VudF91bml4X21zGAQgASgDEhEKCXBsYXllcl9pZBgFIAEoCSI2CgtDaGF0SGlzdG9yeRInCghtZXNzYWdlcxgBIAMoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlIpgECg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABImCgVlcnJvchgJIAEoCzIVLm5vaXR1LnYxLlNlcnZlckVycm9ySAASHgoEcG9uZxgKIAEoCzIOLm5vaXR1LnYxLlBvbmdIABIpCgpyb29tX3N0YXRlGAwgASgLMhMubm9pdHUudjEuUm9vbVN0YXRlSAASLQoMY2hhdF9tZXNzYWdlGA0gASgLMhUubm9pdHUudjEuQ2hhdE1lc3NhZ2VIABItCgxjaGF0X2hpc3RvcnkYDiABKAsyFS5ub2l0dS52MS5DaGF0SGlzdG9yeUgAEjcKEXBsYXllcl9lbGltaW5hdGVkGA8gASgLMhoubm9pdHUudjEuUGxheWVyRWxpbWluYXRlZEgAQgkKB3BheWxvYWRKBAgCEANKBAgDEARKBAgIEAlKBAgLEAwqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z"); /** * Describes the message noitu.v1.Hello. @@ -128,96 +128,103 @@ export const WelcomeSchema = /*@__PURE__*/ export const PlayedWordSchema = /*@__PURE__*/ messageDesc(file_noitu_v1_game, 14); +/** + * Describes the message noitu.v1.Sense. + * Use `create(SenseSchema)` to create a new message. + */ +export const SenseSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 15); + /** * Describes the message noitu.v1.PlayerSlot. * Use `create(PlayerSlotSchema)` to create a new message. */ export const PlayerSlotSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 15); + messageDesc(file_noitu_v1_game, 16); /** * Describes the message noitu.v1.PlayerScore. * Use `create(PlayerScoreSchema)` to create a new message. */ export const PlayerScoreSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 16); + messageDesc(file_noitu_v1_game, 17); /** * Describes the message noitu.v1.GameStarted. * Use `create(GameStartedSchema)` to create a new message. */ export const GameStartedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 17); + messageDesc(file_noitu_v1_game, 18); /** * Describes the message noitu.v1.TurnUpdate. * Use `create(TurnUpdateSchema)` to create a new message. */ export const TurnUpdateSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 18); + messageDesc(file_noitu_v1_game, 19); /** * Describes the message noitu.v1.MoveRejected. * Use `create(MoveRejectedSchema)` to create a new message. */ export const MoveRejectedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 19); + messageDesc(file_noitu_v1_game, 20); /** * Describes the message noitu.v1.GameOver. * Use `create(GameOverSchema)` to create a new message. */ export const GameOverSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 20); + messageDesc(file_noitu_v1_game, 21); /** * Describes the message noitu.v1.PlayerEliminated. * Use `create(PlayerEliminatedSchema)` to create a new message. */ export const PlayerEliminatedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 21); + messageDesc(file_noitu_v1_game, 22); /** * Describes the message noitu.v1.ServerError. * Use `create(ServerErrorSchema)` to create a new message. */ export const ServerErrorSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 22); + messageDesc(file_noitu_v1_game, 23); /** * Describes the message noitu.v1.Pong. * Use `create(PongSchema)` to create a new message. */ export const PongSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 23); + messageDesc(file_noitu_v1_game, 24); /** * Describes the message noitu.v1.RoomState. * Use `create(RoomStateSchema)` to create a new message. */ export const RoomStateSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 24); + messageDesc(file_noitu_v1_game, 25); /** * Describes the message noitu.v1.ChatMessage. * Use `create(ChatMessageSchema)` to create a new message. */ export const ChatMessageSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 25); + messageDesc(file_noitu_v1_game, 26); /** * Describes the message noitu.v1.ChatHistory. * Use `create(ChatHistorySchema)` to create a new message. */ export const ChatHistorySchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 26); + messageDesc(file_noitu_v1_game, 27); /** * Describes the message noitu.v1.ServerMessage. * Use `create(ServerMessageSchema)` to create a new message. */ export const ServerMessageSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 27); + messageDesc(file_noitu_v1_game, 28); /** * Describes the enum noitu.v1.Difficulty. diff --git a/web/tests/game-wire.test.js b/web/tests/game-wire.test.js index 8ac3029..d79eb73 100644 --- a/web/tests/game-wire.test.js +++ b/web/tests/game-wire.test.js @@ -112,6 +112,22 @@ describe('generated wire types', () => { expect(turn.turnSeq).toBe(2); }); + // The label and the definition travel as two fields, so the client decides + // how a label is shown and never has to parse it back out of the text. + it('carries a word\'s senses on the opening and on each move', () => { + const start = decode('server_game_started'); + expect(start.payload.value.openingMeanings).toEqual([ + expect.objectContaining({ pos: 'danh từ', gloss: 'Tình trạng không có chiến tranh.' }) + ]); + + const turn = decode('server_turn_update'); + const senses = turn.payload.value.played.meanings; + expect(senses).toHaveLength(2); + expect(senses[0].pos).toBe('tính từ'); + expect(senses[1].pos).toBe(''); + expect(senses[1].gloss).toBe('Xem bình an.'); + }); + it('decodes an empty client arm without losing its tag', () => { const msg = decode('client_create_room'); expect(msg.payload.case).toBe('createRoom');