mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +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
@@ -72,12 +72,18 @@ the seat of any guest who is not ready, naming it rather than pointing at "the
|
||||
other one". An owner who leaves hands the room to whoever is left, and the last
|
||||
player out closes it, as does ten minutes with no game started.
|
||||
|
||||
The room also keeps the score of the series: how many games each seat has won
|
||||
since the room opened, shown in the lobby and beside each player's score on the
|
||||
board. It is a room fact rather than a game one, credited the moment a game
|
||||
ends, and a seat that is vacated takes its tally with it — the name on it no
|
||||
longer means the same person.
|
||||
|
||||
Joining is a lobby thing: a room with a game running turns a latecomer away
|
||||
even when it has seats going spare, because there is no way to hand somebody a
|
||||
game already in progress.
|
||||
|
||||
The whole room travels as one `RoomState` per recipient — the seating, roles,
|
||||
readiness and presence — so a client that missed a frame is correct again from
|
||||
readiness, presence and the series score — so a client that missed a frame is correct again from
|
||||
the next one rather than from a stream of deltas it has to replay. The
|
||||
recipient's own row is in that list like everybody else's, marked `is_me`,
|
||||
which is the only encoding of their role there is: a second one alongside would
|
||||
@@ -88,9 +94,17 @@ the chain, the clock, the chat — with only the word input gone, and the final
|
||||
screen shows everybody's standings, ranked by who outlasted whom with each
|
||||
score reported beside the place rather than deciding it.
|
||||
|
||||
Everybody in the room can talk, in the lobby and during a game. The conversation
|
||||
belongs to the room rather than to a game, so it survives one starting and
|
||||
finishing, and it dies with the room. A player is replayed what was said while
|
||||
A wide screen puts the game and the conversation side by side, so neither has
|
||||
to be scrolled past to reach the other; a phone gets one column, the game first
|
||||
with the chat below it, folded behind an unread count while a game is on.
|
||||
|
||||
Everybody in the room can talk, in the lobby and during a game. The
|
||||
conversation reads as a log — one line per message, `name: text`, each seat
|
||||
writing in its own colour — rather than as a stack of bubbles, which stops
|
||||
being legible once four people are talking. The colour comes from the seat the
|
||||
server says spoke, never from matching names. The conversation belongs to the
|
||||
room rather than to a game, so it survives one starting and finishing, and it
|
||||
dies with the room. A player is replayed what was said while
|
||||
they held their seat: a refresh brings their conversation back, and somebody
|
||||
who walks in with the code starts at silence rather than reading what the last
|
||||
people in the room said. Text passes the same filter as a nickname before anyone sees
|
||||
@@ -98,7 +112,7 @@ it — control and format characters dropped, whitespace collapsed, combining
|
||||
marks capped — and a bot game has no chat, there being nobody to talk to.
|
||||
|
||||
When somebody leaves their seat their words stay in the conversation but their
|
||||
name does not: the panel shows them as having left. A name left behind would be
|
||||
name does not: the panel shows them as having left, in nobody's colour. A name left behind would be
|
||||
one the next person to walk in could ask for, and the words above it would
|
||||
become theirs.
|
||||
|
||||
|
||||
@@ -192,6 +192,11 @@ message PlayerSlot {
|
||||
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.
|
||||
@@ -353,6 +358,10 @@ message ChatMessage {
|
||||
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
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
rl
|
||||
,Ngưá»�i chÆ¡iBắt đầu nhé €»÷©‘3
|
||||
"Khách má»�iSẵn sà ng Ⱦ÷©‘3
|
||||
rt
|
||||
0Ngưá»�i chÆ¡iBắt đầu nhé €»÷©‘3*p1
|
||||
&Khách má»�iSẵn sà ng Ⱦ÷©‘3*p2
|
||||
Tôi phải Ä‘i •Á÷©‘3
|
||||
+1
-1
@@ -1 +1 @@
|
||||
j;Khách má»�i$Tiếng “sinhâ€� khó nối lắm. û»÷©‘3
|
||||
j?Khách má»�i$Tiếng “sinhâ€� khó nối lắm. û»÷©‘3*p2
|
||||
Vendored
+4
-4
@@ -1,5 +1,5 @@
|
||||
bV
|
||||
K7QXJ
|
||||
p1Người chơi 0J
|
||||
p2Khách mời(0J
|
||||
bZ
|
||||
K7QXJ
|
||||
p1Người chơi 08J
|
||||
p2Khách mời(08J
|
||||
p3Kh獺ch 2(PX`國
|
||||
Generated
+31
-6
@@ -1180,7 +1180,12 @@ type PlayerSlot struct {
|
||||
// Always false for the owner, whose readiness is StartGame itself.
|
||||
Ready bool `protobuf:"varint,5,opt,name=ready,proto3" json:"ready,omitempty"`
|
||||
// False while this player is inside their reconnect window.
|
||||
Connected bool `protobuf:"varint,6,opt,name=connected,proto3" json:"connected,omitempty"`
|
||||
Connected bool `protobuf:"varint,6,opt,name=connected,proto3" json:"connected,omitempty"`
|
||||
// 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.
|
||||
Wins uint32 `protobuf:"varint,7,opt,name=wins,proto3" json:"wins,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1257,6 +1262,13 @@ func (x *PlayerSlot) GetConnected() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *PlayerSlot) GetWins() uint32 {
|
||||
if x != nil {
|
||||
return x.Wins
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PlayerScore is one player in a running or finished game.
|
||||
//
|
||||
// Separate from PlayerSlot because they answer different questions: a slot is
|
||||
@@ -2012,7 +2024,11 @@ type ChatMessage struct {
|
||||
Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"`
|
||||
Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"`
|
||||
// Server clock. The client renders it; it never orders by its own clock.
|
||||
SentUnixMs int64 `protobuf:"varint,4,opt,name=sent_unix_ms,json=sentUnixMs,proto3" json:"sent_unix_ms,omitempty"`
|
||||
SentUnixMs int64 `protobuf:"varint,4,opt,name=sent_unix_ms,json=sentUnixMs,proto3" json:"sent_unix_ms,omitempty"`
|
||||
// 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.
|
||||
PlayerId string `protobuf:"bytes,5,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2075,6 +2091,13 @@ func (x *ChatMessage) GetSentUnixMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ChatMessage) GetPlayerId() string {
|
||||
if x != nil {
|
||||
return x.PlayerId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -2418,7 +2441,7 @@ 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\"\xa1\x01\n" +
|
||||
"\tplayer_id\x18\x06 \x01(\tR\bplayerId\"\xb5\x01\n" +
|
||||
"\n" +
|
||||
"PlayerSlot\x12\x1b\n" +
|
||||
"\tplayer_id\x18\x01 \x01(\tR\bplayerId\x12\x12\n" +
|
||||
@@ -2426,7 +2449,8 @@ const file_noitu_v1_game_proto_rawDesc = "" +
|
||||
"\x05is_me\x18\x03 \x01(\bR\x04isMe\x12\x19\n" +
|
||||
"\bis_owner\x18\x04 \x01(\bR\aisOwner\x12\x14\n" +
|
||||
"\x05ready\x18\x05 \x01(\bR\x05ready\x12\x1c\n" +
|
||||
"\tconnected\x18\x06 \x01(\bR\tconnected\"\xbb\x01\n" +
|
||||
"\tconnected\x18\x06 \x01(\bR\tconnected\x12\x12\n" +
|
||||
"\x04wins\x18\a \x01(\rR\x04wins\"\xbb\x01\n" +
|
||||
"\vPlayerScore\x12\x1b\n" +
|
||||
"\tplayer_id\x18\x01 \x01(\tR\bplayerId\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x13\n" +
|
||||
@@ -2487,13 +2511,14 @@ const file_noitu_v1_game_proto_rawDesc = "" +
|
||||
"maxPlayers\x12\x1f\n" +
|
||||
"\vmin_players\x18\v \x01(\rR\n" +
|
||||
"minPlayers\x12\x19\n" +
|
||||
"\bgrace_ms\x18\f \x01(\rR\agraceMsJ\x04\b\x02\x10\x03J\x04\b\x04\x10\x05J\x04\b\x05\x10\x06J\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\b\x10\t\"t\n" +
|
||||
"\bgrace_ms\x18\f \x01(\rR\agraceMsJ\x04\b\x02\x10\x03J\x04\b\x04\x10\x05J\x04\b\x05\x10\x06J\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\b\x10\t\"\x91\x01\n" +
|
||||
"\vChatMessage\x12\x17\n" +
|
||||
"\afrom_me\x18\x01 \x01(\bR\x06fromMe\x12\x16\n" +
|
||||
"\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
|
||||
"\x04text\x18\x03 \x01(\tR\x04text\x12 \n" +
|
||||
"\fsent_unix_ms\x18\x04 \x01(\x03R\n" +
|
||||
"sentUnixMs\"@\n" +
|
||||
"sentUnixMs\x12\x1b\n" +
|
||||
"\tplayer_id\x18\x05 \x01(\tR\bplayerId\"@\n" +
|
||||
"\vChatHistory\x121\n" +
|
||||
"\bmessages\x18\x01 \x03(\v2\x15.noitu.v1.ChatMessageR\bmessages\"\x96\x05\n" +
|
||||
"\rServerMessage\x12-\n" +
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export function board(page) {
|
||||
syllable: page.getByTestId('current-syllable'),
|
||||
turn: page.getByTestId('turn-indicator'),
|
||||
input: page.getByRole('textbox', { name: 'Nhập từ của bạn' }),
|
||||
submit: page.getByRole('button', { name: 'Gửi', exact: true }),
|
||||
submit: page.getByTestId('word-submit'),
|
||||
chain: page.getByRole('list').first(),
|
||||
rejection: page.getByRole('alert')
|
||||
};
|
||||
|
||||
+41
-11
@@ -16,9 +16,11 @@ import {
|
||||
* socket. One context with two tabs would share a session and prove nothing
|
||||
* about two people playing.
|
||||
*/
|
||||
async function twoPlayers(browser) {
|
||||
const hostContext = await browser.newContext();
|
||||
const guestContext = await browser.newContext();
|
||||
async function twoPlayers(browser, options = {}) {
|
||||
// newContext does not inherit the project's `use` options, so a test about
|
||||
// a phone-sized screen has to pass the viewport in here.
|
||||
const hostContext = await browser.newContext(options);
|
||||
const guestContext = await browser.newContext(options);
|
||||
return {
|
||||
host: await hostContext.newPage(),
|
||||
guest: await guestContext.newPage(),
|
||||
@@ -73,9 +75,11 @@ test.describe('playing a stranger', () => {
|
||||
// naming anybody.
|
||||
await expect(board(guest).turn).toHaveText('Đến lượt Minh…');
|
||||
|
||||
// Each side is shown the other's server-sanitized name.
|
||||
await expect(host.locator('.who', { hasText: 'Lan' })).toBeVisible();
|
||||
await expect(guest.locator('.who', { hasText: 'Minh' })).toBeVisible();
|
||||
// Each side is shown the other's server-sanitized name. Scoped to the
|
||||
// scoreboard: the turn indicator names a player too, so an unscoped
|
||||
// match is ambiguous for whoever is not on turn.
|
||||
await expect(host.getByTestId('scoreboard').getByText('Lan')).toBeVisible();
|
||||
await expect(guest.getByTestId('scoreboard').getByText('Minh')).toBeVisible();
|
||||
|
||||
const used = new Set(await chainWords(host));
|
||||
const hostWord = await playLegalMove(host, used);
|
||||
@@ -131,6 +135,12 @@ test.describe('playing a stranger', () => {
|
||||
// The readiness that started the first game is spent, so the owner
|
||||
// cannot simply start another.
|
||||
await expect(host.getByTestId('start-game')).toBeDisabled();
|
||||
|
||||
// The series score of the room, which the finished game has just moved:
|
||||
// the guest took it, so the owner's lobby shows 1 against their seat.
|
||||
await expect(host.getByTestId('wins-p2')).toContainText('Tỉ số 1');
|
||||
await expect(host.getByTestId('wins-p1')).toContainText('Tỉ số 0');
|
||||
|
||||
await readyAndStart(host, guest);
|
||||
|
||||
// A new game in the same room: the code is unchanged and the board is
|
||||
@@ -139,6 +149,10 @@ test.describe('playing a stranger', () => {
|
||||
await expect(host.getByText(code)).toBeVisible();
|
||||
expect(await chainWords(host)).toHaveLength(1);
|
||||
|
||||
// And the tally is carried into it, where the board shows it beside
|
||||
// each player's score for this game.
|
||||
await expect(host.getByTestId('series-p2')).toContainText('Tỉ số 1');
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
@@ -242,17 +256,33 @@ test.describe('playing a stranger', () => {
|
||||
await say(guest, 'chào!');
|
||||
await expect(chat(host).log).toContainText('chào!');
|
||||
|
||||
// And the conversation follows them into the game.
|
||||
// The log is a list of lines, each naming its author and written in
|
||||
// that seat's own colour, so four people talking stay tellable apart.
|
||||
const lines = chat(host).log.getByRole('listitem');
|
||||
await expect(lines.first()).toContainText('Minh:');
|
||||
await expect(lines.nth(1)).toContainText('Lan:');
|
||||
const [first, second] = await Promise.all([
|
||||
lines.first().evaluate((el) => getComputedStyle(el).color),
|
||||
lines.nth(1).evaluate((el) => getComputedStyle(el).color)
|
||||
]);
|
||||
expect(first).not.toBe(second);
|
||||
|
||||
// And the conversation follows them into the game, where a screen this
|
||||
// wide keeps it beside the board rather than folding it away.
|
||||
await readyAndStart(host, guest);
|
||||
await waitForMyTurn(host);
|
||||
await host.getByRole('button', { name: 'Trò chuyện' }).click();
|
||||
await expect(chat(host).log).toContainText('chào bạn');
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('the board panel opens with nothing unread from the lobby', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
// A phone: one column, so the board's chat folds behind an unread count
|
||||
// instead of sitting beside the game. Nothing folds on a wide screen,
|
||||
// and a badge for a panel that is always open would be a lie.
|
||||
const { host, guest, close } = await twoPlayers(browser, {
|
||||
viewport: { width: 420, height: 900 }
|
||||
});
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
@@ -266,8 +296,8 @@ test.describe('playing a stranger', () => {
|
||||
await readyAndStart(host, guest);
|
||||
await waitForMyTurn(host);
|
||||
|
||||
// The board's panel is a fresh instance of the same component. What it
|
||||
// mounted with is not new mail.
|
||||
// The panel that folds on the way into the game is the one that was
|
||||
// open in the lobby. What it had already shown is not new mail.
|
||||
await expect(chat(host).unread).toHaveCount(0);
|
||||
|
||||
// And it does start counting what actually arrives while folded. The
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
--warn: #b06d0a;
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 8%), 0 8px 24px rgb(0 0 0 / 6%);
|
||||
|
||||
/* One colour per seat, in seat order. The chat log is written in them, so
|
||||
a line's author is legible before its name is read. Four, because a room
|
||||
holds four. */
|
||||
--player-1: #1f6f4a;
|
||||
--player-2: #1d5c8f;
|
||||
--player-3: #9a5312;
|
||||
--player-4: #7a3f8f;
|
||||
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--gap: 12px;
|
||||
@@ -41,6 +49,11 @@
|
||||
--warn: #e0a94a;
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 8px 24px rgb(0 0 0 / 30%);
|
||||
|
||||
--player-1: #6fc79a;
|
||||
--player-2: #79b9ec;
|
||||
--player-3: #e6a563;
|
||||
--player-4: #c193e0;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,26 @@
|
||||
* client never appends its own copy of a message, so there is one ordering
|
||||
* rather than a guessed one.
|
||||
*
|
||||
* `collapsible` is for the board, where the panel folds behind an unread
|
||||
* count so it does not crowd a phone screen.
|
||||
* Three facts about where it is, because each is a different question and
|
||||
* the caller is the only one that can answer them:
|
||||
*
|
||||
* @type {{ collapsible?: boolean, onsend: (text: string) => void }}
|
||||
* - `collapsible` folds the panel behind an unread count. That is for the
|
||||
* board on a narrow screen, where the game and the chat share one column
|
||||
* and an open log would crowd the board off it.
|
||||
* - `column` says the panel has a column of its own, so the log grows into
|
||||
* the height it is given instead of stopping at a phone's worth.
|
||||
* - `errors` shows the room's refusals here. The lobby renders none of its
|
||||
* own, so this is where they land; the board has its own alert, and two
|
||||
* boxes for one error is worse than none.
|
||||
*
|
||||
* @type {{
|
||||
* collapsible?: boolean,
|
||||
* column?: boolean,
|
||||
* errors?: boolean,
|
||||
* onsend: (text: string) => void
|
||||
* }}
|
||||
*/
|
||||
let { collapsible = false, onsend } = $props();
|
||||
let { collapsible = false, column = false, errors = false, onsend } = $props();
|
||||
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let list = $state();
|
||||
@@ -66,6 +80,19 @@
|
||||
if (room < 80) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
/**
|
||||
* The colour a seat writes in, from the palette in app.css. A line whose
|
||||
* seat the server cleared has no colour of its own: it belongs to nobody,
|
||||
* and painting it as somebody would be a lie about who said it.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {string}
|
||||
*/
|
||||
function colourOf(playerId) {
|
||||
const seat = game.seatIndexOf(playerId);
|
||||
return seat ? `var(--player-${seat})` : 'var(--text-muted)';
|
||||
}
|
||||
|
||||
/** @param {number} atMs */
|
||||
function clock(atMs) {
|
||||
const at = new Date(atMs);
|
||||
@@ -112,14 +139,15 @@
|
||||
{#if messages.length === 0}
|
||||
<p class="empty">{t.chatEmpty}</p>
|
||||
{:else}
|
||||
<ol bind:this={list} data-testid="chat-log">
|
||||
<!-- A log, not a stack of bubbles: every line reads "name: text" in
|
||||
its author's colour, so four people talking stay tellable apart
|
||||
without a shape per speaker. -->
|
||||
<ol bind:this={list} class:column data-testid="chat-log">
|
||||
{#each messages as entry}
|
||||
<li class:mine={entry.fromMe}>
|
||||
{#if !entry.fromMe}
|
||||
<!-- An author the server cleared belongs to nobody: the seat
|
||||
they spoke from may be somebody else's now. -->
|
||||
<span class="author">{entry.author || t.chatAuthorLeft}</span>
|
||||
{/if}
|
||||
<li style:color={colourOf(entry.playerId)}>
|
||||
<!-- An author the server cleared belongs to nobody: the seat
|
||||
they spoke from may be somebody else's now. -->
|
||||
<span class="author">{entry.author || t.chatAuthorLeft}:</span>
|
||||
<!-- Interpolated, never {@html}: this is another player's text. -->
|
||||
<span class="text">{entry.text}</span>
|
||||
<span class="at">{clock(entry.atMs)}</span>
|
||||
@@ -128,12 +156,9 @@
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#if !collapsible && game.state.error}
|
||||
<!-- Only where nothing else shows one. The lobby renders no errors
|
||||
of its own, so this is where its refusals land — too_fast on a
|
||||
burst, must_unready_first, player_is_ready. The board already
|
||||
has an alert of its own, and two boxes for one error is worse
|
||||
than none. -->
|
||||
{#if errors && game.state.error}
|
||||
<!-- too_fast on a burst, must_unready_first, player_is_ready: the
|
||||
lobby's refusals, which nothing else on that screen shows. -->
|
||||
<p class="error" role="alert" data-testid="chat-error">
|
||||
{game.state.error}
|
||||
<button type="button" onclick={() => game.clearError()} aria-label={t.dismiss}>×</button>
|
||||
@@ -215,7 +240,9 @@
|
||||
ol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
/* Stacked under the game, so bounded: the field it scrolls above has to
|
||||
stay on screen. */
|
||||
max-height: 180px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -223,26 +250,25 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Given a column of its own, the log takes the height of it. */
|
||||
ol.column {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
li.mine {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
/* Colour is set per line, from the author's seat. Everything else about
|
||||
a line is the same for everybody. */
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
* onsubmit: (word: string) => boolean,
|
||||
* onresign: () => void,
|
||||
* gameOver: import('svelte').Snippet,
|
||||
* banner?: import('svelte').Snippet,
|
||||
* chat?: import('svelte').Snippet
|
||||
* banner?: import('svelte').Snippet
|
||||
* }}
|
||||
*/
|
||||
let { modeLabel = '', onsubmit, onresign, gameOver, banner, chat } = $props();
|
||||
let { modeLabel = '', onsubmit, onresign, gameOver, banner } = $props();
|
||||
|
||||
// Whose turn it is, said by name. With four people at the table "the
|
||||
// opponent is thinking" stops naming anybody.
|
||||
@@ -75,11 +74,6 @@
|
||||
|
||||
<ChainHistory />
|
||||
|
||||
<!-- Online play passes a chat panel; the bot screen passes none, which is
|
||||
how "a bot game has no chat" stays a fact about the markup rather than
|
||||
a condition somebody has to remember to check. -->
|
||||
{#if chat}{@render chat()}{/if}
|
||||
|
||||
{#if game.state.phase === 'playing' && !game.iAmOut}
|
||||
<button type="button" class="resign" onclick={onresign}>{t.resign}</button>
|
||||
{/if}
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
<span class="role">{player.isOwner ? t.owner : t.guest}</span>
|
||||
|
||||
<span class="right">
|
||||
<!-- The series, not this game: how many games this seat has
|
||||
taken since the room opened. Shown from zero, so the
|
||||
tally is a thing the room has rather than something
|
||||
that appears once somebody wins. -->
|
||||
<span class="wins" data-testid={`wins-${player.playerId}`}>
|
||||
{t.winsLabel} <strong>{player.wins}</strong>
|
||||
</span>
|
||||
|
||||
{#if !player.connected}
|
||||
<span class="state offline">{t.offline}</span>
|
||||
{:else if !player.isOwner}
|
||||
@@ -196,6 +204,18 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.wins {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wins strong {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
// Standings once the game is over, so the board settles into the result
|
||||
// rather than freezing on the last position.
|
||||
const players = $derived(s.phase === 'over' && s.standings.length ? s.standings : s.gamePlayers);
|
||||
// The series score, which belongs to the room rather than to this game. A
|
||||
// bot game is played in a room with no seating to speak of, so there is
|
||||
// nothing to tally and the row says nothing about one.
|
||||
const series = $derived(s.roomPlayers.length > 0);
|
||||
</script>
|
||||
|
||||
<ul class="board" data-testid="scoreboard">
|
||||
@@ -29,6 +33,11 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="score">{player.score}</span>
|
||||
{#if series}
|
||||
<span class="series" data-testid={`series-${player.playerId}`}>
|
||||
{t.winsLabel} {game.winsOf(player.playerId)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if player.rank === 1}
|
||||
<span class="badge win">{t.winnerBadge}</span>
|
||||
{:else if player.eliminated}
|
||||
@@ -103,6 +112,13 @@
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -94,7 +94,9 @@
|
||||
oncompositionstart={() => (composing = true)}
|
||||
oncompositionend={() => (composing = false)}
|
||||
/>
|
||||
<button type="submit" disabled={!enabled}>{t.submit}</button>
|
||||
<!-- Named, because the chat's send button says the same word: both are a
|
||||
"Gửi", and only a test can tell them apart by where they are. -->
|
||||
<button type="submit" disabled={!enabled} data-testid="word-submit">{t.submit}</button>
|
||||
</form>
|
||||
|
||||
{#if game.state.rejection}
|
||||
|
||||
@@ -87,6 +87,10 @@ export const t = {
|
||||
chatUnread: '{n} tin mới',
|
||||
chatAuthorLeft: 'Đã rời phòng',
|
||||
|
||||
// The series score: how many games each player has won since the room
|
||||
// opened, which is a room fact rather than a game one.
|
||||
winsLabel: 'Tỉ số',
|
||||
|
||||
lobbyTitle: 'Phòng chờ',
|
||||
owner: 'Chủ phòng',
|
||||
guest: 'Khách',
|
||||
|
||||
+19
@@ -469,6 +469,16 @@ export declare type PlayerSlot = Message<"noitu.v1.PlayerSlot"> & {
|
||||
* @generated from field: bool connected = 6;
|
||||
*/
|
||||
connected: boolean;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @generated from field: uint32 wins = 7;
|
||||
*/
|
||||
wins: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -912,6 +922,15 @@ export declare type ChatMessage = Message<"noitu.v1.ChatMessage"> & {
|
||||
* @generated from field: int64 sent_unix_ms = 4;
|
||||
*/
|
||||
sentUnixMs: bigint;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @generated from field: string player_id = 5;
|
||||
*/
|
||||
playerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Generated
+1
-1
@@ -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("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiHwoKS2lja1BsYXllchIRCglwbGF5ZXJfaWQYASABKAkiCwoJTGVhdmVSb29tIhgKCFNlbmRDaGF0EgwKBHRleHQYASABKAkiHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyKQBAoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAEicKCXNlbmRfY2hhdBgNIAEoCzISLm5vaXR1LnYxLlNlbmRDaGF0SABCCQoHcGF5bG9hZEoECAgQCSJoCgdXZWxjb21lEhIKCnNlc3Npb25faWQYASABKAkSFAoMcmVzdW1lX3Rva2VuGAIgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YAyABKA0SGQoRYWNjZXB0ZWRfbmlja25hbWUYBCABKAkibgoKUGxheWVkV29yZBIMCgR3b3JkGAEgASgJEg0KBWJ5X21lGAIgASgIEg4KBnBvaW50cxgDIAEoDRIRCglzeWxsYWJsZXMYBCABKA0SDQoFdHlwZWQYBSABKAkSEQoJcGxheWVyX2lkGAYgASgJInAKClBsYXllclNsb3QSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSEAoIaXNfb3duZXIYBCABKAgSDQoFcmVhZHkYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIIoEBCgtQbGF5ZXJTY29yZRIRCglwbGF5ZXJfaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgVpc19tZRgDIAEoCBINCgVzY29yZRgEIAEoDRISCgplbGltaW5hdGVkGAUgASgIEhEKCWNvbm5lY3RlZBgGIAEoCBIMCgRyYW5rGAcgASgNItEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDRImCgdwbGF5ZXJzGAcgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmUSFgoOdHVybl9wbGF5ZXJfaWQYCCABKAki6wEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0SJgoHcGxheWVycxgJIAMoCzIVLm5vaXR1LnYxLlBsYXllclNjb3JlEhYKDnR1cm5fcGxheWVyX2lkGAogASgJSgQIBhAHSgQIBxAIIlYKDE1vdmVSZWplY3RlZBImCgZyZWFzb24YASABKA4yFi5ub2l0dS52MS5SZWplY3RSZWFzb24SDAoEd29yZBgCIAEoCRIQCgh0dXJuX3NlcRgDIAEoDSKOAQoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIUCgxjaGFpbl9sZW5ndGgYBCABKA0SKAoJc3RhbmRpbmdzGAYgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmVKBAgDEARKBAgFEAYigAEKEFBsYXllckVsaW1pbmF0ZWQSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSJwoGcmVhc29uGAQgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhITCgtzdWdnZXN0aW9ucxgFIAMoCSIsCgtTZXJ2ZXJFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiNgoEUG9uZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAxIWCg5zZXJ2ZXJfdGltZV9tcxgCIAEoAyK4AQoJUm9vbVN0YXRlEhEKCXJvb21fY29kZRgBIAEoCRIRCgljYW5fc3RhcnQYAyABKAgSJQoHcGxheWVycxgJIAMoCzIULm5vaXR1LnYxLlBsYXllclNsb3QSEwoLbWF4X3BsYXllcnMYCiABKA0SEwoLbWluX3BsYXllcnMYCyABKA0SEAoIZ3JhY2VfbXMYDCABKA1KBAgCEANKBAgEEAVKBAgFEAZKBAgGEAdKBAgHEAhKBAgIEAkiUgoLQ2hhdE1lc3NhZ2USDwoHZnJvbV9tZRgBIAEoCBIOCgZhdXRob3IYAiABKAkSDAoEdGV4dBgDIAEoCRIUCgxzZW50X3VuaXhfbXMYBCABKAMiNgoLQ2hhdEhpc3RvcnkSJwoIbWVzc2FnZXMYASADKAsyFS5ub2l0dS52MS5DaGF0TWVzc2FnZSKYBAoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDGdhbWVfc3RhcnRlZBgEIAEoCzIVLm5vaXR1LnYxLkdhbWVTdGFydGVkSAASKwoLdHVybl91cGRhdGUYBSABKAsyFC5ub2l0dS52MS5UdXJuVXBkYXRlSAASLwoNbW92ZV9yZWplY3RlZBgGIAEoCzIWLm5vaXR1LnYxLk1vdmVSZWplY3RlZEgAEicKCWdhbWVfb3ZlchgHIAEoCzISLm5vaXR1LnYxLkdhbWVPdmVySAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIABI3ChFwbGF5ZXJfZWxpbWluYXRlZBgPIAEoCzIaLm5vaXR1LnYxLlBsYXllckVsaW1pbmF0ZWRIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICBAJSgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lLine truncated
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiHwoKS2lja1BsYXllchIRCglwbGF5ZXJfaWQYASABKAkiCwoJTGVhdmVSb29tIhgKCFNlbmRDaGF0EgwKBHRleHQYASABKAkiHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyKQBAoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAEicKCXNlbmRfY2hhdBgNIAEoCzISLm5vaXR1LnYxLlNlbmRDaGF0SABCCQoHcGF5bG9hZEoECAgQCSJoCgdXZWxjb21lEhIKCnNlc3Npb25faWQYASABKAkSFAoMcmVzdW1lX3Rva2VuGAIgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YAyABKA0SGQoRYWNjZXB0ZWRfbmlja25hbWUYBCABKAkibgoKUGxheWVkV29yZBIMCgR3b3JkGAEgASgJEg0KBWJ5X21lGAIgASgIEg4KBnBvaW50cxgDIAEoDRIRCglzeWxsYWJsZXMYBCABKA0SDQoFdHlwZWQYBSABKAkSEQoJcGxheWVyX2lkGAYgASgJIn4KClBsYXllclNsb3QSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSEAoIaXNfb3duZXIYBCABKAgSDQoFcmVhZHkYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIEgwKBHdpbnMYByABKA0igQEKC1BsYXllclNjb3JlEhEKCXBsYXllcl9pZBgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWlzX21lGAMgASgIEg0KBXNjb3JlGAQgASgNEhIKCmVsaW1pbmF0ZWQYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIEgwKBHJhbmsYByABKA0i0QEKC0dhbWVTdGFydGVkEhQKDG9wZW5pbmdfd29yZBgBIAEoCRIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIVCg10dXJuX2xpbWl0X21zGAYgASgNEiYKB3BsYXllcnMYByADKAsyFS5ub2l0dS52MS5QbGF5ZXJTY29yZRIWCg50dXJuX3BsYXllcl9pZBgIIAEoCSLrAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDRImCgdwbGF5ZXJzGAkgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmUSFgoOdHVybl9wbGF5ZXJfaWQYCiABKAlKBAgGEAdKBAgHEAgiVgoMTW92ZVJlamVjdGVkEiYKBnJlYXNvbhgBIAEoDjIWLm5vaXR1LnYxLlJlamVjdFJlYXNvbhIMCgR3b3JkGAIgASgJEhAKCHR1cm5fc2VxGAMgASgNIo4BCghHYW1lT3ZlchINCgVpX3dvbhgBIAEoCBInCgZyZWFzb24YAiABKA4yFy5ub2l0dS52MS5HYW1lRW5kUmVhc29uEhQKDGNoYWluX2xlbmd0aBgEIAEoDRIoCglzdGFuZGluZ3MYBiADKAsyFS5ub2l0dS52MS5QbGF5ZXJTY29yZUoECAMQBEoECAUQBiKAAQoQUGxheWVyRWxpbWluYXRlZBIRCglwbGF5ZXJfaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgVpc19tZRgDIAEoCBInCgZyZWFzb24YBCABKA4yFy5ub2l0dS52MS5HYW1lRW5kUmVhc29uEhMKC3N1Z2dlc3Rpb25zGAUgAygJIiwKC1NlcnZlckVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI2CgRQb25nEhYKDmNsaWVudF90aW1lX21zGAEgASgDEhYKDnNlcnZlcl90aW1lX21zGAIgASgDIrgBCglSb29tU3RhdGUSEQoJcm9vbV9jb2RlGAEgASgJEhEKCWNhbl9zdGFydBgDIAEoCBIlCgdwbGF5ZXJzGAkgAygLMhQubm9pdHUudjEuUGxheWVyU2xvdBITCgttYXhfcGxheWVycxgKIAEoDRITCgttaW5fcGxheWVycxgLIAEoDRIQCghncmFjZV9tcxgMIAEoDUoECAIQA0oECAQQBUoECAUQBkoECAYQB0oECAcQCEoECAgQCSJlCgtDaGF0TWVzc2FnZRIPCgdmcm9tX21lGAEgASgIEg4KBmF1dGhvchgCIAEoCRIMCgR0ZXh0GAMgASgJEhQKDHNlbnRfdW5peF9tcxgEIAEoAxIRCglwbGF5ZXJfaWQYBSABKAkiNgoLQ2hhdEhpc3RvcnkSJwoIbWVzc2FnZXMYASADKAsyFS5ub2l0dS52MS5DaGF0TWVzc2FnZSKYBAoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDGdhbWVfc3RhcnRlZBgEIAEoCzIVLm5vaXR1LnYxLkdhbWVTdGFydGVkSAASKwoLdHVybl91cGRhdGUYBSABKAsyFC5ub2l0dS52MS5UdXJuVXBkYXRlSAASLwoNbW92ZV9yZWplY3RlZBgGIAEoCzIWLm5vaXR1LnYxLk1vdmVSZWplY3RlZEgAEicKCWdhbWVfb3ZlchgHIAEoCzISLm5vaXR1LnYxLkdhbWVPdmVySAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIABI3ChFwbGF5ZXJfZWxpbWluYXRlZBgPIAEoCzIaLm5vaXR1LnYxLlBsYXllckVsaW1pbmF0ZWRIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICBAJSgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVLine truncated
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Hello.
|
||||
|
||||
@@ -28,6 +28,7 @@ export const CHAT_WINDOW = 20;
|
||||
* @property {boolean} isOwner
|
||||
* @property {boolean} ready
|
||||
* @property {boolean} connected
|
||||
* @property {number} wins - games won since the room opened
|
||||
*
|
||||
* @typedef {object} PlayerScore
|
||||
* @property {string} playerId
|
||||
@@ -124,7 +125,7 @@ function initialState() {
|
||||
* belongs to the room rather than to a game, so it survives reset();
|
||||
* leaving the room is what clears it.
|
||||
*
|
||||
* @type {{ fromMe: boolean, author: string, text: string, atMs: number }[]}
|
||||
* @type {{ fromMe: boolean, playerId: string, author: string, text: string, atMs: number }[]}
|
||||
*/
|
||||
chat: [],
|
||||
/**
|
||||
@@ -245,7 +246,8 @@ export function createGameStore() {
|
||||
isMe: p.isMe,
|
||||
isOwner: p.isOwner,
|
||||
ready: p.ready,
|
||||
connected: p.connected
|
||||
connected: p.connected,
|
||||
wins: p.wins
|
||||
}));
|
||||
// The lobby is where a room sits when no game is on. `over`
|
||||
// keeps its result panel, which the lobby appears beneath.
|
||||
@@ -351,6 +353,7 @@ export function createGameStore() {
|
||||
case 'chatMessage':
|
||||
state.chat.push({
|
||||
fromMe: value.fromMe,
|
||||
playerId: value.playerId,
|
||||
author: value.author,
|
||||
text: value.text,
|
||||
// int64 on the wire, which the runtime hands over as a
|
||||
@@ -371,6 +374,7 @@ export function createGameStore() {
|
||||
// cannot outlive the room it was had in.
|
||||
state.chat = value.messages.map((/** @type {any} */ m) => ({
|
||||
fromMe: m.fromMe,
|
||||
playerId: m.playerId,
|
||||
author: m.author,
|
||||
text: m.text,
|
||||
atMs: Number(m.sentUnixMs)
|
||||
@@ -434,6 +438,34 @@ export function createGameStore() {
|
||||
const table = state.phase === 'over' ? state.standings : state.gamePlayers;
|
||||
return table.find((/** @type {PlayerScore} */ p) => p.isMe)?.score ?? 0;
|
||||
},
|
||||
/**
|
||||
* How many games a seat has won since the room opened. Read off the
|
||||
* room rather than the game: the tally spans games, and the table of
|
||||
* the one on screen cannot carry it.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {number}
|
||||
*/
|
||||
winsOf(playerId) {
|
||||
return (
|
||||
state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.playerId === playerId)?.wins ?? 0
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Which seat a player is in, 1-based, or 0 for nobody. It is what the
|
||||
* chat log colours a line by — the server says which seat spoke, so
|
||||
* the client never has to match display names.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {number}
|
||||
*/
|
||||
seatIndexOf(playerId) {
|
||||
if (!playerId) return 0;
|
||||
const at = state.roomPlayers.findIndex(
|
||||
(/** @type {PlayerSlot} */ p) => p.playerId === playerId
|
||||
);
|
||||
return at < 0 ? 0 : at + 1;
|
||||
},
|
||||
/**
|
||||
* Everybody whose reconnect window is currently running. The lobby and
|
||||
* the board both wait on the same list.
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { page } from '$app/state';
|
||||
import AttributionFooter from '$lib/components/AttributionFooter.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// The online screen puts the game and the room's conversation side by side
|
||||
// where the screen allows it, and two columns do not fit in a width chosen
|
||||
// for a phone. Every other screen is one column of reading, which is what
|
||||
// the narrower shell is for.
|
||||
const wide = $derived(page.route.id === '/online');
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<div class="shell" class:wide>
|
||||
<header>
|
||||
<a class="brand" href="/">{t.appName}</a>
|
||||
<ThemeToggle />
|
||||
@@ -30,6 +37,10 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shell.wide {
|
||||
max-width: 1040px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -41,6 +41,29 @@
|
||||
*/
|
||||
let pending = $state(null);
|
||||
|
||||
/**
|
||||
* Where the two-column layout starts. The media queries in the styles
|
||||
* below are the same decision expressed in CSS, so the two must agree: the
|
||||
* columns are drawn there, and what goes in them — a chat panel that folds
|
||||
* or one with a column of its own — is decided here.
|
||||
*/
|
||||
const WIDE = '(min-width: 900px)';
|
||||
|
||||
// Whether the room is being drawn as two columns. Read from the browser
|
||||
// rather than assumed, because the chat panel behaves differently in each:
|
||||
// stacked under the board it folds behind an unread count, and beside it
|
||||
// there is nothing to fold out of the way of.
|
||||
let wide = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const mq = window.matchMedia(WIDE);
|
||||
wide = mq.matches;
|
||||
/** @param {MediaQueryListEvent} event */
|
||||
const onChange = (event) => (wide = event.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
let codeInput = $state(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
|
||||
let codeError = $state('');
|
||||
// True while the only reason this screen has a socket is to reclaim a game
|
||||
@@ -49,6 +72,9 @@
|
||||
|
||||
const inviteCode = $derived(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
|
||||
const playing = $derived(game.state.phase === 'playing' || game.state.phase === 'over');
|
||||
// A seat in a room, whichever phase it is in. Both are the same layout —
|
||||
// the game or the lobby on one side, the conversation on the other.
|
||||
const inRoom = $derived(playing || game.state.phase === 'lobby');
|
||||
|
||||
// The resume worked, so nothing that happens from here is its fault.
|
||||
$effect(() => {
|
||||
@@ -190,32 +216,43 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="online">
|
||||
{#if playing}
|
||||
<GameBoard modeLabel={game.state.roomCode} onsubmit={play} onresign={giveUp}>
|
||||
{#snippet banner()}
|
||||
<PlayerStatus />
|
||||
{/snippet}
|
||||
{#snippet chat()}
|
||||
<ChatPanel collapsible onsend={say} />
|
||||
{/snippet}
|
||||
{#snippet gameOver()}
|
||||
<!-- No rematch button on the panel: the room is still here, and
|
||||
the next game is agreed in the lobby below exactly as the
|
||||
last one was. -->
|
||||
<GameOverPanel isRecord={false} onhome={goHome} />
|
||||
<Lobby
|
||||
compact
|
||||
onready={ready}
|
||||
onstart={start}
|
||||
onkick={kick}
|
||||
onleave={leave}
|
||||
/>
|
||||
{/snippet}
|
||||
</GameBoard>
|
||||
{:else if game.state.phase === 'lobby'}
|
||||
<Lobby onready={ready} onstart={start} onkick={kick} onleave={leave} />
|
||||
<ChatPanel onsend={say} />
|
||||
<section class="online" class:room={inRoom}>
|
||||
{#if inRoom}
|
||||
<!-- Two columns where there is room for them: the game on one side and
|
||||
the conversation on the other, so neither has to be scrolled past
|
||||
to reach the other. One column, game first, where there is not.
|
||||
|
||||
One chat panel across both phases, kept by staying in the same
|
||||
place in the markup: a panel remounted on the way into a game
|
||||
would reopen having read nothing, and the lobby's conversation
|
||||
would come back as unread mail. -->
|
||||
<div class="pane game">
|
||||
{#if playing}
|
||||
<GameBoard modeLabel={game.state.roomCode} onsubmit={play} onresign={giveUp}>
|
||||
{#snippet banner()}
|
||||
<PlayerStatus />
|
||||
{/snippet}
|
||||
{#snippet gameOver()}
|
||||
<!-- No rematch button on the panel: the room is still here, and
|
||||
the next game is agreed in the lobby below exactly as the
|
||||
last one was. -->
|
||||
<GameOverPanel isRecord={false} onhome={goHome} />
|
||||
<Lobby compact onready={ready} onstart={start} onkick={kick} onleave={leave} />
|
||||
{/snippet}
|
||||
</GameBoard>
|
||||
{:else}
|
||||
<Lobby onready={ready} onstart={start} onkick={kick} onleave={leave} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pane talk">
|
||||
<ChatPanel
|
||||
collapsible={playing && !wide}
|
||||
column={wide}
|
||||
errors={!playing}
|
||||
onsend={say}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>{t.onlineTitle}</h1>
|
||||
<p class="intro">{t.onlineIntro}</p>
|
||||
@@ -270,6 +307,60 @@
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Joining is a form, not a room: it keeps a form's width whatever the
|
||||
screen the two columns were widened for. */
|
||||
.online:not(.room) {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane.game {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Stacked: a divider does the work the second column's whitespace does. */
|
||||
.pane.talk {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Must match WIDE in the script above. */
|
||||
@media (min-width: 900px) {
|
||||
.online.room {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 320px);
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Each column scrolls on its own, so a long chain does not push the
|
||||
conversation off the screen and a long conversation does not push
|
||||
the word field off it. */
|
||||
.pane {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Stacked, the game is as tall as it is and the conversation follows
|
||||
it directly. Given a column, it takes the height of one. */
|
||||
.pane.game {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pane.talk {
|
||||
padding-top: 0;
|
||||
padding-left: 24px;
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
|
||||
@@ -317,6 +317,7 @@ function seat(fields = {}) {
|
||||
isOwner: false,
|
||||
ready: false,
|
||||
connected: true,
|
||||
wins: 0,
|
||||
...fields
|
||||
};
|
||||
}
|
||||
@@ -372,6 +373,37 @@ describe('room messages', () => {
|
||||
store.apply(pair());
|
||||
expect(store.freeSeats).toBe(2);
|
||||
});
|
||||
|
||||
it('reads the series score off the room, not off the game', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(
|
||||
pair({
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true, wins: 2 }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true, wins: 1 })
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.winsOf('p1')).toBe(2);
|
||||
expect(store.winsOf('p2')).toBe(1);
|
||||
// A game starting clears the board and keeps the room, tally included.
|
||||
store.apply(started());
|
||||
expect(store.winsOf('p1')).toBe(2);
|
||||
// Nobody the room does not seat has a tally.
|
||||
expect(store.winsOf('bot')).toBe(0);
|
||||
});
|
||||
|
||||
it('numbers the seats, which is what a chat line is coloured by', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(pair());
|
||||
|
||||
expect(store.seatIndexOf('p1')).toBe(1);
|
||||
expect(store.seatIndexOf('p2')).toBe(2);
|
||||
// A line whose seat the server cleared belongs to nobody.
|
||||
expect(store.seatIndexOf('')).toBe(0);
|
||||
expect(store.seatIndexOf('p9')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pong', () => {
|
||||
@@ -389,6 +421,7 @@ describe('chat', () => {
|
||||
function line(fields) {
|
||||
return msg('chatMessage', {
|
||||
fromMe: false,
|
||||
playerId: 'p2',
|
||||
author: 'Lan',
|
||||
text: 'chào',
|
||||
sentUnixMs: 1756998000123n,
|
||||
@@ -406,6 +439,15 @@ describe('chat', () => {
|
||||
expect(store.state.chat[0].atMs).toBe(1756998000123);
|
||||
});
|
||||
|
||||
it('keeps the seat a line came from, which is what colours it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(line({ text: 'của tôi', fromMe: true, playerId: 'p1' }));
|
||||
// An author the server cleared: the seat goes with the name.
|
||||
store.apply(line({ text: 'của ai', playerId: '', author: '' }));
|
||||
|
||||
expect(store.state.chat.map((/** @type {any} */ m) => m.playerId)).toEqual(['p1', '']);
|
||||
});
|
||||
|
||||
it('stops at the window the server keeps, so the two cannot disagree', () => {
|
||||
const store = createGameStore();
|
||||
for (let i = 0; i < CHAT_WINDOW + 5; i++) {
|
||||
|
||||
Reference in new issue
Block a user