diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 45e7555..f8c6e36 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -254,6 +254,17 @@ let showDiscards = $state(false); let chatDraft = $state(""); let botTier = $state(prefs.botTier); + let botStyle = $state<"hunter" | "berserker" | "worrier" | "mystery">("hunter"); + let addingBot = $state(false); + /** The wizard picker stands open until you have a wizard; after that, on request. */ + let colorPickerToggled = $state(null); + const colorPickerOpen = $derived(colorPickerToggled ?? (net.you != null && net.roomColors[net.you] === undefined)); + const BOT_BLURBS = { + hunter: "A hunter goes for the treasure and carries it home.", + berserker: "A berserker goes for blood, and worries about treasure later.", + worrier: "A worrier keeps to the shadows and guards what is theirs.", + mystery: "Mystery deals one of the three at random; you learn which when the game ends.", + } as const; /** Card whose official FAQ rulings are open. */ let faqCardId = $state(null); /** A discard-pile card enlarged above the pile. */ @@ -2420,26 +2431,9 @@ {:else if !local.active && !net.started}
-
Room {net.roomId}
- {#if !net.spectating} -
Your color
-
- {#each [0, 1, 2, 3, 4, 5] as c (c)} - {@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]} - - {/each} -
- {/if} +
Gather your wizards
+
Room {net.roomId} · {net.players.length} of 6 seats filled
+ {#if net.spectating} {#if net.players.length < 6} @@ -2457,66 +2451,119 @@

👁 The table is full — you watch from the Peanut Gallery. Waiting for the boards to flip…

{/if} {/if} -
Fill the table
-
Two to six wizards enter the maze — friends by code or link, - computer wizards seated below, or a mix of both.
-
- {inviteLink} - -
-
    + +
      {#each net.players as p (p)} {@const chosen = net.roomColors[p]} -
    • + {@const isYou = p === net.you} +
    • {#if chosen !== undefined} - + {:else} - + + {/if} + + {p}{isYou ? " (you)" : ""} + + {#if net.roomBots[p]}⚙ {net.roomBots[p]}{:else if chosen === undefined}{p === net.hostId ? "host · " : ""}choosing a wizard…{:else if p === net.hostId}host{:else}wizard{/if} + + + {#if isYou && !net.spectating} + {/if} - {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""} {#if net.you === net.hostId && p !== net.hostId} - {/if}
    • + {#if isYou && !net.spectating && colorPickerOpen} +
    • + {#each [0, 1, 2, 3, 4, 5] as c (c)} + {@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]} + + {/each} +
    • + {/if} {/each} -
    - {#if net.you === net.hostId} {#if net.players.length < 6} -
    Seat a computer wizard
    -
    An automaton plays a full seat by the rules — a hunter goes for treasure, - a berserker for blood, a worrier for the shadows between; mystery deals one at random.
    - - ⚙ seat a - - - - - - +
  • + + + An empty seat + {net.players.length < 2 ? "one more wizard and the boards can flip" : "room for another"} + +
  • {/if} -
+ + {#if !net.spectating} +
+ + {#if net.you === net.hostId && net.players.length < 6} + + {/if} +
+
+ the link itself, for pasting by hand + {inviteLink} +
+ {#if net.you === net.hostId && addingBot && net.players.length < 6} +
+
+ Difficulty + + {#each ["apprentice", "adept", "archmage"] as t (t)} + + {/each} + +
+
+ Personality + + {#each ["hunter", "berserker", "worrier", "mystery"] as st (st)} + + {/each} + +
+
{BOT_BLURBS[botStyle]}
+ +
+ {/if} + {/if} + + {#if net.you === net.hostId} + - - {#if net.players.length < 2} -
Seat a computer wizard, or invite a friend, and the boards can flip.
- {/if} - +
+ {#if net.players.length < 2} +
Waiting for one more wizard
+ {/if} + + +
{:else}

Waiting for {net.hostId} to flip the boards…

{/if} @@ -3476,7 +3523,23 @@ overflow: hidden; } .standee img { width: 100%; height: 100%; object-fit: cover; display: block; } + .standee { position: relative; } .standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); } + .standee.current::after { + content: "✓"; + position: absolute; + right: 0.1rem; + bottom: 0.05rem; + width: 1rem; + height: 1rem; + border-radius: 50%; + background: var(--ring, #43331f); + color: #fff; + font-size: 0.7rem; + line-height: 1rem; + text-align: center; + font-weight: 700; + } .standee.taken { opacity: 0.28; cursor: default; filter: grayscale(0.8); } .setup-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; } .setup-input { @@ -3614,10 +3677,6 @@ color: #8a6d3f; margin: 1.1rem 0 0; } - .stamp.big.begin { display: inline-flex; flex-direction: column; align-items: center; line-height: 1.15; } - .stamp.big.begin .sub { font-size: 0.68rem; letter-spacing: 0.1em; opacity: 0.75; margin-top: 0.15rem; } - .begin-hint { font-size: 0.85rem; color: #8a6d3f; margin-top: 0.4rem; } - .stamp.tiny.abandon { display: block; margin: 0.9rem auto 0; } .about-link { background: none; border: none; @@ -3655,18 +3714,65 @@ .boxlid-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; justify-content: center; } .or { font-size: 0.85rem; color: #6b5a41; } .code { width: 6.5rem; padding: 0.55rem 0.4rem; text-transform: uppercase; text-align: center; letter-spacing: 0.15em; } - .roster { list-style: none; padding: 0; margin: 0 0 1rem; text-align: left; } - .roster li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; font-size: 1.05rem; } - .roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; } - .check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; } - .waiting { color: #6b5a41; font-style: italic; } - .invite-row { - display: flex; - align-items: center; + /* The lobby is a table being assembled: the roster is the picture, the + * ways to fill a seat sit beneath it, the start waits at the bottom. */ + .lobby-sub { color: #6b5a41; font-size: 0.95rem; margin-top: 0.3rem; } + .room-code { font-family: "Courier Prime", monospace; letter-spacing: 0.12em; color: #43331f; } + .table-roster { list-style: none; padding: 0; margin: 1rem 0 0.8rem; text-align: left; } + .seat { display: flex; align-items: center; gap: 0.7rem; padding: 0.4rem 0.5rem; border-radius: 5px; } + .seat.you { background: rgba(255, 252, 240, 0.55); border: 1px solid #d8cdae; } + .seat.open { opacity: 0.7; } + .seat-portrait { width: 2.6rem; height: 2.6rem; border-radius: 5px; object-fit: cover; flex: 0 0 auto; } + .seat-portrait.empty { border: 1.5px dashed #b3a687; background: rgba(0, 0, 0, 0.03); box-sizing: border-box; } + .seat-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; } + .seat-name { font-size: 1.05rem; font-weight: 700; } + .seat-role { font-size: 0.82rem; color: #6b5a41; } + .color-pick { + display: grid; + grid-template-columns: repeat(3, 3.1rem); + gap: 0.45rem; justify-content: center; - gap: 0.5rem; - margin-bottom: 0.5rem; + padding: 0.5rem 0 0.7rem; } + .fill-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; } + .fill-row .stamp.current { background: #e4dbc2; } + .invite-fallback { margin: 0.4rem 0 0; font-size: 0.8rem; } + .invite-fallback summary { cursor: pointer; color: #8a6d3f; font-style: italic; } + .invite-fallback .invite-link { display: inline-block; margin-top: 0.3rem; word-break: break-all; } + .bot-panel { + margin-top: 0.8rem; + padding: 0.7rem 0.8rem; + border: 1px solid #b3a687; + border-radius: 5px; + background: rgba(255, 252, 240, 0.45); + display: flex; + flex-direction: column; + gap: 0.55rem; + align-items: center; + } + .bot-field { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.3rem 0.6rem; } + .bot-field-label { font-family: "Oswald", sans-serif; font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a6d3f; min-width: 5.2rem; text-align: right; } + .seg { display: inline-flex; flex-wrap: wrap; gap: 0.25rem; } + .seg button { + font-family: "Oswald", sans-serif; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + background: none; + border: 1.5px solid #b3a687; + border-radius: 3px; + color: #6b5a41; + padding: 0.25rem 0.55rem; + cursor: pointer; + } + .seg button.current { border-color: #43331f; color: #43331f; background: #e4dbc2; } + .bot-blurb { font-size: 0.9rem; color: #6b5a41; font-style: italic; } + .check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; } + .check.option { margin: 1rem 0 0.6rem; font-size: 0.88rem; color: #6b5a41; } + .ready { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; } + .ready-note { font-size: 0.88rem; color: #8a6d3f; font-style: italic; } + .ready .abandon { margin-top: 0.4rem; font-size: 0.8rem; opacity: 0.75; } + .waiting { color: #6b5a41; font-style: italic; } .invite-link { font-size: 0.8rem; color: #6b5a41; @@ -4335,17 +4441,6 @@ color: #43331f; padding: 0.1rem 0.25rem; } - .bot-row { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - gap: 0.35rem; - max-width: 100%; - font-size: 0.85rem; - color: #6b5a41; - } - .bot-label { white-space: nowrap; } .discard-link { background: none; border: none;