mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-13 12:14:59 +00:00
feat(online): seat the chat beside the game and score the series
A room screen wide enough for it is two columns now: the game or the lobby on one side and the room's conversation on the other, so neither has to be scrolled past to reach the other. A phone keeps one column, the game first, with the chat folded behind an unread count while a game is on. One chat panel spans both phases by staying in the same place in the markup — a remounted panel reopened having read nothing, and handed the reader the lobby's conversation back as unread mail. The room also keeps the score of the series. PlayerSlot carries the games a seat has won since the room opened, credited before the lobby is broadcast so the players see it the moment a game ends, and shown in the lobby and beside each player's score on the board. It belongs to the seat rather than to the room, because vacating one is exactly when the name on it stops meaning the same person. The conversation reads as a log — one line per message, "name: text", each seat in its own colour — rather than as a stack of bubbles, which stops being legible once four people are talking. ChatMessage carries the seat that spoke, so a line is coloured by what the server said rather than by matching display names, and a line whose seat has been vacated is in nobody's colour. The board's word button gets a test id of its own: it and the chat's send button both read "Gửi", and the two are now on screen together.
This commit is contained in:
1 parent
4e2e9e1433
commit
e19b083dc0
24 files changed
+521
-104
No files matched your search
@@ -176,6 +176,11 @@ type seat struct {
|
||||
// filled. A replay starts there, which is what keeps a stranger who walks
|
||||
// in with the code from being handed what the last two people said.
|
||||
chatFrom uint64
|
||||
// wins counts the games this seat has taken since it was filled. A room
|
||||
// outlives its games, so a running tally has to live on something that
|
||||
// does too; the seat is the shortest-lived thing that still spans them,
|
||||
// and vacating it is exactly when the tally stops meaning one player.
|
||||
wins uint32
|
||||
// graceUntil is when this seat stops being held for the player who dropped
|
||||
// out of it, and zero while they are connected. Per seat rather than per
|
||||
// room because any number of them can be waiting at once.
|
||||
@@ -983,6 +988,12 @@ func (r *room) broadcastGameOver(state game.State) {
|
||||
reason = r.wireEndReason(state.Eliminated[n-1])
|
||||
}
|
||||
|
||||
// Credited before anything is sent, so the RoomState the run loop
|
||||
// broadcasts after a finished game already carries the game just won.
|
||||
if s := r.seatOf(state.Winner); s != nil {
|
||||
s.wins++
|
||||
}
|
||||
|
||||
ranks := make(map[game.PlayerID]int, len(state.Standings))
|
||||
order := make([]game.PlayerID, 0, len(state.Standings))
|
||||
for _, standing := range state.Standings {
|
||||
@@ -1258,6 +1269,9 @@ func chatMessageFor(entry chatEntry, id game.PlayerID) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatMessage{
|
||||
ChatMessage: &noituv1.ChatMessage{
|
||||
FromMe: entry.author != "" && entry.author == id,
|
||||
// Empty together with the name for a vacated seat: a line nobody
|
||||
// owns must not be coloured as somebody's either.
|
||||
PlayerId: string(entry.author),
|
||||
Author: entry.name,
|
||||
Text: entry.text,
|
||||
SentUnixMs: entry.at.UnixMilli(),
|
||||
@@ -1453,6 +1467,7 @@ func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot {
|
||||
IsOwner: s.id == r.owner,
|
||||
Ready: s.ready,
|
||||
Connected: s.sess != nil,
|
||||
Wins: s.wins,
|
||||
})
|
||||
}
|
||||
return slots
|
||||
|
||||
@@ -138,6 +138,7 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
// their opponent's answer as their own.
|
||||
"server_chat_message": {Payload: &noituv1.ServerMessage_ChatMessage{ChatMessage: &noituv1.ChatMessage{
|
||||
FromMe: false,
|
||||
PlayerId: "p2",
|
||||
Author: "Khách mời",
|
||||
Text: "Tiếng “sinh” khó nối lắm.",
|
||||
SentUnixMs: 1756998000123,
|
||||
@@ -147,8 +148,9 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
// from_me values, and the empty-author convention at once.
|
||||
"server_chat_history": {Payload: &noituv1.ServerMessage_ChatHistory{ChatHistory: &noituv1.ChatHistory{
|
||||
Messages: []*noituv1.ChatMessage{
|
||||
{FromMe: true, Author: "Người chơi", Text: "Bắt đầu nhé", SentUnixMs: 1756998000000},
|
||||
{FromMe: false, Author: "Khách mời", Text: "Sẵn sàng", SentUnixMs: 1756998000456},
|
||||
{FromMe: true, PlayerId: "p1", Author: "Người chơi", Text: "Bắt đầu nhé", SentUnixMs: 1756998000000},
|
||||
{FromMe: false, PlayerId: "p2", Author: "Khách mời", Text: "Sẵn sàng", SentUnixMs: 1756998000456},
|
||||
// A seat that has been vacated carries neither a name nor an id.
|
||||
{FromMe: false, Author: "", Text: "Tôi phải đi", SentUnixMs: 1756998000789},
|
||||
},
|
||||
}}},
|
||||
@@ -162,8 +164,8 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
MinPlayers: 2,
|
||||
GraceMs: 30000,
|
||||
Players: []*noituv1.PlayerSlot{
|
||||
{PlayerId: "p1", Name: "Người chơi", IsMe: true, IsOwner: true, Connected: true},
|
||||
{PlayerId: "p2", Name: "Khách mời", Ready: true, Connected: true},
|
||||
{PlayerId: "p1", Name: "Người chơi", IsMe: true, IsOwner: true, Connected: true, Wins: 2},
|
||||
{PlayerId: "p2", Name: "Khách mời", Ready: true, Connected: true, Wins: 1},
|
||||
{PlayerId: "p3", Name: "Khách 2", Ready: true},
|
||||
},
|
||||
}}},
|
||||
|
||||
@@ -1254,6 +1254,46 @@ func TestNextGameNeedsAFreshReady(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoomKeepsARunningWinTally: a room outlives its games, so the score of
|
||||
// the series is a room fact. It is credited before the lobby is broadcast, so
|
||||
// the players see it the moment a game ends rather than one input later.
|
||||
func TestRoomKeepsARunningWinTally(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
|
||||
// The owner resigns, so the guest takes the first game.
|
||||
hostState, guestState := resignAndSettle(t, host, guest)
|
||||
if got := mySlot(guestState).GetWins(); got != 1 {
|
||||
t.Errorf("the winner's tally is %d after one game, want 1", got)
|
||||
}
|
||||
if got := mySlot(hostState).GetWins(); got != 0 {
|
||||
t.Errorf("the loser's tally is %d, want 0", got)
|
||||
}
|
||||
// And each player is told the whole table, not just their own row.
|
||||
if got := otherSlot(hostState).GetWins(); got != 1 {
|
||||
t.Errorf("the owner sees the guest's tally as %d, want 1", got)
|
||||
}
|
||||
|
||||
// A second game the other way round leaves the series level.
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
host.startGame()
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
host.await("game_over")
|
||||
guest.await("game_over")
|
||||
hostState = host.await("room_state").GetRoomState()
|
||||
|
||||
if got := mySlot(hostState).GetWins(); got != 1 {
|
||||
t.Errorf("the owner's tally is %d after winning one of two, want 1", got)
|
||||
}
|
||||
if got := otherSlot(hostState).GetWins(); got != 1 {
|
||||
t.Errorf("the guest's tally is %d after winning one of two, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLobbyActionsAreRefusedDuringAGame keeps the lobby from being a way out
|
||||
// of a game in progress.
|
||||
func TestLobbyActionsAreRefusedDuringAGame(t *testing.T) {
|
||||
@@ -1484,6 +1524,13 @@ func TestChatReachesBothSeatsRenderedPerRecipient(t *testing.T) {
|
||||
if theirs.GetSentUnixMs() == 0 {
|
||||
t.Error("a message carries no time")
|
||||
}
|
||||
// The seat behind a line is the same fact for everybody: from_me is the
|
||||
// only field that is relative to the reader, and a client colours a line
|
||||
// by its author rather than by matching names.
|
||||
if mine.GetPlayerId() != theirs.GetPlayerId() || theirs.GetPlayerId() == "" {
|
||||
t.Errorf("the author's seat differs between recipients: %q and %q",
|
||||
mine.GetPlayerId(), theirs.GetPlayerId())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatWorksInTheLobbyAndInAGame: the conversation belongs to the room, not
|
||||
@@ -1886,6 +1933,11 @@ func TestVacatedSeatKeepsItsWordsButLosesItsAuthor(t *testing.T) {
|
||||
if got := history[0]; got.GetText() != "tôi là khách" || got.GetAuthor() != "" || got.GetFromMe() {
|
||||
t.Errorf("a vacated seat's message is still attributed: %+v", got)
|
||||
}
|
||||
// The seat goes with the name. A line still carrying it would be coloured
|
||||
// as whoever fills that seat next.
|
||||
if got := history[0].GetPlayerId(); got != "" {
|
||||
t.Errorf("a vacated seat's message still names its seat: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheRemainingPlayerIsResyncedWhenASeatIsVacated is the live half of the
|
||||
|
||||
Reference in new issue
Block a user