Compare commits
13 Commits
bf800a06db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d68a80a3 | |||
| b9cbbda1a2 | |||
| 3e912b9458 | |||
| 929941b8ff | |||
| 9a5a319cb9 | |||
| a5bfb7905f | |||
| b253a06b45 | |||
| 8470da4aa9 | |||
| 2de4648288 | |||
| 3fe3333768 | |||
| feb595af16 | |||
| 1a4bc86139 | |||
| 2210bcd48e |
@@ -20,6 +20,8 @@ Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list
|
|||||||
|
|
||||||
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
|
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
|
||||||
|
|
||||||
|
- If Alembic autogeneration says the target database is not up to date, create a temporary database with `DATABASE_URL=sqlite:////tmp/<name>.db .venv/bin/alembic upgrade head`, then run the autogeneration command with that same `DATABASE_URL`.
|
||||||
|
|
||||||
## Versioning & changelog
|
## Versioning & changelog
|
||||||
|
|
||||||
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
|
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -173,8 +173,16 @@ pirats/
|
|||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
||||||
Run the backend (`pirats --reload`) and the Vite dev server side by side; Vite proxies `/api` to port 8000:
|
Start the backend and Vite dev server together from the repository root. Pressing Ctrl-C stops both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only the frontend, start the backend separately on port 8000, then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to the backend on port 8000.
|
||||||
|
|||||||
16
TODO.md
16
TODO.md
@@ -1,11 +1,15 @@
|
|||||||
## DevOps/Maintenance
|
## Polish
|
||||||
|
|
||||||
- [x] **Logging**: Add some logging to the backend. The Nix service should log to /var/log/pirats.log by default (but be configurable). At very least, stderr should go there, plus important server-side events, like:
|
- [x] The character sheet modal popup is narrower than it needs to be on wide screens. I think it could safely be like 90% as wide as the non-modal UI.
|
||||||
- [x] **Purge old finished/inactive games**: Add a periodic task (daily is good enough, but make the interval configurable through an env variable/the nix flake) to purge games that have finished more than two weeks ago, or have had no moves played within the last month. These time windows should also be configurable. Log all purges, including crew names/uuids and how much disk space they recovered.
|
- [x] Popping out the event log should leave a close button where the "Event Log" open button is, so that you don't have to move your mouse to quickly toggle it. The existing close button can remain.
|
||||||
- [x] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
|
- [x] Use the same vertical UI for the player list in all phases
|
||||||
|
- [x] In the admin panel, if a pi-rat doesn't have a name yet, it should show something like 'not chosen yet' rather than the player name
|
||||||
|
- [x] In the admin panel, the "Open" and "Copy" buttons should be the same size
|
||||||
|
- [x] Store the player name in local storage and pre-populate it when joining a new game (but don't force it, let the player edit it if they'd prefer a different name)
|
||||||
|
- [x] If a player tries to rejoin a game that has ended or that they've been kicked from, they should get an error message and it should be removed from the list of re-joinable games
|
||||||
|
- [x] Games should have join codes in addition to join links. Join codes should be a sequence of 5 alphanumeric characters (upper case letters only, no ambiguous letters like 0/O/1/I). Add a panel to the home page for joining a game via code, and display the join code on the admin page
|
||||||
|
|
||||||
## Words Words Words (Waiting for Fable to come back)
|
## Words Words Words
|
||||||
|
|
||||||
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
||||||
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
||||||
|
|
||||||
|
|||||||
38
dev.sh
Executable file
38
dev.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PIDS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
|
||||||
|
if ((${#PIDS[@]})); then
|
||||||
|
kill "${PIDS[@]}" 2>/dev/null || true
|
||||||
|
wait "${PIDS[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
echo "Starting backend at http://localhost:8000"
|
||||||
|
"$ROOT_DIR/.venv/bin/uvicorn" pirats.main:app --host 0.0.0.0 --port 8000 --reload &
|
||||||
|
PIDS+=("$!")
|
||||||
|
|
||||||
|
echo "Starting frontend at http://localhost:5173"
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/frontend"
|
||||||
|
exec ./node_modules/.bin/vite
|
||||||
|
) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
|
||||||
|
echo "Press Ctrl-C to stop both servers."
|
||||||
|
wait
|
||||||
@@ -23,6 +23,83 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 10.5rem;
|
||||||
|
padding: 0.85rem 1.1rem 0.95rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--well));
|
||||||
|
color: var(--text);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, var(--well));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code.copied {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.admin-summary {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -51,6 +128,16 @@
|
|||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-copy-action > .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Game over --- */
|
/* --- Game over --- */
|
||||||
.gameover-container {
|
.gameover-container {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
@@ -75,20 +162,3 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crew-epitaph-row {
|
|
||||||
background: var(--well);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crew-epitaph-row .objectives-summary {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ input[type="checkbox"] {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phase-view-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr) minmax(300px, 0.8fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-main-column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1101px) {
|
||||||
|
.phase-view-layout.log-collapsed {
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.phase-view-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
padding: 3.5rem 1rem 4rem;
|
padding: 3.5rem 1rem 4rem;
|
||||||
|
|||||||
@@ -104,22 +104,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Crew creation progress rows */
|
|
||||||
.status-row {
|
|
||||||
background: var(--well);
|
|
||||||
border-left: 4px solid var(--edge);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row.is-ready {
|
|
||||||
border-left-color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Character sheet layout --- */
|
/* --- Character sheet layout --- */
|
||||||
.character-sheet-details {
|
.character-sheet-details {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -293,49 +293,6 @@
|
|||||||
color: color-mix(in srgb, var(--danger) 75%, var(--text));
|
color: color-mix(in srgb, var(--danger) 75%, var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Player chips --- */
|
|
||||||
.player-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip {
|
|
||||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
|
||||||
border: 1px solid var(--edge);
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
border-radius: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
transition: var(--transition-smooth);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.current-user {
|
|
||||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.voted-chip {
|
|
||||||
border-color: var(--success);
|
|
||||||
background-color: color-mix(in srgb, var(--success) 6%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.not-voted-chip {
|
|
||||||
border-color: var(--edge);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-chips {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-chips .player-chip {
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deep-chip {
|
.deep-chip {
|
||||||
border-color: var(--deep);
|
border-color: var(--deep);
|
||||||
background: color-mix(in srgb, var(--deep) 5%, transparent);
|
background: color-mix(in srgb, var(--deep) 5%, transparent);
|
||||||
@@ -346,24 +303,6 @@
|
|||||||
background: color-mix(in srgb, var(--pirat) 5%, transparent);
|
background: color-mix(in srgb, var(--pirat) 5%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-chip.is-ghost {
|
|
||||||
border-color: var(--ghost);
|
|
||||||
color: var(--ghost);
|
|
||||||
background: color-mix(in srgb, var(--ghost) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.is-dead {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.is-captain {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Badges --- */
|
/* --- Badges --- */
|
||||||
.creator-badge {
|
.creator-badge {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -447,6 +386,10 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-box.character-sheet-modal {
|
||||||
|
max-width: 1260px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.6rem;
|
top: 0.6rem;
|
||||||
@@ -544,4 +487,3 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-status-box {
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-box {
|
.links-box {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
@@ -38,6 +34,11 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lobby-join-code-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-container {
|
.copy-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Corner button that reopens the collapsed inline Event Log. */
|
/* Corner button that toggles the inline Event Log without moving. */
|
||||||
.log-reopen-btn {
|
.log-reopen-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
@@ -88,6 +88,15 @@
|
|||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-static:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.crew-bubble.is-deep {
|
.crew-bubble.is-deep {
|
||||||
border-left-color: var(--deep);
|
border-left-color: var(--deep);
|
||||||
background: color-mix(in srgb, var(--deep) 12%, var(--surface-raised));
|
background: color-mix(in srgb, var(--deep) 12%, var(--surface-raised));
|
||||||
@@ -183,23 +192,13 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On narrow screens the crew column becomes a horizontal strip above the board */
|
/* Keep the crew roster vertical at every width, matching the other phases. */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.crew-bubbles {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.crew-bubble {
|
.crew-bubble {
|
||||||
width: auto;
|
width: 100%;
|
||||||
flex: 1 1 140px;
|
|
||||||
}
|
}
|
||||||
.crew-objectives-toggle {
|
.crew-objectives-toggle {
|
||||||
width: auto;
|
width: 100%;
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
.crew-objectives-detail {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setup-grid.single {
|
||||||
|
grid-template-columns: minmax(0, 700px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.setup-grid {
|
.setup-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -63,19 +68,3 @@
|
|||||||
border: 1px dashed var(--edge);
|
border: 1px dashed var(--edge);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.roster-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: var(--well);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -566,39 +566,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 5: Crew Creation Progress -->
|
|
||||||
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
|
||||||
<h3>V. Crew Creation Status</h3>
|
|
||||||
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
|
|
||||||
<div class="crew-status-list">
|
|
||||||
<ul class="space-y-2 mt-4">
|
|
||||||
{#each state.players as p}
|
|
||||||
<li class="status-row {p.tech_jack ? 'is-ready' : ''}">
|
|
||||||
<span>{displayName(p)}</span>
|
|
||||||
<span class="text-sm">
|
|
||||||
{#if p.needs_reroll}
|
|
||||||
<span class="text-muted">Spectating</span>
|
|
||||||
{:else if phase === 'swap_techniques'}
|
|
||||||
{#if p.is_ready}
|
|
||||||
<span class="text-success">Done Swapping</span>
|
|
||||||
{:else}
|
|
||||||
{@const ownLeft = JSON.parse(p.held_techniques || '[]').filter(t => t.creator_id === p.id).length}
|
|
||||||
<span class="text-warning">Swapping ({ownLeft} of their own left)</span>
|
|
||||||
{/if}
|
|
||||||
{:else if p.tech_jack}
|
|
||||||
<span class="text-success">Ready</span>
|
|
||||||
{:else if phase === 'assign_techniques'}
|
|
||||||
<span class="text-warning">Assigning Face Cards</span>
|
|
||||||
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
|
|
||||||
<span class="text-warning">Techniques Submitted</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted">Writing Sheet</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
97
frontend/src/components/CrewSidebar.svelte
Normal file
97
frontend/src/components/CrewSidebar.svelte
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script>
|
||||||
|
import { displayName, crewLabel } from '../lib/cards';
|
||||||
|
import CharacterSheet from './scene/CharacterSheet.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let openTargetId = null;
|
||||||
|
|
||||||
|
$: captainId = state.game.captain_player_id;
|
||||||
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
|
||||||
|
function iconFor(p) {
|
||||||
|
if (p.role === 'deep') return '🌊';
|
||||||
|
if (p.is_ghost) return '👻';
|
||||||
|
if (p.is_dead) return '💀';
|
||||||
|
return '🐀';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeArray(value) {
|
||||||
|
try { return JSON.parse(value || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFor(p) {
|
||||||
|
const phase = state.game.phase;
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (p.is_creator) return 'Creator';
|
||||||
|
if (p.is_admin) return 'Admin';
|
||||||
|
return 'Crew member';
|
||||||
|
}
|
||||||
|
if (phase === 'character_creation') {
|
||||||
|
if (p.needs_reroll) return 'Spectating';
|
||||||
|
return safeArray(p.created_techniques).length === 3 ? 'Techniques submitted' : 'Writing sheet';
|
||||||
|
}
|
||||||
|
if (phase === 'swap_techniques') {
|
||||||
|
if (p.is_ready) return 'Done swapping';
|
||||||
|
const ownLeft = safeArray(p.held_techniques).filter(t => t.creator_id === p.id).length;
|
||||||
|
return `Swapping · ${ownLeft} own left`;
|
||||||
|
}
|
||||||
|
if (phase === 'assign_techniques') return p.tech_jack ? 'Ready' : 'Assigning face cards';
|
||||||
|
if (phase === 'scene_setup') {
|
||||||
|
if (p.needs_reroll) return 'Spectating';
|
||||||
|
if (p.role === 'deep') return 'The Deep';
|
||||||
|
if (p.role === 'pirat') return `Pi-Rat · Rank ${p.rank}`;
|
||||||
|
return 'Choosing a role…';
|
||||||
|
}
|
||||||
|
if (phase === 'between_scenes') {
|
||||||
|
const voted = (state.votes || []).some(v => v.voter_player_id === p.id);
|
||||||
|
const canVote = state.players.some(candidate =>
|
||||||
|
candidate.id !== p.id && candidate.role !== 'deep' && !candidate.is_dead && !candidate.needs_reroll
|
||||||
|
);
|
||||||
|
return `Rank ${p.rank} · ${voted ? 'Nomination done' : canVote ? 'Nominating…' : 'No vote'}`;
|
||||||
|
}
|
||||||
|
if (phase === 'deep_upkeep') return `Rank ${p.rank} · ${p.is_ready ? 'Ready' : 'Finishing upkeep…'}`;
|
||||||
|
if (phase === 'recruit_creation') return p.needs_reroll ? 'Creating a new Pi-Rat' : `Rank ${p.rank} · Helping recruits`;
|
||||||
|
if (phase === 'ended') {
|
||||||
|
const story = p.completed_personal_3 ? 'Story complete' : 'Still sailing';
|
||||||
|
return `Rank ${p.rank} · ${story}`;
|
||||||
|
}
|
||||||
|
return `Rank ${p.rank}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="crew-column phase-crew-column" aria-label="Crew roster">
|
||||||
|
<div class="crew-bubbles">
|
||||||
|
{#each crew as p (p.id)}
|
||||||
|
{#if state.game.phase === 'lobby'}
|
||||||
|
<div
|
||||||
|
class="crew-bubble is-static"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
>
|
||||||
|
<span class="bubble-icon">{iconFor(p)}</span>
|
||||||
|
<span class="bubble-name">{displayName(p)}</span>
|
||||||
|
<span class="bubble-meta">{statusFor(p)}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="crew-bubble"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
class:is-deep={p.role === 'deep'}
|
||||||
|
class:is-dead={p.is_dead}
|
||||||
|
class:is-ghost={p.is_ghost}
|
||||||
|
title="View {p.name}'s character sheet"
|
||||||
|
on:click={() => openTargetId = p.id}
|
||||||
|
>
|
||||||
|
<span class="bubble-icon">{iconFor(p)}</span>
|
||||||
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
|
<span class="bubble-meta">{statusFor(p)}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
|
{/if}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
$: pirats = state.players;
|
|
||||||
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -29,24 +26,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sheet-group">
|
|
||||||
<h3 class="text-accent">The Crew</h3>
|
|
||||||
{#each pirats as p}
|
|
||||||
<div class="crew-epitaph-row">
|
|
||||||
<span>
|
|
||||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
|
|
||||||
<strong>{displayName(p)}</strong> (Rank {p.rank})
|
|
||||||
{#if p.id === state.game.captain_player_id}<span class="text-accent"> ⭐ Captain</span>{/if}
|
|
||||||
</span>
|
|
||||||
<span class="objectives-summary">
|
|
||||||
{p.completed_personal_1 ? '🔫 Gat' : '— no gat'} ·
|
|
||||||
{p.completed_personal_2 ? '📛 Named' : '— nameless'} ·
|
|
||||||
{p.completed_personal_3 ? '⚰️ Story complete' : '⛵ Still sailing'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
|
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,21 +43,23 @@
|
|||||||
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
||||||
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||||
|
|
||||||
<div class="lobby-status-box glass-panel">
|
|
||||||
<h3>Joined Crew members</h3>
|
|
||||||
<div class="player-chips" id="lobby-player-list">
|
|
||||||
{#each state.players as p}
|
|
||||||
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
|
|
||||||
<span class="avatar-icon">🐀</span>
|
|
||||||
<span class="name">{p.name}</span>
|
|
||||||
{#if p.is_creator}<span class="creator-badge">Creator</span>
|
|
||||||
{:else if p.is_admin}<span class="creator-badge">Admin</span>{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="links-box glass-panel">
|
<div class="links-box glass-panel">
|
||||||
|
{#if state.player.is_admin}
|
||||||
|
<div class="link-item lobby-join-code-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-join-code"
|
||||||
|
class:copied={copiedLink === 'code'}
|
||||||
|
aria-label="Copy join code {state.game.join_code}"
|
||||||
|
on:click={() => copyLink('code', state.game.join_code)}
|
||||||
|
>
|
||||||
|
<span class="admin-join-code-label">Join Code</span>
|
||||||
|
<strong>{state.game.join_code}</strong>
|
||||||
|
<span class="admin-join-code-hint">{copiedLink === 'code' ? '✓ Copied!' : 'Click to copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="link-item">
|
<div class="link-item">
|
||||||
<h4>Share Join Link:</h4>
|
<h4>Share Join Link:</h4>
|
||||||
<div class="copy-container">
|
<div class="copy-container">
|
||||||
|
|||||||
@@ -135,16 +135,6 @@
|
|||||||
techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_id === me.id)
|
techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_id === me.id)
|
||||||
})).filter(t => t.like || t.hate || t.techSlots.length > 0);
|
})).filter(t => t.like || t.hate || t.techSlots.length > 0);
|
||||||
|
|
||||||
function recruitProgress(p) {
|
|
||||||
const techs = incomingTechs(p);
|
|
||||||
const parts = [];
|
|
||||||
parts.push(p.avatar_look && p.avatar_smell && p.first_words && p.good_at_math ? '✅ Records' : '❌ Records');
|
|
||||||
const likeOk = !p.other_like_from_player_id || p.other_like;
|
|
||||||
const hateOk = !p.other_hate_from_player_id || p.other_hate;
|
|
||||||
parts.push(likeOk && hateOk ? '✅ Like/Hate' : '❌ Like/Hate');
|
|
||||||
parts.push(`${techs.filter(s => s.text).length}/3 Techniques`);
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="recruit-phase-view">
|
<div class="recruit-phase-view">
|
||||||
@@ -321,22 +311,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RECRUIT PROGRESS -->
|
|
||||||
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
|
||||||
<h3>{iAmRecruit ? 'V.' : 'II.'} Recruit Progress</h3>
|
|
||||||
<div class="crew-status-list">
|
|
||||||
<ul class="space-y-2 mt-4">
|
|
||||||
{#each recruits as p}
|
|
||||||
<li class="status-row">
|
|
||||||
<span>{displayName(p)}</span>
|
|
||||||
<span class="text-sm text-muted">{recruitProgress(p)}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
{#if recruits.length === 0}
|
|
||||||
<li class="status-row"><span class="text-success">All recruits are aboard! Heading to the next scene...</span></li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
// The inline Event Log can be collapsed to a corner button to declutter.
|
// The inline Event Log can be toggled from a fixed corner button to declutter.
|
||||||
let logOpen = true;
|
let logOpen = true;
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
@@ -70,8 +70,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !logOpen}
|
<button
|
||||||
<button class="log-reopen-btn" title="Show the event log" on:click={() => (logOpen = true)}>
|
class="log-reopen-btn"
|
||||||
📜 Event Log
|
title={logOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
</button>
|
on:click={() => (logOpen = !logOpen)}
|
||||||
{/if}
|
>
|
||||||
|
{logOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="setup-grid">
|
<div class="setup-grid single">
|
||||||
<!-- Role Selection Card -->
|
<!-- Role Selection Card -->
|
||||||
<div class="card glass-panel role-selection-card">
|
<div class="card glass-panel role-selection-card">
|
||||||
<h3>Select Your Role</h3>
|
<h3>Select Your Role</h3>
|
||||||
@@ -107,24 +106,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Roster Card -->
|
|
||||||
<div class="card glass-panel roster-card">
|
|
||||||
<h3>Live Roster Selection</h3>
|
|
||||||
<div class="roster-list" id="scene-roster-list">
|
|
||||||
{#each state.players as p}
|
|
||||||
<div class="roster-item">
|
|
||||||
<span class="player-name">{displayName(p)} <span class="text-sm text-muted">(Rank {p.rank})</span></span>
|
|
||||||
{#if p.role === 'pirat'}
|
|
||||||
<span class="role-badge pirat">Pi-Rat</span>
|
|
||||||
{:else if p.role === 'deep'}
|
|
||||||
<span class="role-badge deep">The Deep</span>
|
|
||||||
{:else}
|
|
||||||
<span class="role-badge unassigned">Choosing...</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-box margin-top">
|
<div class="action-box margin-top">
|
||||||
|
|||||||
@@ -234,34 +234,6 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Voting Roster Status -->
|
|
||||||
{#if state.game.phase !== 'deep_upkeep'}
|
|
||||||
<div class="vote-status-table margin-top">
|
|
||||||
<h4>Nomination Progress:</h4>
|
|
||||||
<div class="player-chips list-chips" id="voting-roster">
|
|
||||||
{#each state.players as p}
|
|
||||||
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
|
|
||||||
{@const skips = eligibleNomineesFor(p).length === 0}
|
|
||||||
<div class="player-chip {voted || skips ? 'voted-chip' : 'not-voted-chip'}">
|
|
||||||
<span class="name">{displayName(p)}</span>
|
|
||||||
<span class="status-badge">{voted ? 'Done' : skips ? 'No vote' : 'Voting...'}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
|
||||||
<h4>Crew Rank Ledger:</h4>
|
|
||||||
<ul class="ranks-ledger">
|
|
||||||
{#each state.players as p}
|
|
||||||
<li>
|
|
||||||
<strong>{displayName(p)}:</strong> Rank {p.rank}
|
|
||||||
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
|
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
<div class="modal-backdrop" on:click|self={close}>
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
<div class="modal-box sheet-modal glass-panel">
|
<div class="modal-box sheet-modal character-sheet-modal glass-panel">
|
||||||
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
|
||||||
<h3>
|
<h3>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export async function apiRequest(endpoint, method = 'GET', data = null) {
|
|||||||
if (errBody.error) msg = errBody.error;
|
if (errBody.error) msg = errBody.error;
|
||||||
else if (errBody.detail) msg = errBody.detail;
|
else if (errBody.detail) msg = errBody.detail;
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
throw new Error(msg);
|
const error = new Error(msg);
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,94 @@
|
|||||||
// - Add a CHANGELOG entry only when a commit changes something players can
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
||||||
|
|
||||||
export const VERSION = 12;
|
export const VERSION = 25;
|
||||||
|
|
||||||
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
export const CHANGELOG = [
|
export const CHANGELOG = [
|
||||||
|
{
|
||||||
|
version: 25,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Event Log now uses the same pinned column and fixed close/reopen control in every game phase.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 24,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The crew roster now stays in the same left-hand column throughout every phase of the game.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 23,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Admins can now click the join code to copy it, and the code is available directly from the lobby.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 21,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Player lists now use the same easy-to-scan vertical layout in every phase and at every screen size.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 20,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now gives the game’s join code a large, easy-to-share display.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 19,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Games now have short join codes that can be entered on the home page and shared from the Admin Panel.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 18,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Expired rejoin entries are now removed automatically when a game has ended or a player is no longer in the crew, with a clear explanation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 17,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Join forms now remember your player name for the next crew while still letting you edit it.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 16,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Open and Copy controls in the Admin Panel now have matching sizes.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 15,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now clearly marks Pi-Rats whose character identity has not been chosen yet.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 14,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The corner Event Log button now stays put when the log is open, so it can be closed again without moving your mouse.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 13,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Character sheets now make better use of the available space on wide screens.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: 8,
|
version: 8,
|
||||||
date: '2026-06-15',
|
date: '2026-06-15',
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
let copiedTimer = null;
|
let copiedTimer = null;
|
||||||
let copiedInvite = false;
|
let copiedInvite = false;
|
||||||
let copiedInviteTimer = null;
|
let copiedInviteTimer = null;
|
||||||
|
let copiedJoinCode = false;
|
||||||
|
let copiedJoinCodeTimer = null;
|
||||||
|
|
||||||
$: setPageTitle('Admin', data?.game?.crew_name);
|
$: setPageTitle('Admin', data?.game?.crew_name);
|
||||||
|
|
||||||
@@ -48,6 +50,13 @@
|
|||||||
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
|
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyJoinCode() {
|
||||||
|
navigator.clipboard.writeText(data.game.join_code);
|
||||||
|
copiedJoinCode = true;
|
||||||
|
clearTimeout(copiedJoinCodeTimer);
|
||||||
|
copiedJoinCodeTimer = setTimeout(() => { copiedJoinCode = false; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Try to load admin_key from local storage
|
// Try to load admin_key from local storage
|
||||||
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
||||||
@@ -117,9 +126,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div class="admin-summary">
|
||||||
|
<div class="admin-summary-details">
|
||||||
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
|
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
|
||||||
<p><strong>Phase:</strong> {data.game.phase}</p>
|
<p><strong>Phase:</strong> {data.game.phase}</p>
|
||||||
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
|
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-join-code"
|
||||||
|
class:copied={copiedJoinCode}
|
||||||
|
aria-label="Copy join code {data.game.join_code}"
|
||||||
|
on:click={copyJoinCode}
|
||||||
|
>
|
||||||
|
<span class="admin-join-code-label">Join Code</span>
|
||||||
|
<strong>{data.game.join_code}</strong>
|
||||||
|
<span class="admin-join-code-hint">{copiedJoinCode ? '✓ Copied!' : 'Click to copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-danger mt-4">{error}</div>
|
<div class="alert alert-danger mt-4">{error}</div>
|
||||||
@@ -152,7 +176,13 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each data.players as p}
|
{#each data.players as p}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{p.name}</td>
|
<td>
|
||||||
|
{#if p.name && p.name !== p.player_name}
|
||||||
|
{p.name}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not chosen yet</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td>{p.player_name || p.name}</td>
|
<td>{p.player_name || p.name}</td>
|
||||||
<td>
|
<td>
|
||||||
{#if p.role === 'deep'} 🌊 Deep
|
{#if p.role === 'deep'} 🌊 Deep
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||||
import RecruitPhase from '../components/RecruitPhase.svelte';
|
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||||
import GameOverPhase from '../components/GameOverPhase.svelte';
|
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||||
|
import CrewSidebar from '../components/CrewSidebar.svelte';
|
||||||
import EventLog from '../components/EventLog.svelte';
|
import EventLog from '../components/EventLog.svelte';
|
||||||
import NameModal from '../components/NameModal.svelte';
|
import NameModal from '../components/NameModal.svelte';
|
||||||
import GatModal from '../components/GatModal.svelte';
|
import GatModal from '../components/GatModal.svelte';
|
||||||
@@ -31,16 +32,40 @@
|
|||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let reconnectDelay = 1000;
|
let reconnectDelay = 1000;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
let staleSession = false;
|
||||||
|
// Non-scene phases use the same pinned Event Log column and fixed corner
|
||||||
|
// toggle that ScenePhase owns for the main play screen.
|
||||||
|
let phaseLogOpen = true;
|
||||||
|
|
||||||
|
function discardStaleSession(message) {
|
||||||
|
staleSession = true;
|
||||||
|
removeSession(gameId, playerId);
|
||||||
|
state = null;
|
||||||
|
error = message;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (ws) ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchState() {
|
async function fetchState() {
|
||||||
|
if (staleSession) return;
|
||||||
try {
|
try {
|
||||||
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
||||||
|
// Preserve the Game Over screen for players who were already present
|
||||||
|
// when play ended, but discard stale home-screen rejoin attempts.
|
||||||
|
if (!state && data.game.phase === 'ended') {
|
||||||
|
discardStaleSession('This game has ended and can no longer be rejoined.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = data;
|
state = data;
|
||||||
error = '';
|
error = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!state && err.status === 404) {
|
||||||
|
discardStaleSession('This saved game can no longer be rejoined. You may have been removed from the crew.');
|
||||||
|
} else {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The server pushes a ping over this socket whenever the game changes;
|
// The server pushes a ping over this socket whenever the game changes;
|
||||||
// each ping triggers a refetch of the state blob.
|
// each ping triggers a refetch of the state blob.
|
||||||
@@ -53,7 +78,7 @@
|
|||||||
};
|
};
|
||||||
ws.onmessage = () => fetchState();
|
ws.onmessage = () => fetchState();
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (destroyed) return;
|
if (destroyed || staleSession) return;
|
||||||
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
|
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
||||||
};
|
};
|
||||||
@@ -188,19 +213,28 @@
|
|||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-danger" style="margin: 20px;">
|
<div class="alert alert-danger" style="margin: 20px;">
|
||||||
Error connecting to game: {error}
|
Error connecting to game: {error}
|
||||||
|
{#if staleSession}
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="#/" class="btn btn-secondary btn-small">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state}
|
{#if state}
|
||||||
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||||
|
{#if state.game.phase === 'scene'}
|
||||||
|
<ScenePhase {state} />
|
||||||
|
{:else}
|
||||||
|
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
|
||||||
|
<CrewSidebar {state} />
|
||||||
|
<main class="phase-main-column">
|
||||||
{#if state.game.phase === 'lobby'}
|
{#if state.game.phase === 'lobby'}
|
||||||
<LobbyPhase {state} />
|
<LobbyPhase {state} />
|
||||||
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
|
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
|
||||||
<CharacterCreationPhase {state} />
|
<CharacterCreationPhase {state} />
|
||||||
{:else if state.game.phase === 'scene_setup'}
|
{:else if state.game.phase === 'scene_setup'}
|
||||||
<SceneSetupPhase {state} />
|
<SceneSetupPhase {state} />
|
||||||
{:else if state.game.phase === 'scene'}
|
|
||||||
<ScenePhase {state} />
|
|
||||||
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
||||||
<UpkeepPhase {state} />
|
<UpkeepPhase {state} />
|
||||||
{:else if state.game.phase === 'recruit_creation'}
|
{:else if state.game.phase === 'recruit_creation'}
|
||||||
@@ -210,11 +244,24 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</main>
|
||||||
|
{#if phaseLogOpen}
|
||||||
|
<div class="log-column">
|
||||||
|
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The scene phase renders its own inline Event Log as the third column. -->
|
|
||||||
{#if state.game.phase !== 'scene'}
|
{#if state.game.phase !== 'scene'}
|
||||||
<EventLog {state} />
|
<button
|
||||||
|
class="log-reopen-btn"
|
||||||
|
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
|
on:click={() => (phaseLogOpen = !phaseLogOpen)}
|
||||||
|
>
|
||||||
|
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<NameModal {state} />
|
<NameModal {state} />
|
||||||
<GatModal {state} />
|
<GatModal {state} />
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
let crewName = '';
|
let crewName = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let creating = false;
|
let creating = false;
|
||||||
|
let joinCode = '';
|
||||||
|
let joinCodeError = '';
|
||||||
|
let joiningByCode = false;
|
||||||
|
|
||||||
function sessionRat(s) {
|
function sessionRat(s) {
|
||||||
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
|
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
|
||||||
@@ -53,6 +56,26 @@
|
|||||||
creating = false;
|
creating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateJoinCode(event) {
|
||||||
|
joinCode = event.currentTarget.value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, '')
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinByCode() {
|
||||||
|
if (joinCode.length !== 5) return;
|
||||||
|
joiningByCode = true;
|
||||||
|
joinCodeError = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/game/code/${joinCode}`);
|
||||||
|
push(`/game/${data.id}/join`);
|
||||||
|
} catch (err) {
|
||||||
|
joinCodeError = err.message;
|
||||||
|
joiningByCode = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
@@ -91,6 +114,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="glass-panel creation-card join-code-card">
|
||||||
|
<h2>Join a Crew</h2>
|
||||||
|
<p class="info-text">Enter the five-character code shared by your game’s Admin.</p>
|
||||||
|
<form class="join-code-form" on:submit|preventDefault={joinByCode}>
|
||||||
|
<label for="joinCode">Join Code</label>
|
||||||
|
<div class="join-code-action">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="joinCode"
|
||||||
|
value={joinCode}
|
||||||
|
on:input={updateJoinCode}
|
||||||
|
required
|
||||||
|
minlength="5"
|
||||||
|
maxlength="5"
|
||||||
|
placeholder="ABCDE"
|
||||||
|
class="input-field input-large join-code-input"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="characters"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary btn-large" disabled={joiningByCode || joinCode.length !== 5}>
|
||||||
|
{joiningByCode ? 'Joining...' : 'Join'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if joinCodeError}
|
||||||
|
<div class="alert alert-danger mt-4">{joinCodeError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="glass-panel creation-card">
|
<div class="glass-panel creation-card">
|
||||||
<h2>Muster a New Crew</h2>
|
<h2>Muster a New Crew</h2>
|
||||||
<form on:submit|preventDefault={createGame}>
|
<form on:submit|preventDefault={createGame}>
|
||||||
@@ -128,6 +181,25 @@
|
|||||||
.saved-sessions {
|
.saved-sessions {
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 1.75rem;
|
||||||
}
|
}
|
||||||
|
.join-code-card {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
.join-code-form {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.join-code-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.join-code-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
.session-list {
|
.session-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -6,12 +6,18 @@
|
|||||||
import { saveSession } from '../lib/sessions';
|
import { saveSession } from '../lib/sessions';
|
||||||
export let params = {};
|
export let params = {};
|
||||||
|
|
||||||
onMount(() => setPageTitle('Join the Crew'));
|
const PLAYER_NAME_KEY = 'pirats-player-name';
|
||||||
|
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
let playerName = '';
|
let playerName = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let joining = false;
|
let joining = false;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setPageTitle('Join the Crew');
|
||||||
|
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
|
||||||
|
});
|
||||||
|
|
||||||
// We can extract ?creator=true from $querystring if needed,
|
// We can extract ?creator=true from $querystring if needed,
|
||||||
// though the backend determines creator based on if they are the first player.
|
// though the backend determines creator based on if they are the first player.
|
||||||
// For UI purposes, we could show "You are the creator" if we want.
|
// For UI purposes, we could show "You are the creator" if we want.
|
||||||
@@ -22,6 +28,7 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
||||||
|
localStorage.setItem(PLAYER_NAME_KEY, playerName.trim());
|
||||||
// Remember this session so the home page can offer a rejoin link;
|
// Remember this session so the home page can offer a rejoin link;
|
||||||
// the dashboard enriches it with crew/rat names once state loads.
|
// the dashboard enriches it with crew/rat names once state loads.
|
||||||
saveSession({ gameId, playerId: data.id, playerName });
|
saveSession({ gameId, playerId: data.id, playerName });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os
|
|||||||
import random
|
import random
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import Game, Player, GameEvent
|
from .models import Game, Player, GameEvent, new_join_code
|
||||||
from . import cards
|
from . import cards
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -253,7 +253,16 @@ def has_min_players(game: Game) -> bool:
|
|||||||
|
|
||||||
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||||
deck = cards.get_fresh_deck()
|
deck = cards.get_fresh_deck()
|
||||||
|
join_code = ""
|
||||||
|
for _ in range(100):
|
||||||
|
candidate = new_join_code()
|
||||||
|
if not db.exec(select(Game).where(Game.join_code == candidate)).first():
|
||||||
|
join_code = candidate
|
||||||
|
break
|
||||||
|
if not join_code:
|
||||||
|
raise RuntimeError("Could not allocate a unique join code")
|
||||||
game = Game(
|
game = Game(
|
||||||
|
join_code=join_code,
|
||||||
deck_cards=json.dumps(deck),
|
deck_cards=json.dumps(deck),
|
||||||
phase="lobby",
|
phase="lobby",
|
||||||
current_scene_number=1,
|
current_scene_number=1,
|
||||||
@@ -270,6 +279,9 @@ def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
|||||||
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
||||||
return db.get(Game, game_id)
|
return db.get(Game, game_id)
|
||||||
|
|
||||||
|
def get_game_by_join_code(db: Session, join_code: str) -> Optional[Game]:
|
||||||
|
return db.exec(select(Game).where(Game.join_code == join_code)).first()
|
||||||
|
|
||||||
def get_player(db: Session, player_id: str) -> Optional[Player]:
|
def get_player(db: Session, player_id: str) -> Optional[Player]:
|
||||||
return db.get(Player, player_id)
|
return db.get(Player, player_id)
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ def create_game_route(
|
|||||||
# admin_key is only revealed here, to the creator; the state endpoint hides it
|
# admin_key is only revealed here, to the creator; the state endpoint hides it
|
||||||
return {"id": game.id, "admin_key": game.admin_key}
|
return {"id": game.id, "admin_key": game.admin_key}
|
||||||
|
|
||||||
|
@api.get("/game/code/{join_code}")
|
||||||
|
def find_game_by_join_code(join_code: str, db: Session = Depends(get_session)):
|
||||||
|
normalized = join_code.strip().upper()
|
||||||
|
game = crud.get_game_by_join_code(db, normalized)
|
||||||
|
if not game or game.phase == "ended":
|
||||||
|
raise HTTPException(status_code=404, detail="No active game found for that join code")
|
||||||
|
return {"id": game.id}
|
||||||
|
|
||||||
@api.post("/game/{game_id}/join")
|
@api.post("/game/{game_id}/join")
|
||||||
def join_game_submit(
|
def join_game_submit(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""add game join codes
|
||||||
|
|
||||||
|
Revision ID: 6ea41638edfc
|
||||||
|
Revises: 153132c8e583
|
||||||
|
Create Date: 2026-07-10 12:28:45.476200
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
revision = '6ea41638edfc'
|
||||||
|
down_revision = '153132c8e583'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _new_join_code(used: set[str]) -> str:
|
||||||
|
while True:
|
||||||
|
code = "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5))
|
||||||
|
if code not in used:
|
||||||
|
used.add(code)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# SQLite cannot add a required column to a populated table. Add it as
|
||||||
|
# nullable, backfill every existing game with a distinct code, then tighten
|
||||||
|
# the constraint and create the unique lookup index.
|
||||||
|
op.add_column('game', sa.Column('join_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
connection = op.get_bind()
|
||||||
|
game_ids = connection.execute(sa.text("SELECT id FROM game")).scalars().all()
|
||||||
|
used: set[str] = set()
|
||||||
|
for game_id in game_ids:
|
||||||
|
connection.execute(
|
||||||
|
sa.text("UPDATE game SET join_code = :join_code WHERE id = :game_id"),
|
||||||
|
{"join_code": _new_join_code(used), "game_id": game_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'join_code',
|
||||||
|
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
batch_op.create_index(batch_op.f('ix_game_join_code'), ['join_code'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_game_join_code'))
|
||||||
|
batch_op.drop_column('join_code')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import secrets
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlmodel import Field, SQLModel, Relationship
|
from sqlmodel import Field, SQLModel, Relationship
|
||||||
|
|
||||||
|
JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def new_join_code() -> str:
|
||||||
|
return "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5))
|
||||||
|
|
||||||
class Game(SQLModel, table=True):
|
class Game(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
join_code: str = Field(default_factory=new_join_code, index=True, unique=True)
|
||||||
crew_name: Optional[str] = Field(default=None)
|
crew_name: Optional[str] = Field(default=None)
|
||||||
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "assign_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
|
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "assign_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
|
||||||
dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table
|
dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
"game": {
|
"game": {
|
||||||
"id": game.id,
|
"id": game.id,
|
||||||
"crew_name": game.crew_name,
|
"crew_name": game.crew_name,
|
||||||
|
"join_code": game.join_code,
|
||||||
"phase": game.phase,
|
"phase": game.phase,
|
||||||
"admin_key": game.admin_key
|
"admin_key": game.admin_key
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -443,6 +443,11 @@ def test_game_creation_with_crew_name(session):
|
|||||||
assert game.id is not None
|
assert game.id is not None
|
||||||
assert game.crew_name == "The Black Gats"
|
assert game.crew_name == "The Black Gats"
|
||||||
assert game.phase == "lobby"
|
assert game.phase == "lobby"
|
||||||
|
assert len(game.join_code) == 5
|
||||||
|
assert set(game.join_code) <= set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
|
||||||
|
|
||||||
|
another = crud.create_game(session, crew_name="The Other Gats")
|
||||||
|
assert another.join_code != game.join_code
|
||||||
|
|
||||||
def test_create_game_endpoint():
|
def test_create_game_endpoint():
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -475,6 +480,17 @@ def test_create_game_endpoint():
|
|||||||
games = session.exec(select(Game)).all()
|
games = session.exec(select(Game)).all()
|
||||||
assert len(games) == 1
|
assert len(games) == 1
|
||||||
assert games[0].crew_name == "The Math Mutineers"
|
assert games[0].crew_name == "The Math Mutineers"
|
||||||
|
|
||||||
|
# Codes are case-insensitive at the API boundary and resolve only
|
||||||
|
# active games without exposing the UUID in the code itself.
|
||||||
|
lookup = client.get(f"/api/game/code/{games[0].join_code.lower()}")
|
||||||
|
assert lookup.status_code == 200
|
||||||
|
assert lookup.json() == {"id": games[0].id}
|
||||||
|
|
||||||
|
games[0].phase = "ended"
|
||||||
|
session.add(games[0])
|
||||||
|
session.commit()
|
||||||
|
assert client.get(f"/api/game/code/{games[0].join_code}").status_code == 404
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user