mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-14 04:16:27 +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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user