Compare commits
4
Commits
3e67aba75c
...
162d8c9224
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162d8c9224 | ||
|
|
1c672f0ab8 | ||
|
|
78f0de5333 | ||
|
|
a8c9740a05 |
@@ -5,3 +5,5 @@ AW_REGISTRATION_CODE=
|
|||||||
AW_TRUST_PROXY=0
|
AW_TRUST_PROXY=0
|
||||||
# Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090):
|
# Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090):
|
||||||
AW_PORT=8090
|
AW_PORT=8090
|
||||||
|
# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created:
|
||||||
|
AW_ADMINS=
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ docker compose up -d --build
|
|||||||
curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK
|
curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK
|
||||||
```
|
```
|
||||||
|
|
||||||
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`.
|
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`; changes from the rules editor are in `./db/rules_overrides.json` (back up the whole `db` folder). Set `AW_ADMINS` in `.env` to choose who may use the editor (default: the first account).
|
||||||
|
|
||||||
## 4. nginx + HTTPS
|
## 4. nginx + HTTPS
|
||||||
|
|
||||||
@@ -70,10 +70,17 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
|
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
# the lobby uses a WebSocket for live updates (without these three lines it falls back to polling)
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Already running an older config? Add the last four lines inside `location /`, then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
|
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
@@ -118,3 +125,13 @@ docker compose down # stop
|
|||||||
```
|
```
|
||||||
|
|
||||||
If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`.
|
If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/AWCharacterCreator
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The `db` folder (database and rules editor changes) is kept. New settings from `.env.example` have to be added to your `.env` by hand.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
|
|||||||
|
|
||||||
### Skills, proficiency and expertise
|
### Skills, proficiency and expertise
|
||||||
|
|
||||||
|
- `creation.advanced_attr_percent`: share of the attribute that counts for **advanced skills** (default 50, rounded down; 100 = full attribute like basic skills).
|
||||||
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if the character's profession grants proficiency in it; proficiency picks do not unlock a skill).
|
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if the character's profession grants proficiency in it; proficiency picks do not unlock a skill).
|
||||||
- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training").
|
- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training").
|
||||||
- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`.
|
- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`.
|
||||||
@@ -85,13 +86,22 @@ Each wizard page has a **Reset this page** button that clears only that page's d
|
|||||||
|
|
||||||
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
|
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
|
||||||
|
|
||||||
|
### Rules editor
|
||||||
|
|
||||||
|
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin.
|
||||||
|
|
||||||
|
- Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list.
|
||||||
|
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
|
||||||
|
- The server checks every reference: an item needs an existing category, a skill a group and attributes, a profession existing skills. Removing a category, group or skill that is still used is refused. Removing an item, skill or profession that saved characters use asks for confirmation first (their data stays, the entry disappears from the sheets).
|
||||||
|
- Talents, mutations, implants, classes and the rules text are still edited in `rules.json`. Players see changes after reloading the page.
|
||||||
|
|
||||||
### Lobbies
|
### Lobbies
|
||||||
|
|
||||||
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
|
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
|
||||||
|
|
||||||
The lobby page is built for desktop with three columns: the **character sheet** on the left (collapsible with the arrow), the **Player Area** in the middle and the **chat** at the right edge of the window.
|
The lobby page is built for desktop with three columns: the **character sheet** on the left (collapsible with the arrow), the **Player Area** in the middle and the **chat** at the right edge of the window.
|
||||||
|
|
||||||
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. The chat polls every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
|
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. Everything in the lobby (chat, dice, difficulty, colours, sheets) updates live over a **WebSocket** (`/api/lobbies/{id}/ws`); it only sends a short "something changed" note and the data is fetched through the normal API, so the visibility rules (whispers, hidden numbers) stay in one place. If the socket is blocked or drops, the page reconnects and meanwhile polls every few seconds. The chat keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
|
||||||
- **Character bar** (top of the Player Area): one card per player with the character, class and the player's colour. The DM clicks a card to show that character's sheet on the left (always the current saved version, refreshed every 15 s) and can enter a personal **bonus / malus** per character in the small number field. Players see their own sheet only and just a short card of the others. (The bar is also meant for initiative and enemies later.)
|
- **Character bar** (top of the Player Area): one card per player with the character, class and the player's colour. The DM clicks a card to show that character's sheet on the left (always the current saved version, refreshed every 15 s) and can enter a personal **bonus / malus** per character in the small number field. Players see their own sheet only and just a short card of the others. (The bar is also meant for initiative and enemies later.)
|
||||||
- **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers.
|
- **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers.
|
||||||
- **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin.
|
- **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin.
|
||||||
@@ -119,6 +129,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
|||||||
| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
|
| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
|
||||||
| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
|
| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
|
||||||
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
|
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
|
||||||
|
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet}` for lobby members (cookie login, same origin) |
|
||||||
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
||||||
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
|
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
|
||||||
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
|
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ def compute(ch: dict, rules: dict) -> dict:
|
|||||||
unlocked = s["id"] in (ch.get("unlocked") or [])
|
unlocked = s["id"] in (ch.get("unlocked") or [])
|
||||||
usable = s["type"] == "basic" or unlocked or prof_pip > 0 # advanced skills must be unlocked (a profession proficiency does it for free)
|
usable = s["type"] == "basic" or unlocked or prof_pip > 0 # advanced skills must be unlocked (a profession proficiency does it for free)
|
||||||
best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts
|
best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts
|
||||||
|
base = best * cr.get("advanced_attr_percent", 100) // 100 if s["type"] == "advanced" else best # advanced skills only use part of the attribute
|
||||||
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
|
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
|
||||||
"total": best + pts + prof})
|
"base": base, "total": base + pts + prof})
|
||||||
|
|
||||||
aug_by_id = {a["id"]: a for a in rules["augments"]}
|
aug_by_id = {a["id"]: a for a in rules["augments"]}
|
||||||
chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id]
|
chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id]
|
||||||
|
|||||||
+190
-10
@@ -7,12 +7,15 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
|
import asyncio
|
||||||
|
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import auth
|
from . import auth
|
||||||
from .derive import compute
|
from .derive import compute
|
||||||
|
from . import rules_edit
|
||||||
|
from .realtime import hub
|
||||||
from .export import to_docx, to_pdf
|
from .export import to_docx, to_pdf
|
||||||
|
|
||||||
BASE = Path(__file__).resolve().parent.parent
|
BASE = Path(__file__).resolve().parent.parent
|
||||||
@@ -37,15 +40,39 @@ CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
|
|||||||
# No public API docs: everything about the API stays behind the login
|
# No public API docs: everything about the API stays behind the login
|
||||||
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
limiter = auth.Limiter()
|
limiter = auth.Limiter()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _attach_hub():
|
||||||
|
hub.attach(asyncio.get_running_loop())
|
||||||
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
|
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def load_rules() -> dict:
|
# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
|
||||||
# Re-read on every request so rules.json can be edited without a restart.
|
OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
|
||||||
|
|
||||||
|
|
||||||
|
def base_rules() -> dict:
|
||||||
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
|
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_overrides() -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_rules() -> dict:
|
||||||
|
# Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections.
|
||||||
|
r = base_rules()
|
||||||
|
for sec, items in read_overrides().items():
|
||||||
|
if sec in rules_edit.SECTIONS:
|
||||||
|
r[sec] = items
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
def db() -> sqlite3.Connection:
|
def db() -> sqlite3.Connection:
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
con = sqlite3.connect(DB_PATH)
|
con = sqlite3.connect(DB_PATH)
|
||||||
@@ -147,13 +174,25 @@ async def security(request: Request, call_next):
|
|||||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
resp.headers["X-Frame-Options"] = "DENY"
|
resp.headers["X-Frame-Options"] = "DENY"
|
||||||
resp.headers["Referrer-Policy"] = "same-origin"
|
resp.headers["Referrer-Policy"] = "same-origin"
|
||||||
|
host = request.headers.get("x-forwarded-host", "") if TRUST_PROXY else ""
|
||||||
|
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", host or request.headers.get("host", ""))
|
||||||
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
|
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||||
"img-src 'self' data:; frame-ancestors 'none'")
|
f"img-src 'self' data: blob:; connect-src 'self' ws://{host} wss://{host}; frame-ancestors 'none'")
|
||||||
if request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
resp.headers["Cache-Control"] = "no-store"
|
resp.headers["Cache-Control"] = "no-store"
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user: dict) -> bool:
|
||||||
|
"""Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account."""
|
||||||
|
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
||||||
|
if names:
|
||||||
|
return user["username"].lower() in names
|
||||||
|
with db() as con:
|
||||||
|
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||||||
|
return bool(first) and first["id"] == user["id"]
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request) -> dict:
|
def current_user(request: Request) -> dict:
|
||||||
with db() as con:
|
with db() as con:
|
||||||
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
||||||
@@ -187,6 +226,8 @@ def check_password_rules(pw: str):
|
|||||||
def me(request: Request):
|
def me(request: Request):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
||||||
|
if user:
|
||||||
|
user = {**user, "admin": is_admin(user)}
|
||||||
return {"user": user, "registration_open": bool(auth.registration_code())}
|
return {"user": user, "registration_open": bool(auth.registration_code())}
|
||||||
|
|
||||||
|
|
||||||
@@ -213,7 +254,8 @@ def register(request: Request, response: Response, body: dict = Body(...)):
|
|||||||
(username, auth.hash_password(password), time.time()))
|
(username, auth.hash_password(password), time.time()))
|
||||||
token = auth.create_session(con, cur.lastrowid)
|
token = auth.create_session(con, cur.lastrowid)
|
||||||
set_cookie(request, response, token)
|
set_cookie(request, response, token)
|
||||||
return {"user": {"id": cur.lastrowid, "username": username}}
|
u = {"id": cur.lastrowid, "username": username}
|
||||||
|
return {"user": {**u, "admin": is_admin(u)}}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
@@ -235,7 +277,8 @@ def login(request: Request, response: Response, body: dict = Body(...)):
|
|||||||
token = auth.create_session(con, row["id"])
|
token = auth.create_session(con, row["id"])
|
||||||
limiter.reset(keys[1])
|
limiter.reset(keys[1])
|
||||||
set_cookie(request, response, token)
|
set_cookie(request, response, token)
|
||||||
return {"user": {"id": row["id"], "username": row["username"]}}
|
u = {"id": row["id"], "username": row["username"]}
|
||||||
|
return {"user": {**u, "admin": is_admin(u)}}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout")
|
||||||
@@ -394,6 +437,9 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
|
|||||||
with db() as con:
|
with db() as con:
|
||||||
_owned(con, "characters", cid, user)
|
_owned(con, "characters", cid, user)
|
||||||
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
||||||
|
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
||||||
|
for lid in lobbies:
|
||||||
|
hub.notify(lid, "sheet", cid=cid)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -467,6 +513,7 @@ def _event(con, lid: str, text: str, uid=None):
|
|||||||
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
|
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
|
||||||
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
|
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
|
||||||
(lid, uid, text, time.time()))
|
(lid, uid, text, time.time()))
|
||||||
|
hub.notify(lid, "messages")
|
||||||
|
|
||||||
|
|
||||||
def _char_name(con, cid) -> str:
|
def _char_name(con, cid) -> str:
|
||||||
@@ -578,6 +625,7 @@ def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(curren
|
|||||||
with db() as con:
|
with db() as con:
|
||||||
_dm_only(_lobby_for(con, lid, user), user)
|
_dm_only(_lobby_for(con, lid, user), user)
|
||||||
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
|
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -596,19 +644,20 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
|
|||||||
(mod, lid, username))
|
(mod, lid, username))
|
||||||
if not cur.rowcount:
|
if not cur.rowcount:
|
||||||
raise HTTPException(404, "No such player in this lobby")
|
raise HTTPException(404, "No such player in this lobby")
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _recipient(con, lobby, user: dict, to):
|
def _recipient(con, lobby, user: dict, to):
|
||||||
"""User id to whisper to (the DM or a member, not yourself), None for everybody."""
|
"""User id to whisper to (the DM, a member or yourself for private rolls and notes), None for everybody."""
|
||||||
to = str(to or "").strip()
|
to = str(to or "").strip()
|
||||||
if not to:
|
if not to:
|
||||||
return None
|
return None
|
||||||
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
|
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
|
||||||
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
|
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
|
||||||
(to, lobby["owner_id"], lobby["id"])).fetchone()
|
(to, lobby["owner_id"], lobby["id"])).fetchone()
|
||||||
if not target or target["id"] == user["id"]:
|
if not target:
|
||||||
raise HTTPException(422, "You can only whisper to other people in this lobby")
|
raise HTTPException(422, "You can only whisper to people in this lobby")
|
||||||
return target["id"]
|
return target["id"]
|
||||||
|
|
||||||
|
|
||||||
@@ -667,6 +716,7 @@ def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_
|
|||||||
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
|
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
|
||||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||||
|
hub.notify(lid, "messages", None if to_id is None else {user["id"], to_id})
|
||||||
return {"id": cur.lastrowid}
|
return {"id": cur.lastrowid}
|
||||||
|
|
||||||
|
|
||||||
@@ -692,6 +742,7 @@ def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
|
|||||||
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
|
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
|
||||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||||
|
hub.notify(lid, "messages", None if to_id is None else {uid, to_id})
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
@@ -762,7 +813,8 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
|
|||||||
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
|
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
|
||||||
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
|
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
|
||||||
"manual": bool(manual)}
|
"manual": bool(manual)}
|
||||||
mid = _post(con, lid, user["id"], None, "check", payload)
|
private = str(body.get("to") or "") == user["username"] # a private check is only seen by the roller
|
||||||
|
mid = _post(con, lid, user["id"], user["id"] if private else None, "check", payload)
|
||||||
return {"id": mid, "roll": roll, "outcome": outcome}
|
return {"id": mid, "roll": roll, "outcome": outcome}
|
||||||
|
|
||||||
|
|
||||||
@@ -785,6 +837,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||||
|
hub.close_lobby(lid)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -815,6 +868,7 @@ def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(
|
|||||||
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
|
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
|
||||||
raise HTTPException(409, "Another player already uses this colour")
|
raise HTTPException(409, "Another player already uses this colour")
|
||||||
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
|
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
if "character_id" in body:
|
if "character_id" in body:
|
||||||
_own_character(con, body.get("character_id"), user)
|
_own_character(con, body.get("character_id"), user)
|
||||||
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
|
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
|
||||||
@@ -829,6 +883,8 @@ def leave_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
|
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
|
||||||
if gone:
|
if gone:
|
||||||
_event(con, lid, f"{user['username']} left the lobby", user["id"])
|
_event(con, lid, f"{user['username']} left the lobby", user["id"])
|
||||||
|
if gone:
|
||||||
|
hub.close_user(lid, user["id"])
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -840,6 +896,9 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
|
|||||||
(lid, username)).rowcount
|
(lid, username)).rowcount
|
||||||
if gone:
|
if gone:
|
||||||
_event(con, lid, f"{username} was removed by the DM", user["id"])
|
_event(con, lid, f"{username} was removed by the DM", user["id"])
|
||||||
|
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
|
||||||
|
if gone:
|
||||||
|
hub.close_user(lid, gone_id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -883,6 +942,127 @@ def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
|||||||
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
|
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- rules editor (admins only)
|
||||||
|
|
||||||
|
def admin_user(user: dict = Depends(current_user)) -> dict:
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Only admins can edit the rules")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/admin/rules")
|
||||||
|
def editor_state(user: dict = Depends(admin_user)):
|
||||||
|
"""The editable sections as they are now, and which of them differ from the shipped rules.json."""
|
||||||
|
r = load_rules()
|
||||||
|
return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]}
|
||||||
|
|
||||||
|
|
||||||
|
def _all_characters(con) -> list:
|
||||||
|
return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")]
|
||||||
|
|
||||||
|
|
||||||
|
def _save_overrides(data: dict):
|
||||||
|
OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if OVERRIDES_PATH.exists():
|
||||||
|
OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes())
|
||||||
|
tmp = OVERRIDES_PATH.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
os.replace(tmp, OVERRIDES_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(section: str, new: list, confirm: bool):
|
||||||
|
r = load_rules()
|
||||||
|
old_ids = {x["id"] for x in r[section]}
|
||||||
|
removed = old_ids - {x["id"] for x in new}
|
||||||
|
bad = rules_edit.dangling(section, new, r)
|
||||||
|
if bad:
|
||||||
|
raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else ""))
|
||||||
|
with db() as con:
|
||||||
|
used = rules_edit.usage(_all_characters(con), section, removed)
|
||||||
|
if used and not confirm:
|
||||||
|
raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. "
|
||||||
|
"Their data stays in the characters but the removed entries disappear from the sheets.")
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/admin/rules/{section}")
|
||||||
|
def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)):
|
||||||
|
try:
|
||||||
|
new = rules_edit.clean(section, body.get("items"), load_rules())
|
||||||
|
except rules_edit.RuleError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
_apply(section, new, bool(body.get("confirm")))
|
||||||
|
data = read_overrides()
|
||||||
|
data[section] = new
|
||||||
|
_save_overrides(data)
|
||||||
|
return {"ok": True, "count": len(new)}
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/admin/rules/{section}")
|
||||||
|
def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)):
|
||||||
|
"""Back to the shipped rules.json for this section."""
|
||||||
|
if section not in rules_edit.SECTIONS:
|
||||||
|
raise HTTPException(404, "Unknown section")
|
||||||
|
data = read_overrides()
|
||||||
|
if section in data:
|
||||||
|
_apply(section, base_rules()[section], confirm)
|
||||||
|
del data[section]
|
||||||
|
_save_overrides(data)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/api/lobbies/{lid}/ws")
|
||||||
|
async def lobby_socket(websocket: WebSocket, lid: str):
|
||||||
|
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
|
||||||
|
origin = websocket.headers.get("origin")
|
||||||
|
hosts = {websocket.headers.get("host", "")}
|
||||||
|
if TRUST_PROXY:
|
||||||
|
hosts.add(websocket.headers.get("x-forwarded-host", ""))
|
||||||
|
if origin and origin.split("://", 1)[-1] not in hosts:
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
def check():
|
||||||
|
with db() as con:
|
||||||
|
user = auth.user_for_token(con, websocket.cookies.get(auth.SESSION_COOKIE))
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
lobby = con.execute("SELECT owner_id FROM lobbies WHERE id=?", (lid,)).fetchone()
|
||||||
|
ok = lobby and (lobby["owner_id"] == user["id"] or con.execute(
|
||||||
|
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone())
|
||||||
|
return user if ok else None
|
||||||
|
|
||||||
|
user = await asyncio.to_thread(check)
|
||||||
|
if not user:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
await websocket.accept()
|
||||||
|
if not hub.add(lid, websocket, user["id"]):
|
||||||
|
await websocket.close(code=4429)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
last_check = time.time()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
text = await asyncio.wait_for(websocket.receive_text(), timeout=70)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break # the client pings every 25 seconds, silence means it is gone
|
||||||
|
if text == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
if time.time() - last_check > 300: # session or membership may have ended
|
||||||
|
last_check = time.time()
|
||||||
|
if not await asyncio.to_thread(check):
|
||||||
|
break
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
hub.remove(lid, websocket)
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Real-time pushes for lobbies over WebSockets.
|
||||||
|
|
||||||
|
The sockets only carry short "something changed" notes ({"t": "messages"} or {"t": "lobby"}). The browser then loads the
|
||||||
|
data through the normal API, so all visibility rules (whispers, hidden rolls, who sees which character) stay in one place.
|
||||||
|
Without a working socket the client keeps polling, so nothing breaks when a proxy blocks WebSockets."""
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
|
||||||
|
MAX_PER_USER = 6 # open sockets per user (several tabs)
|
||||||
|
MAX_PER_LOBBY = 80
|
||||||
|
|
||||||
|
|
||||||
|
class Hub:
|
||||||
|
def __init__(self):
|
||||||
|
self.rooms: dict = {} # lobby id -> {websocket: user id}
|
||||||
|
self.loop = None
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def attach(self, loop):
|
||||||
|
self.loop = loop
|
||||||
|
|
||||||
|
def add(self, lid: str, ws, uid: int) -> bool:
|
||||||
|
with self.lock:
|
||||||
|
room = self.rooms.setdefault(lid, {})
|
||||||
|
if len(room) >= MAX_PER_LOBBY or sum(1 for u in room.values() if u == uid) >= MAX_PER_USER:
|
||||||
|
return False
|
||||||
|
room[ws] = uid
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove(self, lid: str, ws):
|
||||||
|
with self.lock:
|
||||||
|
room = self.rooms.get(lid)
|
||||||
|
if room:
|
||||||
|
room.pop(ws, None)
|
||||||
|
if not room:
|
||||||
|
del self.rooms[lid]
|
||||||
|
|
||||||
|
def online(self, lid: str) -> set:
|
||||||
|
with self.lock:
|
||||||
|
return set(self.rooms.get(lid, {}).values())
|
||||||
|
|
||||||
|
async def _send(self, lid: str, note: dict, users):
|
||||||
|
with self.lock:
|
||||||
|
targets = [ws for ws, uid in self.rooms.get(lid, {}).items() if users is None or uid in users]
|
||||||
|
for ws in targets:
|
||||||
|
try:
|
||||||
|
await ws.send_json(note)
|
||||||
|
except Exception:
|
||||||
|
self.remove(lid, ws)
|
||||||
|
|
||||||
|
def notify(self, lid: str, kind: str, users=None, delay: float = 0.08, **extra):
|
||||||
|
"""Callable from normal (threadpool) endpoints. The short delay lets the database transaction finish first."""
|
||||||
|
if not self.loop or lid not in self.rooms:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await self._send(lid, {"t": kind, **extra}, users)
|
||||||
|
asyncio.run_coroutine_threadsafe(go(), self.loop)
|
||||||
|
|
||||||
|
def close_user(self, lid: str, uid: int):
|
||||||
|
"""Drops the sockets of someone who left or was removed."""
|
||||||
|
if not self.loop:
|
||||||
|
return
|
||||||
|
with self.lock:
|
||||||
|
targets = [ws for ws, u in self.rooms.get(lid, {}).items() if u == uid]
|
||||||
|
for ws in targets:
|
||||||
|
asyncio.run_coroutine_threadsafe(ws.close(code=4403), self.loop)
|
||||||
|
|
||||||
|
def close_lobby(self, lid: str):
|
||||||
|
if not self.loop:
|
||||||
|
return
|
||||||
|
with self.lock:
|
||||||
|
targets = list(self.rooms.get(lid, {}))
|
||||||
|
for ws in targets:
|
||||||
|
asyncio.run_coroutine_threadsafe(ws.close(code=4404), self.loop)
|
||||||
|
|
||||||
|
|
||||||
|
hub = Hub()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Validation for the rules editor (items, skills, professions and their categories).
|
||||||
|
Only whitelisted fields are kept and every reference (category, group, attribute, skill) has to exist."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
SECTIONS = ("equipment", "equipment_categories", "skills", "skill_groups", "professions")
|
||||||
|
MAX_ENTRIES = 400
|
||||||
|
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||||
|
|
||||||
|
|
||||||
|
class RuleError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _text(v, limit, label, required=False):
|
||||||
|
v = str(v if v is not None else "").strip()
|
||||||
|
if required and not v:
|
||||||
|
raise RuleError(f"{label} must not be empty")
|
||||||
|
if len(v) > limit:
|
||||||
|
raise RuleError(f"{label} is too long (max {limit} characters)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v, lo, hi, label, integer=False):
|
||||||
|
try:
|
||||||
|
n = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise RuleError(f"{label} must be a number")
|
||||||
|
if not lo <= n <= hi:
|
||||||
|
raise RuleError(f"{label} must be between {lo} and {hi}")
|
||||||
|
return int(n) if integer else round(n, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(v, allowed, label, lo=0, hi=50):
|
||||||
|
if not isinstance(v, list) or not lo <= len(v) <= hi:
|
||||||
|
raise RuleError(f"{label}: choose {lo} to {hi}" if hi < 50 else f"{label} must be a list")
|
||||||
|
out = []
|
||||||
|
for x in v:
|
||||||
|
if x not in allowed:
|
||||||
|
raise RuleError(f"{label}: unknown entry '{x}'")
|
||||||
|
if x not in out:
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def clean(section: str, items, rules: dict) -> list:
|
||||||
|
"""Returns the cleaned list for one section. `rules` is the effective rules (for the other sections' ids)."""
|
||||||
|
if section not in SECTIONS:
|
||||||
|
raise RuleError("Unknown section")
|
||||||
|
if not isinstance(items, list) or len(items) > MAX_ENTRIES:
|
||||||
|
raise RuleError(f"A list of at most {MAX_ENTRIES} entries is expected")
|
||||||
|
attrs = {a["id"] for a in rules["attributes"]}
|
||||||
|
out, seen = [], set()
|
||||||
|
cats = {c["id"] for c in (items if section == "equipment_categories" else rules["equipment_categories"])}
|
||||||
|
groups = {g["id"] for g in (items if section == "skill_groups" else rules["skill_groups"])}
|
||||||
|
skills = {s["id"] for s in (items if section == "skills" else rules["skills"])}
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
raise RuleError("Every entry has to be an object")
|
||||||
|
iid = str(it.get("id", ""))
|
||||||
|
if not ID_RE.match(iid):
|
||||||
|
raise RuleError(f"Invalid id '{iid}' (lowercase letters, digits and _ only, up to 40)")
|
||||||
|
if iid in seen:
|
||||||
|
raise RuleError(f"The id '{iid}' is used twice")
|
||||||
|
seen.add(iid)
|
||||||
|
name = _text(it.get("name"), 60, f"Name of '{iid}'", True)
|
||||||
|
e = {"id": iid, "name": name}
|
||||||
|
label = f"'{name}'"
|
||||||
|
if section == "equipment":
|
||||||
|
if it.get("cat") not in cats:
|
||||||
|
raise RuleError(f"{label}: unknown category")
|
||||||
|
e.update(cat=it["cat"], stat=_text(it.get("stat"), 40, f"{label} stat"), kg=_num(it.get("kg", 0), 0, 1000, f"{label} weight"),
|
||||||
|
note=_text(it.get("note"), 200, f"{label} note"))
|
||||||
|
if it.get("tier"):
|
||||||
|
e["tier"] = _text(it["tier"], 20, f"{label} tier")
|
||||||
|
elif section == "equipment_categories":
|
||||||
|
e["stat"] = _text(it.get("stat"), 40, f"{label} stat column")
|
||||||
|
elif section == "skills":
|
||||||
|
if it.get("group") not in groups:
|
||||||
|
raise RuleError(f"{label}: unknown group")
|
||||||
|
if it.get("type") not in ("basic", "advanced"):
|
||||||
|
raise RuleError(f"{label}: type must be basic or advanced")
|
||||||
|
a = _ids(it.get("attrs"), attrs, f"{label} attributes", 1, 3)
|
||||||
|
e.update(group=it["group"], attrs=a, type=it["type"], desc=_text(it.get("desc"), 300, f"{label} description"))
|
||||||
|
elif section == "professions":
|
||||||
|
e.update(desc=_text(it.get("desc"), 500, f"{label} description"), bonus_attrs=_ids(it.get("bonus_attrs"), attrs, f"{label} bonus attributes", 0, 7),
|
||||||
|
proficiencies=_ids(it.get("proficiencies"), skills, f"{label} proficiencies", 0, 12),
|
||||||
|
picks=_num(it.get("picks", 0), 0, 10, f"{label} free picks", True), note=_text(it.get("note"), 200, f"{label} note"))
|
||||||
|
if not e["note"]:
|
||||||
|
del e["note"]
|
||||||
|
if it.get("placeholder"):
|
||||||
|
e["placeholder"] = True
|
||||||
|
out.append(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dangling(section: str, new: list, rules: dict) -> list:
|
||||||
|
"""Things in the other sections that would point to an entry that no longer exists."""
|
||||||
|
ids = {x["id"] for x in new}
|
||||||
|
msgs = []
|
||||||
|
if section == "equipment_categories":
|
||||||
|
msgs += [f"Item '{e['name']}' uses the removed category" for e in rules["equipment"] if e["cat"] not in ids]
|
||||||
|
if section == "skill_groups":
|
||||||
|
msgs += [f"Skill '{s['name']}' uses the removed group" for s in rules["skills"] if s["group"] not in ids]
|
||||||
|
if section == "skills":
|
||||||
|
for p in rules["professions"]:
|
||||||
|
gone = [i for i in p["proficiencies"] if i not in ids]
|
||||||
|
if gone:
|
||||||
|
msgs.append(f"Profession '{p['name']}' grants proficiency in a removed skill ({', '.join(gone)})")
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def character_refs(ch: dict) -> dict:
|
||||||
|
"""Ids a saved character points to, per section."""
|
||||||
|
kit = ch.get("kit") or {}
|
||||||
|
items = {i for i in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or []), *(kit.get("extras") or {})] if i}
|
||||||
|
sk = set((ch.get("skill_points") or {}).keys()) | set((ch.get("proficiencies") or {}).keys()) | set((ch.get("proficiency_picks") or {}).keys()) \
|
||||||
|
| set(ch.get("unlocked") or []) | set(ch.get("profession_picks") or [])
|
||||||
|
return {"equipment": items, "skills": sk, "professions": {ch["profession"]} if ch.get("profession") else set()}
|
||||||
|
|
||||||
|
|
||||||
|
def usage(chars: list, section: str, removed: set) -> int:
|
||||||
|
"""How many saved characters use one of the removed ids."""
|
||||||
|
if section not in ("equipment", "skills", "professions") or not removed:
|
||||||
|
return 0
|
||||||
|
return sum(1 for ch in chars if character_refs(ch)[section] & removed)
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"skill_points_default": 150,
|
"skill_points_default": 150,
|
||||||
"skill_max_divisor": 2,
|
"skill_max_divisor": 2,
|
||||||
"advanced_unlock_cost": 10,
|
"advanced_unlock_cost": 10,
|
||||||
|
"advanced_attr_percent": 50,
|
||||||
"free_proficiencies": 2,
|
"free_proficiencies": 2,
|
||||||
"max_proficiency_pips": 2,
|
"max_proficiency_pips": 2,
|
||||||
"talents_min": 1,
|
"talents_min": 1,
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
{"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50},
|
{"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50},
|
||||||
{"key": "free_proficiencies", "label": "Free proficiency picks", "group": "Skills", "min": 0, "max": 20},
|
{"key": "free_proficiencies", "label": "Free proficiency picks", "group": "Skills", "min": 0, "max": 20},
|
||||||
{"key": "skill_points_default", "label": "Skill point pool", "group": "Skills", "min": 0, "max": 1000},
|
{"key": "skill_points_default", "label": "Skill point pool", "group": "Skills", "min": 0, "max": 1000},
|
||||||
|
{"key": "advanced_attr_percent", "label": "Advanced skills: share of the attribute that counts (%)", "group": "Skills", "min": 0, "max": 100},
|
||||||
{"key": "advanced_unlock_cost", "label": "Cost to unlock an advanced skill", "group": "Skills", "min": 0, "max": 100},
|
{"key": "advanced_unlock_cost", "label": "Cost to unlock an advanced skill", "group": "Skills", "min": 0, "max": 100},
|
||||||
{"key": "skill_max_divisor", "label": "Skill maximum = attribute divided by", "group": "Skills", "min": 1, "max": 10},
|
{"key": "skill_max_divisor", "label": "Skill maximum = attribute divided by", "group": "Skills", "min": 1, "max": 10},
|
||||||
{"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50},
|
{"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50},
|
||||||
@@ -434,7 +436,7 @@
|
|||||||
"placeholder": true
|
"placeholder": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"p": "The unlock cost for advanced skills (10 points) is a house rule of the app, not part of the brainstorm. It can be changed in rules.json (creation.advanced_unlock_cost).",
|
"p": "The unlock cost for advanced skills (10 points) and the rule that advanced skills only use half of the attribute are house rules of the app, not part of the brainstorm. They can be changed in rules.json (creation.advanced_unlock_cost, creation.advanced_attr_percent).",
|
||||||
"placeholder": true
|
"placeholder": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
# Code that new users must enter to create an account (set it in the .env file next to this file).
|
# Code that new users must enter to create an account (set it in the .env file next to this file).
|
||||||
# Empty = registration is disabled.
|
# Empty = registration is disabled.
|
||||||
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
|
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
|
||||||
|
# Who may use the rules editor: comma separated usernames. Empty = only the first account that was ever created.
|
||||||
|
AW_ADMINS: ${AW_ADMINS:-}
|
||||||
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
|
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
|
||||||
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
|
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Presets from './Presets.jsx'
|
|||||||
import Lobbies from './Lobbies.jsx'
|
import Lobbies from './Lobbies.jsx'
|
||||||
import Login from './Login.jsx'
|
import Login from './Login.jsx'
|
||||||
import Account from './Account.jsx'
|
import Account from './Account.jsx'
|
||||||
|
import Editor from './Editor.jsx'
|
||||||
import { claimLegacy } from './legacy.js'
|
import { claimLegacy } from './legacy.js'
|
||||||
|
|
||||||
const useHash = () => {
|
const useHash = () => {
|
||||||
@@ -53,7 +54,7 @@ export default function App() {
|
|||||||
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
|
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
|
||||||
|
|
||||||
const page = hash.split('/')[0]
|
const page = hash.split('/')[0]
|
||||||
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']]
|
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -81,6 +82,7 @@ export default function App() {
|
|||||||
{rules && page === 'lobbies' && (
|
{rules && page === 'lobbies' && (
|
||||||
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||||
)}
|
)}
|
||||||
|
{rules && page === 'editor' && auth.user.admin && <Editor rules={rules} onReload={loadRules} />}
|
||||||
{page === 'account' && <Account user={username} onLogout={logout} />}
|
{page === 'account' && <Account user={username} onLogout={logout} />}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -314,9 +314,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
<button className="ghost small-btn" title={`Lock again: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
|
<button className="ghost small-btn" title={`Lock again: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
|
||||||
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock</button>
|
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
|
<button className="step5" onClick={() => setPts(s.points - 5)} disabled={s.points <= 0} aria-label={`${s.name} minus 5`}>−5</button>
|
||||||
|
<button onClick={() => setPts(s.points - 1)} disabled={s.points <= 0} aria-label={`${s.name} minus`}>−</button>
|
||||||
<span className="val">{s.points}</span>
|
<span className="val">{s.points}</span>
|
||||||
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
|
<button onClick={() => setPts(Math.min(s.points + 1, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
|
||||||
|
<button className="step5" onClick={() => setPts(Math.min(s.points + 5, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus 5`}>+5</button>
|
||||||
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</span>
|
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
const J = { 'Content-Type': 'application/json' }
|
||||||
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40)
|
||||||
|
|
||||||
|
// What can be edited: fields per entry and the columns of the list
|
||||||
|
const defs = (saved, attrs) => {
|
||||||
|
const cats = (saved.equipment_categories || []).map((c) => [c.id, c.name])
|
||||||
|
const groups = (saved.skill_groups || []).map((g) => [g.id, g.name])
|
||||||
|
const skills = (saved.skills || []).map((s) => [s.id, s.name])
|
||||||
|
const name = (list, id) => (list.find(([k]) => k === id) || [])[1] || id
|
||||||
|
return {
|
||||||
|
equipment: {
|
||||||
|
label: 'Items', noun: 'item',
|
||||||
|
fields: [['name', 'Name', 'text'], ['cat', 'Category', 'select', cats], ['stat', 'Stat (damage, armor ...)', 'text'], ['tier', 'Tier (optional, e.g. light)', 'text'],
|
||||||
|
['kg', 'Weight (kg)', 'number'], ['note', 'Note', 'text'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
|
||||||
|
blank: { cat: cats[0]?.[0] || '', stat: '', kg: 1, note: '' },
|
||||||
|
cols: [['Name', (e) => e.name], ['Category', (e) => name(cats, e.cat)], ['Stat', (e) => `${e.stat}${e.tier ? ` (${e.tier})` : ''}`], ['kg', (e) => e.kg]],
|
||||||
|
},
|
||||||
|
equipment_categories: {
|
||||||
|
label: 'Item categories', noun: 'category',
|
||||||
|
fields: [['name', 'Name', 'text'], ['stat', 'Name of the stat column', 'text']],
|
||||||
|
blank: { stat: '' }, cols: [['Name', (e) => e.name], ['Stat column', (e) => e.stat]],
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
label: 'Skills', noun: 'skill',
|
||||||
|
fields: [['name', 'Name', 'text'], ['group', 'Group', 'select', groups], ['type', 'Type', 'select', [['basic', 'Basic'], ['advanced', 'Advanced']]],
|
||||||
|
['attrs', 'Attributes (the higher one counts)', 'multi', attrs], ['desc', 'Description', 'textarea'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
|
||||||
|
blank: { group: groups[0]?.[0] || '', type: 'basic', attrs: [], desc: '' },
|
||||||
|
cols: [['Name', (e) => e.name], ['Group', (e) => name(groups, e.group)], ['Type', (e) => e.type], ['Attributes', (e) => e.attrs.map((a) => name(attrs, a)).join(' / ')]],
|
||||||
|
},
|
||||||
|
skill_groups: {
|
||||||
|
label: 'Skill groups', noun: 'group', fields: [['name', 'Name', 'text']], blank: {}, cols: [['Name', (e) => e.name]],
|
||||||
|
},
|
||||||
|
professions: {
|
||||||
|
label: 'Professions', noun: 'profession',
|
||||||
|
fields: [['name', 'Name', 'text'], ['desc', 'Description', 'textarea'], ['bonus_attrs', 'Attributes that can get the profession bonus', 'multi', attrs],
|
||||||
|
['proficiencies', 'Skills with fixed proficiency', 'multi', skills], ['picks', 'Free proficiency picks', 'number'], ['note', 'Note', 'text'],
|
||||||
|
['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
|
||||||
|
blank: { desc: '', bonus_attrs: [], proficiencies: [], picks: 0 },
|
||||||
|
cols: [['Name', (e) => e.name], ['Bonus', (e) => e.bonus_attrs.map((a) => name(attrs, a)).join(', ')], ['Proficiencies', (e) => e.proficiencies.map((s) => name(skills, s)).join(', ')], ['Picks', (e) => e.picks]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ f, value, onChange }) {
|
||||||
|
const [key, label, type, opts] = f
|
||||||
|
if (type === 'bool') return <label className="small"><input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} style={{ width: 'auto' }} /> {label}</label>
|
||||||
|
return (
|
||||||
|
<label className="edfield">
|
||||||
|
<span className="small muted">{label}</span>
|
||||||
|
{type === 'text' && <input value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
|
||||||
|
{type === 'number' && <input type="number" step="any" value={value ?? ''} onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} />}
|
||||||
|
{type === 'textarea' && <textarea rows={3} value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
|
||||||
|
{type === 'select' && <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>{opts.map(([k, n]) => <option key={k} value={k}>{n}</option>)}</select>}
|
||||||
|
{type === 'multi' && (
|
||||||
|
<span className="multi">
|
||||||
|
{opts.map(([k, n]) => (
|
||||||
|
<label key={k} className="small"><input type="checkbox" style={{ width: 'auto' }} checked={(value || []).includes(k)}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? [...(value || []), k] : (value || []).filter((x) => x !== k))} /> {n}</label>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Editor({ rules, onReload }) {
|
||||||
|
const [state, setState] = useState(null)
|
||||||
|
const [tab, setTab] = useState('equipment')
|
||||||
|
const [drafts, setDrafts] = useState({}) // section -> unsaved list
|
||||||
|
const [editing, setEditing] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [msg, setMsg] = useState({ text: '', bad: false })
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = () => fetch('/api/admin/rules').then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(r.status)
|
||||||
|
setState(await r.json())
|
||||||
|
}).catch((e) => setMsg({ text: String(e.message) === '403' ? 'Only admins can use the rules editor.' : 'Could not load the editor.', bad: true }))
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const D = useMemo(() => state && defs(state.sections, rules.attributes.map((a) => [a.id, a.name])), [state, rules.attributes])
|
||||||
|
if (!state) return <section className="page"><p className={msg.bad ? 'warn' : 'muted'}>{msg.text || 'Loading …'}</p></section>
|
||||||
|
|
||||||
|
const d = D[tab]
|
||||||
|
const saved = state.sections[tab]
|
||||||
|
const list = drafts[tab] || saved
|
||||||
|
const dirty = !!drafts[tab]
|
||||||
|
const setList = (l) => setDrafts((x) => ({ ...x, [tab]: l }))
|
||||||
|
const patch = (id, key, v) => setList(list.map((e) => {
|
||||||
|
if (e.id !== id) return e
|
||||||
|
const n = { ...e, [key]: v }
|
||||||
|
if (e._new && key === 'name') { // new entries get their id from the name
|
||||||
|
let base = slug(v) || 'entry', cand = base, i = 2
|
||||||
|
while (list.some((x) => x !== e && x.id === cand)) cand = `${base}_${i++}`
|
||||||
|
const old = e.id
|
||||||
|
n.id = cand
|
||||||
|
if (editing === old) setEditing(cand)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}))
|
||||||
|
const add = () => {
|
||||||
|
let id = 'new_entry', i = 2
|
||||||
|
while (list.some((x) => x.id === id)) id = `new_entry_${i++}`
|
||||||
|
setList([...list, { id, name: '', ...d.blank, _new: true }])
|
||||||
|
setEditing(id); setQ('')
|
||||||
|
}
|
||||||
|
const del = (e) => { if (editing === e.id) setEditing(''); setList(list.filter((x) => x.id !== e.id)) }
|
||||||
|
|
||||||
|
const save = async (confirm = false) => {
|
||||||
|
setBusy(true); setMsg({ text: '', bad: false })
|
||||||
|
const items = list.map(({ _new, ...e }) => e)
|
||||||
|
const r = await fetch(`/api/admin/rules/${tab}`, { method: 'PUT', headers: J, body: JSON.stringify({ items, confirm }) }).catch(() => null)
|
||||||
|
const body = r ? await r.json().catch(() => ({})) : {}
|
||||||
|
setBusy(false)
|
||||||
|
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nSave anyway?`)) return save(true)
|
||||||
|
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not save.', bad: true })
|
||||||
|
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
|
||||||
|
setEditing(''); setMsg({ text: `Saved ${body.count} entries. Everybody sees the change after reloading the page.`, bad: false })
|
||||||
|
await load(); onReload?.()
|
||||||
|
}
|
||||||
|
const reset = async (confirm = false) => {
|
||||||
|
if (!confirm && !window.confirm(`Throw away all your changes to "${d.label}" and go back to the shipped rules?`)) return
|
||||||
|
const r = await fetch(`/api/admin/rules/${tab}?confirm=${confirm}`, { method: 'DELETE' }).catch(() => null)
|
||||||
|
const body = r ? await r.json().catch(() => ({})) : {}
|
||||||
|
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nReset anyway?`)) return reset(true)
|
||||||
|
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not reset.', bad: true })
|
||||||
|
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
|
||||||
|
setMsg({ text: 'Back to the shipped rules.', bad: false })
|
||||||
|
await load(); onReload?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = list.filter((e) => !q || e.name.toLowerCase().includes(q.toLowerCase()) || e.id.includes(q.toLowerCase()))
|
||||||
|
const cur = list.find((e) => e.id === editing)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page editor">
|
||||||
|
<h1>Rules editor</h1>
|
||||||
|
<p className="muted small">Changes are stored next to the database and replace the entries of rules.json for that list; the shipped file stays as it is.
|
||||||
|
Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p>
|
||||||
|
<div className="tabs" role="tablist">
|
||||||
|
{Object.entries(D).map(([id, x]) => (
|
||||||
|
<button key={id} role="tab" aria-selected={tab === id} className={tab === id ? 'on' : ''} onClick={() => { setTab(id); setEditing(''); setQ(''); setMsg({ text: '', bad: false }) }}>
|
||||||
|
{x.label}{drafts[id] ? ' *' : ''}{state.customized.includes(id) ? ' •' : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="row wrapform">
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${d.label.toLowerCase()} …`} aria-label="Search" style={{ maxWidth: '18rem' }} />
|
||||||
|
<span className="muted small">{list.length} entries{state.customized.includes(tab) ? ' · customised (•)' : ' · as shipped'}</span>
|
||||||
|
<span className="actions">
|
||||||
|
<button onClick={add}>+ Add {d.noun}</button>
|
||||||
|
<button onClick={() => save()} disabled={!dirty || busy}>Save changes</button>
|
||||||
|
<button className="ghost" onClick={() => { setDrafts((x) => { const n = { ...x }; delete n[tab]; return n }); setEditing('') }} disabled={!dirty}>Discard</button>
|
||||||
|
<button className="ghost danger" onClick={() => reset()} disabled={!state.customized.includes(tab)}>Reset to shipped</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{msg.text && <p className={msg.bad ? 'warn' : 'small'} role={msg.bad ? 'alert' : 'status'}>{msg.text}</p>}
|
||||||
|
|
||||||
|
{cur && (
|
||||||
|
<div className="card edform">
|
||||||
|
<div className="row"><strong>{cur.name || `New ${d.noun}`}</strong><span className="muted small">id: {cur.id}{cur._new ? ' (from the name)' : ''}</span></div>
|
||||||
|
<div className="edgrid">
|
||||||
|
{d.fields.map((f) => <Field key={f[0]} f={f} value={cur[f[0]]} onChange={(v) => patch(cur.id, f[0], v)} />)}
|
||||||
|
</div>
|
||||||
|
<div className="actions"><button className="ghost" onClick={() => setEditing('')}>Done</button></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="tablewrap"><table className="edtable">
|
||||||
|
<thead><tr>{d.cols.map(([h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map((e) => (
|
||||||
|
<tr key={e.id} className={editing === e.id ? 'sel' : ''}>
|
||||||
|
{d.cols.map(([h, fn]) => <td key={h}>{fn(e)}{h === 'Name' && e.placeholder ? <span className="badge">Placeholder</span> : ''}</td>)}
|
||||||
|
<td className="actions"><button className="ghost" onClick={() => setEditing(e.id)}>Edit</button><button className="ghost danger" onClick={() => del(e)}>Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{shown.length === 0 && <tr><td colSpan={d.cols.length + 1} className="muted">Nothing found.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
+76
-23
@@ -109,8 +109,40 @@ const sgn = (n) => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '±0')
|
|||||||
const store = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v) } catch { /* storage may be blocked */ } return null }
|
const store = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v) } catch { /* storage may be blocked */ } return null }
|
||||||
const OUTCOME = { crit_success: 'Critical success!', success: 'Success', failure: 'Failure', crit_failure: 'Critical failure!' }
|
const OUTCOME = { crit_success: 'Critical success!', success: 'Success', failure: 'Failure', crit_failure: 'Critical failure!' }
|
||||||
|
|
||||||
|
// One WebSocket per open lobby. It only says "something changed" ({t: 'messages' | 'lobby' | 'sheet'}), the data is loaded through
|
||||||
|
// the normal API. Without a working socket the page falls back to polling, so a proxy that blocks WebSockets does not break anything.
|
||||||
|
function useSocket(id, handlers) {
|
||||||
|
const [up, setUp] = useState(false)
|
||||||
|
const h = useRef(handlers)
|
||||||
|
h.current = handlers
|
||||||
|
useEffect(() => {
|
||||||
|
let ws, timer, ping, tries = 0, closed = false
|
||||||
|
const open = () => {
|
||||||
|
ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/lobbies/${id}/ws`)
|
||||||
|
ws.onopen = () => {
|
||||||
|
tries = 0; setUp(true); h.current.open?.()
|
||||||
|
ping = setInterval(() => ws.readyState === 1 && ws.send('ping'), 25000)
|
||||||
|
}
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
if (e.data === 'pong') return
|
||||||
|
try { const n = JSON.parse(e.data); h.current[n.t]?.(n) } catch { /* ignore malformed notes */ }
|
||||||
|
}
|
||||||
|
ws.onclose = (e) => {
|
||||||
|
clearInterval(ping); setUp(false)
|
||||||
|
if (closed || (e.code >= 4400 && e.code < 4500)) return // refused or removed: the normal polling shows why
|
||||||
|
timer = setTimeout(open, Math.min(10000, 1000 * 2 ** tries++))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { open() } catch { /* no WebSocket support: polling only */ }
|
||||||
|
const wake = () => { if (!document.hidden) h.current.open?.() } // catch up after the tab was in the background
|
||||||
|
document.addEventListener('visibilitychange', wake)
|
||||||
|
return () => { closed = true; clearTimeout(timer); clearInterval(ping); document.removeEventListener('visibilitychange', wake); ws?.close() }
|
||||||
|
}, [id])
|
||||||
|
return up
|
||||||
|
}
|
||||||
|
|
||||||
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
|
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
|
||||||
function useFeed(id, onEvent, onRoll) {
|
function useFeed(id, onEvent, onRoll, socketUp) {
|
||||||
const [msgs, setMsgs] = useState([])
|
const [msgs, setMsgs] = useState([])
|
||||||
const last = useRef(0)
|
const last = useRef(0)
|
||||||
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
|
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
|
||||||
@@ -134,9 +166,9 @@ function useFeed(id, onEvent, onRoll) {
|
|||||||
}, [id])
|
}, [id])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll()
|
poll()
|
||||||
const t = setInterval(poll, 2500)
|
const t = setInterval(poll, socketUp ? 20000 : 2500) // the socket says when something is new, the timer is only a safety net
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [poll])
|
}, [poll, socketUp])
|
||||||
return { msgs, poll }
|
return { msgs, poll }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +209,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
|||||||
const who = (m) => (
|
const who = (m) => (
|
||||||
<>
|
<>
|
||||||
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
|
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||||
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
|
{m.to && <em className="small whispertag"> {m.to === m.from ? '(private, only you)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,6 +248,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
|||||||
<form className="row chatform" onSubmit={submit}>
|
<form className="row chatform" onSubmit={submit}>
|
||||||
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
||||||
<option value="">Everyone</option>
|
<option value="">Everyone</option>
|
||||||
|
<option value={me}>Only me (private)</option>
|
||||||
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
|
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
|
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
|
||||||
@@ -243,7 +276,7 @@ function DieIcon({ sides }) {
|
|||||||
|
|
||||||
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
|
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
|
||||||
|
|
||||||
function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
|
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
|
||||||
return (
|
return (
|
||||||
<div className="dicebar" role="toolbar" aria-label="Dice">
|
<div className="dicebar" role="toolbar" aria-label="Dice">
|
||||||
<label className="small muted dicecount">Dice
|
<label className="small muted dicecount">Dice
|
||||||
@@ -264,7 +297,7 @@ function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll
|
|||||||
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
|
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{to && <span className="small dicedest priv">Private: {to}</span>}
|
{to && <span className="small dicedest priv">{to === me ? 'Private: only you' : `Private: ${to}`}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,7 +379,7 @@ function DiceTray({ rolls }) {
|
|||||||
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
|
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
|
||||||
if (m.kind === 'check') {
|
if (m.kind === 'check') {
|
||||||
const c = m.check
|
const c = m.check
|
||||||
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
|
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}{m.to ? ' · only you' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
|
||||||
}
|
}
|
||||||
const t = rollText(m)
|
const t = rollText(m)
|
||||||
return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
|
return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
|
||||||
@@ -410,7 +443,7 @@ function DifficultyBar({ lobby, onSet }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||||||
function CharTab({ cid, rules, onRoll }) {
|
function CharTab({ cid, rules, onRoll, tick }) {
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -420,6 +453,9 @@ function CharTab({ cid, rules, onRoll }) {
|
|||||||
const t = setInterval(() => !document.hidden && load(), 15000)
|
const t = setInterval(() => !document.hidden && load(), 15000)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [cid])
|
}, [cid])
|
||||||
|
useEffect(() => { // pushed by the server when the player saved the character
|
||||||
|
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setData(d)).catch(() => {})
|
||||||
|
}, [tick]) // eslint-disable-line
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
@@ -429,14 +465,14 @@ function CharTab({ cid, rules, onRoll }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lobby settings: join code, rename, colour, character switch, members, leave / delete
|
// lobby settings: join code, rename, colour, character switch, members, leave / delete
|
||||||
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
|
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
|
||||||
const [rename, setRename] = useState('')
|
const [rename, setRename] = useState('')
|
||||||
const [pick, setPick] = useState('')
|
const [pick, setPick] = useState('')
|
||||||
const me = lobby.members.find((m) => m.me)
|
const me = lobby.members.find((m) => m.me)
|
||||||
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
|
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
|
||||||
return (
|
return (
|
||||||
<div className="settings card">
|
<div className={inline ? 'settings inline' : 'settings card'}>
|
||||||
<div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>
|
{!inline && <div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>}
|
||||||
{lobby.is_dm && (
|
{lobby.is_dm && (
|
||||||
<>
|
<>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@@ -505,6 +541,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [mErr, setMErr] = useState('')
|
const [mErr, setMErr] = useState('')
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [leftTab, setLeftTab] = useState('sheet') // the DM's left column: character sheet or lobby actions
|
||||||
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
|
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
|
||||||
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
|
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
|
||||||
|
|
||||||
@@ -512,18 +549,27 @@ function LobbyView({ id, rules, user }) {
|
|||||||
if (!r.ok) throw new Error(r.status)
|
if (!r.ok) throw new Error(r.status)
|
||||||
setLobby(await r.json())
|
setLobby(await r.json())
|
||||||
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
|
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
|
||||||
|
const sock = useRef({})
|
||||||
|
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
|
||||||
|
const up = useSocket(id, {
|
||||||
|
open: () => { sock.current.load?.(); sock.current.poll?.() },
|
||||||
|
messages: () => sock.current.poll?.(),
|
||||||
|
lobby: () => sock.current.load?.(),
|
||||||
|
sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })),
|
||||||
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
|
const t = setInterval(() => !document.hidden && load(), up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [load])
|
}, [load, up])
|
||||||
|
|
||||||
const onRoll = useCallback((m) => {
|
const onRoll = useCallback((m) => {
|
||||||
const key = `${m.id}`
|
const key = `${m.id}`
|
||||||
setTray((t) => [...t.slice(-2), { ...m, key }])
|
setTray((t) => [...t.slice(-2), { ...m, key }])
|
||||||
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
|
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
|
||||||
}, [])
|
}, [])
|
||||||
const feed = useFeed(id, load, onRoll)
|
const feed = useFeed(id, load, onRoll, up)
|
||||||
|
sock.current = { load, poll: feed.poll }
|
||||||
|
|
||||||
const act = async (url, method, body, done) => {
|
const act = async (url, method, body, done) => {
|
||||||
if (!url) return load()
|
if (!url) return load()
|
||||||
@@ -560,10 +606,10 @@ function LobbyView({ id, rules, user }) {
|
|||||||
const check = (kind, cid, name) => {
|
const check = (kind, cid, name) => {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
if (own) return setPending({ type: 'check', kind, id: cid, name })
|
if (own) return setPending({ type: 'check', kind, id: cid, name })
|
||||||
post(`/api/lobbies/${id}/checks`, { kind, id: cid })
|
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '' })
|
||||||
}
|
}
|
||||||
const submitManual = (vals) => {
|
const submitManual = (vals) => {
|
||||||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => setPending(null))
|
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '' }, () => setPending(null))
|
||||||
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
|
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +620,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
<h1>{lobby.name}</h1>
|
<h1>{lobby.name}</h1>
|
||||||
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
||||||
<span className="tabright">
|
<span className="tabright">
|
||||||
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
|
<button className="ghost" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
@@ -584,26 +630,33 @@ function LobbyView({ id, rules, user }) {
|
|||||||
{sheetOpen && (
|
{sheetOpen && (
|
||||||
<div className="sheetcol">
|
<div className="sheetcol">
|
||||||
<div className="sheetbar">
|
<div className="sheetbar">
|
||||||
<b>{cur ? cur.character.name : 'Character sheet'}</b>
|
{lobby.is_dm ? (
|
||||||
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
|
<span className="seg" role="tablist">
|
||||||
|
<button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{cur ? `: ${cur.character.name}` : ''}</button>
|
||||||
|
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
|
||||||
|
</span>
|
||||||
|
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
|
||||||
|
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
|
||||||
</div>
|
</div>
|
||||||
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
|
{lobby.is_dm && leftTab === 'lobby'
|
||||||
|
? <Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} />
|
||||||
|
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
|
||||||
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="playarea">
|
<div className="playarea">
|
||||||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
||||||
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
|
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setLeftTab('sheet') }} />
|
||||||
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||||||
<div className="table">
|
<div className="table">
|
||||||
{showSettings && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
||||||
<DiceTray rolls={tray} />
|
<DiceTray rolls={tray} />
|
||||||
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
|
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
|
||||||
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
||||||
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DiceBar to={dest} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
|
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="lobbychat card" aria-label="Chat">
|
<aside className="lobbychat card" aria-label="Chat">
|
||||||
|
|||||||
@@ -124,8 +124,9 @@ export function compute(ch, rules) {
|
|||||||
const pips = profPip + freePips
|
const pips = profPip + freePips
|
||||||
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
|
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
|
||||||
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
|
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
|
||||||
|
const base = s.type === 'advanced' ? Math.floor((best * (cr.advanced_attr_percent ?? 100)) / 100) : best // advanced skills only use part of the attribute
|
||||||
const unlocked = ch.unlocked.includes(s.id)
|
const unlocked = ch.unlocked.includes(s.id)
|
||||||
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
|
return { ...s, points, prof, profPip, freePips, pips, unlocked, base, total: base + points + prof,
|
||||||
usable: s.type === 'basic' || unlocked || profPip > 0, // advanced skills must be unlocked (a profession proficiency does it for free)
|
usable: s.type === 'basic' || unlocked || profPip > 0, // advanced skills must be unlocked (a profession proficiency does it for free)
|
||||||
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
|
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
|
||||||
max: Math.floor(best / cr.skill_max_divisor) }
|
max: Math.floor(best / cr.skill_max_divisor) }
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function explainSkill(s, { ch, rules, c }) {
|
|||||||
const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ')
|
const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ')
|
||||||
const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0'
|
const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0'
|
||||||
const lines = [
|
const lines = [
|
||||||
`Target number = best attribute ${best} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
|
`Target number = ${s.type === 'advanced' && s.base !== best ? `${cr.advanced_attr_percent}% of the best attribute (${best}) = ${s.base}` : `best attribute ${best}`} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
|
||||||
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
|
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
|
||||||
]
|
]
|
||||||
if (s.type === 'advanced') {
|
if (s.type === 'advanced') {
|
||||||
|
|||||||
@@ -384,3 +384,15 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
|
|||||||
.tablelog { position: absolute; left: .6rem; bottom: .5rem; display: grid; gap: .25rem; max-width: 70%; }
|
.tablelog { position: absolute; left: .6rem; bottom: .5rem; display: grid; gap: .25rem; max-width: 70%; }
|
||||||
.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
|
.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
|
||||||
@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }
|
||||||
|
.stepper .step5 { font-size: .75rem; padding: .25rem .4rem; min-width: 2.2rem; }
|
||||||
|
|
||||||
|
/* rules editor */
|
||||||
|
.editor .edform { margin: .8rem 0; display: grid; gap: .6rem; }
|
||||||
|
.edgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .6rem .9rem; }
|
||||||
|
.edfield { display: grid; gap: .2rem; }
|
||||||
|
.edfield .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; padding: .3rem 0; max-height: 11rem; overflow: auto; }
|
||||||
|
.edtable td.actions { text-align: right; white-space: nowrap; }
|
||||||
|
.edtable tr.sel { background: var(--panel2); }
|
||||||
|
.editor .tabs { margin-top: .6rem; }
|
||||||
|
|
||||||
|
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user