Compare commits
2
Commits
d4d7b4d22e
...
1db8a45cae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db8a45cae | ||
|
|
57f262b6aa |
@@ -92,7 +92,7 @@ On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom i
|
|||||||
|
|
||||||
### Rules editor
|
### 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.
|
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. Admins can also promote other users: the **Users and admins** card above the tabs lists all accounts with *Make admin* / *Remove admin*. Names in `AW_ADMINS` stay admin and can only be changed in `.env`; the last admin cannot be removed.
|
||||||
|
|
||||||
- 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.
|
- 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.
|
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
|
||||||
@@ -116,6 +116,14 @@ The lobby page is built for desktop with three columns: the **character sheet**
|
|||||||
- **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold.
|
- **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold.
|
||||||
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
|
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
|
||||||
|
|
||||||
|
### NPCs, enemies and creatures
|
||||||
|
|
||||||
|
The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as the character creator (attributes, skills, talents, mutations, equipment, portrait), with a free "NPC / creature" preset that has large pools and no profession requirement, plus a type (NPC, enemy, monster, creature, animal, robot), a default token size, a description, special abilities and attacks, and loot.
|
||||||
|
|
||||||
|
- NPCs are stored like characters (flag `npc`) and are listed separately; they can be duplicated and shared with other DMs (read-only).
|
||||||
|
- In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works.
|
||||||
|
- The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet.
|
||||||
|
|
||||||
### Maps, tokens and line of sight
|
### Maps, tokens and line of sight
|
||||||
|
|
||||||
- **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map.
|
- **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map.
|
||||||
@@ -135,7 +143,10 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
|||||||
| GET | `/api/auth/me` | Current user (or `null`) |
|
| GET | `/api/auth/me` | Current user (or `null`) |
|
||||||
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
|
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
|
||||||
| GET | `/api/rules` | Rule data including presets |
|
| GET | `/api/rules` | Rule data including presets |
|
||||||
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
|
| GET / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one |
|
||||||
|
| GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) |
|
||||||
|
| GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) |
|
||||||
|
| GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) |
|
||||||
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
|
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
|
||||||
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
|
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
|
||||||
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
|
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
|
||||||
|
|||||||
+115
-28
@@ -103,6 +103,10 @@ def db() -> sqlite3.Connection:
|
|||||||
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
|
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
|
||||||
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||||
|
if "is_admin" not in [r["name"] for r in con.execute("PRAGMA table_info(users)")]:
|
||||||
|
con.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if "npc" not in [r["name"] for r in con.execute("PRAGMA table_info(characters)")]:
|
||||||
|
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
|
||||||
maps_mod.schema(con)
|
maps_mod.schema(con)
|
||||||
sounds_mod.schema(con)
|
sounds_mod.schema(con)
|
||||||
for table in ("characters", "presets"): # older databases: add the owner column
|
for table in ("characters", "presets"): # older databases: add the owner column
|
||||||
@@ -188,14 +192,21 @@ async def security(request: Request, call_next):
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user: dict) -> bool:
|
def admin_ids(con) -> set:
|
||||||
"""Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account."""
|
"""Admins: the names in AW_ADMINS, accounts an admin promoted, and (when there is neither) the very first account."""
|
||||||
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
||||||
if names:
|
ids = {r["id"] for r in con.execute("SELECT id, username FROM users") if r["username"].lower() in names}
|
||||||
return user["username"].lower() in names
|
ids |= {r["id"] for r in con.execute("SELECT id FROM users WHERE is_admin=1")}
|
||||||
with db() as con:
|
if not ids:
|
||||||
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||||||
return bool(first) and first["id"] == user["id"]
|
if first:
|
||||||
|
ids.add(first["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user: dict) -> bool:
|
||||||
|
with db() as con:
|
||||||
|
return user["id"] in admin_ids(con)
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request) -> dict:
|
def current_user(request: Request) -> dict:
|
||||||
@@ -366,23 +377,35 @@ def _name(data: str) -> str:
|
|||||||
return json.loads(data).get("name") or "Nameless"
|
return json.loads(data).get("name") or "Nameless"
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(r, shares=None, owner=None) -> dict:
|
||||||
|
ch = json.loads(r["data"])
|
||||||
|
e = {"id": r["id"], "name": ch.get("name") or "Nameless", "updated": r["updated"], "portrait": portrait_mod.tag(ch.get("portrait"))}
|
||||||
|
if r["npc"]:
|
||||||
|
e["kind"] = str(ch.get("npc_kind") or "")
|
||||||
|
e["size"] = ch.get("token_size", 1)
|
||||||
|
if shares is not None:
|
||||||
|
e["shared_with"] = shares.get(r["id"], [])
|
||||||
|
if owner is not None:
|
||||||
|
e["owner"] = owner or "?"
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
@api.get("/characters")
|
@api.get("/characters")
|
||||||
def my_characters(user: dict = Depends(current_user)):
|
def my_characters(npc: int = 0, user: dict = Depends(current_user)):
|
||||||
|
"""Your characters and the ones shared with you. With ?npc=1 the NPC library (NPCs, enemies, creatures) instead."""
|
||||||
|
flag = 1 if npc else 0
|
||||||
with db() as con:
|
with db() as con:
|
||||||
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC",
|
rows = con.execute("SELECT id, data, updated, npc FROM characters WHERE owner_id=? AND npc=? ORDER BY updated DESC",
|
||||||
(user["id"],)).fetchall()
|
(user["id"], flag)).fetchall()
|
||||||
shares: dict = {}
|
shares: dict = {}
|
||||||
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
|
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
|
||||||
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""",
|
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? AND c.npc=? ORDER BY u.username""",
|
||||||
(user["id"],)):
|
(user["id"], flag)):
|
||||||
shares.setdefault(r["character_id"], []).append(r["username"])
|
shares.setdefault(r["character_id"], []).append(r["username"])
|
||||||
shared = con.execute("""SELECT c.id, c.data, c.updated, o.username AS owner FROM shares s
|
shared = con.execute("""SELECT c.id, c.data, c.updated, c.npc, o.username AS owner FROM shares s
|
||||||
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
|
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
|
||||||
WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall()
|
WHERE s.user_id=? AND c.npc=? ORDER BY c.updated DESC""", (user["id"], flag)).fetchall()
|
||||||
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])}
|
return {"mine": [_entry(r, shares=shares) for r in rows], "shared": [_entry(r, owner=r["owner"]) for r in shared]}
|
||||||
for r in rows],
|
|
||||||
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
|
|
||||||
for r in shared]}
|
|
||||||
|
|
||||||
|
|
||||||
@api.post("/characters")
|
@api.post("/characters")
|
||||||
@@ -393,20 +416,39 @@ def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
|
|||||||
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
|
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
|
||||||
raise HTTPException(409, "Character limit reached")
|
raise HTTPException(409, "Character limit reached")
|
||||||
cid, now = uuid.uuid4().hex[:12], time.time()
|
cid, now = uuid.uuid4().hex[:12], time.time()
|
||||||
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
|
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id, npc) VALUES (?,?,?,?,?,?,?)",
|
||||||
(cid, "", json.dumps(ch), now, now, user["id"]))
|
(cid, "", json.dumps(ch), now, now, user["id"], 1 if ch.get("npc") else 0))
|
||||||
return {"id": cid}
|
return {"id": cid}
|
||||||
|
|
||||||
|
|
||||||
|
def _can_read_character(con, cid: str, user: dict) -> bool:
|
||||||
|
"""The owner, users the character is shared with, the DM of a lobby it plays in, and the DM of a lobby with a token of this NPC."""
|
||||||
|
row = con.execute("SELECT owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||||
|
return bool(row) and bool(row["owner_id"] == user["id"] or con.execute(
|
||||||
|
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
|
||||||
|
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
|
||||||
|
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone() or con.execute(
|
||||||
|
"""SELECT 1 FROM lobby_tokens t JOIN lobbies l ON l.id = t.lobby_id
|
||||||
|
WHERE t.npc_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/characters/{cid}/portrait")
|
||||||
|
def character_portrait(cid: str, user: dict = Depends(current_user)):
|
||||||
|
with db() as con:
|
||||||
|
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
|
||||||
|
allowed = _can_read_character(con, cid, user)
|
||||||
|
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row and allowed else None
|
||||||
|
if not img:
|
||||||
|
raise HTTPException(404, "No portrait")
|
||||||
|
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
|
||||||
|
|
||||||
|
|
||||||
@api.get("/characters/{cid}")
|
@api.get("/characters/{cid}")
|
||||||
def read_character(cid: str, user: dict = Depends(current_user)):
|
def read_character(cid: str, user: dict = Depends(current_user)):
|
||||||
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
|
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
|
||||||
with db() as con:
|
with db() as con:
|
||||||
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||||
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
|
allowed = _can_read_character(con, cid, user)
|
||||||
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
|
|
||||||
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
|
|
||||||
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise HTTPException(404, "Character not found")
|
raise HTTPException(404, "Character not found")
|
||||||
return json.loads(row["data"])
|
return json.loads(row["data"])
|
||||||
@@ -443,7 +485,7 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
|
|||||||
check_size(ch)
|
check_size(ch)
|
||||||
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=?, npc=? WHERE id=?", (json.dumps(ch), time.time(), 1 if ch.get("npc") else 0, cid))
|
||||||
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
||||||
for lid in lobbies:
|
for lid in lobbies:
|
||||||
hub.notify(lid, "sheet", cid=cid)
|
hub.notify(lid, "sheet", cid=cid)
|
||||||
@@ -485,7 +527,7 @@ def _new_code(con) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _own_character(con, cid, user: dict):
|
def _own_character(con, cid, user: dict):
|
||||||
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=?", (cid, user["id"])).fetchone()
|
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=? AND npc=0", (cid, user["id"])).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(422, "Pick one of your own characters")
|
raise HTTPException(422, "Pick one of your own characters")
|
||||||
|
|
||||||
@@ -810,6 +852,14 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
|
|||||||
lobby = _lobby_for(con, lid, user)
|
lobby = _lobby_for(con, lid, user)
|
||||||
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
|
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
|
||||||
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
|
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
|
||||||
|
npc_name = ""
|
||||||
|
if body.get("npc_token"): # the DM rolls for an NPC on the table
|
||||||
|
_dm_only(lobby, user)
|
||||||
|
t = con.execute("""SELECT t.name, c.data FROM lobby_tokens t JOIN characters c ON c.id = t.npc_id
|
||||||
|
WHERE t.id=? AND t.lobby_id=?""", (str(body["npc_token"]), lid)).fetchone()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "This token has no NPC sheet")
|
||||||
|
member, npc_name = {"char_mod": 0, "data": t["data"]}, t["name"]
|
||||||
if not member or not member["data"]:
|
if not member or not member["data"]:
|
||||||
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
|
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
|
||||||
c = compute(json.loads(member["data"]), rules)
|
c = compute(json.loads(member["data"]), rules)
|
||||||
@@ -819,12 +869,12 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
|
|||||||
raise HTTPException(404, "Unknown skill")
|
raise HTTPException(404, "Unknown skill")
|
||||||
if not s["usable"]:
|
if not s["usable"]:
|
||||||
raise HTTPException(422, "This skill is still locked")
|
raise HTTPException(422, "This skill is still locked")
|
||||||
what, base = s["name"], s["total"]
|
what, base = (f"{npc_name}: " if npc_name else "") + s["name"], s["total"]
|
||||||
elif kind == "attr":
|
elif kind == "attr":
|
||||||
a = next((x for x in rules["attributes"] if x["id"] == key), None)
|
a = next((x for x in rules["attributes"] if x["id"] == key), None)
|
||||||
if not a:
|
if not a:
|
||||||
raise HTTPException(404, "Unknown attribute")
|
raise HTTPException(404, "Unknown attribute")
|
||||||
what, base = a["name"], c["attrs"][key]
|
what, base = (f"{npc_name}: " if npc_name else "") + a["name"], c["attrs"][key]
|
||||||
else:
|
else:
|
||||||
raise HTTPException(422, "Roll a skill or an attribute")
|
raise HTTPException(422, "Roll a skill or an attribute")
|
||||||
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
|
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
|
||||||
@@ -975,6 +1025,43 @@ def admin_user(user: dict = Depends(current_user)) -> dict:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/admin/users")
|
||||||
|
def admin_users(user: dict = Depends(admin_user)):
|
||||||
|
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
||||||
|
with db() as con:
|
||||||
|
ids = admin_ids(con)
|
||||||
|
rows = con.execute("SELECT id, username, created FROM users ORDER BY username COLLATE NOCASE").fetchall()
|
||||||
|
return [{"username": r["username"], "admin": r["id"] in ids, "fixed": r["username"].lower() in names, "me": r["id"] == user["id"],
|
||||||
|
"created": r["created"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/admin/users/{username}")
|
||||||
|
def set_admin(username: str, body: dict = Body(...), user: dict = Depends(admin_user)):
|
||||||
|
"""Make a user an admin or take it away again. Names listed in AW_ADMINS can only be changed in the server settings."""
|
||||||
|
want = bool(body.get("admin"))
|
||||||
|
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
||||||
|
with db() as con:
|
||||||
|
target = con.execute("SELECT id, username FROM users WHERE username=?", (username,)).fetchone()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "No user with this name")
|
||||||
|
ids = admin_ids(con)
|
||||||
|
if want:
|
||||||
|
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")) and not names: # the implicit first-account admin becomes explicit
|
||||||
|
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
|
||||||
|
con.execute("UPDATE users SET is_admin=1 WHERE id=?", (target["id"],))
|
||||||
|
else:
|
||||||
|
if target["username"].lower() in names:
|
||||||
|
raise HTTPException(409, "This admin is set in AW_ADMINS on the server and can only be removed there")
|
||||||
|
if ids - {target["id"]} == set():
|
||||||
|
raise HTTPException(409, "There must be at least one admin")
|
||||||
|
if target["id"] not in ids:
|
||||||
|
return {"ok": True}
|
||||||
|
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")): # implicit first account: make it explicit first
|
||||||
|
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
|
||||||
|
con.execute("UPDATE users SET is_admin=0 WHERE id=?", (target["id"],))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@api.get("/admin/rules")
|
@api.get("/admin/rules")
|
||||||
def editor_state(user: dict = Depends(admin_user)):
|
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."""
|
"""The editable sections as they are now, and which of them differ from the shipped rules.json."""
|
||||||
@@ -1036,7 +1123,7 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps")
|
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character)
|
||||||
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
|
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+45
-12
@@ -16,7 +16,7 @@ from fastapi.responses import Response
|
|||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from . import vision
|
from . import vision
|
||||||
from .portrait import tag as portrait_tag
|
from .portrait import image as portrait_image, tag as portrait_tag
|
||||||
|
|
||||||
MAX_UPLOAD = 12_000_000 # bytes of the uploaded file
|
MAX_UPLOAD = 12_000_000 # bytes of the uploaded file
|
||||||
MAX_SIDE = 4096 # larger images are scaled down
|
MAX_SIDE = 4096 # larger images are scaled down
|
||||||
@@ -36,6 +36,8 @@ def schema(con):
|
|||||||
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER, name TEXT NOT NULL, x REAL, y REAL,
|
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER, name TEXT NOT NULL, x REAL, y REAL,
|
||||||
color TEXT, size REAL NOT NULL DEFAULT 1, hidden INTEGER NOT NULL DEFAULT 0)""")
|
color TEXT, size REAL NOT NULL DEFAULT 1, hidden INTEGER NOT NULL DEFAULT 0)""")
|
||||||
con.execute("CREATE INDEX IF NOT EXISTS lobby_tokens_lobby ON lobby_tokens (lobby_id)")
|
con.execute("CREATE INDEX IF NOT EXISTS lobby_tokens_lobby ON lobby_tokens (lobby_id)")
|
||||||
|
if "npc_id" not in [r["name"] for r in con.execute("PRAGMA table_info(lobby_tokens)")]:
|
||||||
|
con.execute("ALTER TABLE lobby_tokens ADD COLUMN npc_id TEXT")
|
||||||
con.execute("CREATE TABLE IF NOT EXISTS map_shares (map_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (map_id, user_id))")
|
con.execute("CREATE TABLE IF NOT EXISTS map_shares (map_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (map_id, user_id))")
|
||||||
for table, col, ddl in (("lobbies", "map_id", "TEXT"), ("lobbies", "doors", "TEXT NOT NULL DEFAULT '{}'"),
|
for table, col, ddl in (("lobbies", "map_id", "TEXT"), ("lobbies", "doors", "TEXT NOT NULL DEFAULT '{}'"),
|
||||||
("lobbies", "los", "INTEGER NOT NULL DEFAULT 1"), ("maps", "sight", "REAL NOT NULL DEFAULT 0")):
|
("lobbies", "los", "INTEGER NOT NULL DEFAULT 1"), ("maps", "sight", "REAL NOT NULL DEFAULT 0")):
|
||||||
@@ -81,8 +83,10 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
|
|||||||
if (x is None) != (y is None):
|
if (x is None) != (y is None):
|
||||||
x = y = None
|
x = y = None
|
||||||
tid = str(t.get("id", "")) if keep_id else ""
|
tid = str(t.get("id", "")) if keep_id else ""
|
||||||
|
npc = str(t.get("npc_id") or "")
|
||||||
return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y,
|
return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y,
|
||||||
"size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden"))}
|
"size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden")),
|
||||||
|
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else ""}
|
||||||
|
|
||||||
|
|
||||||
def clean_walls(raw, w: int, h: int) -> list:
|
def clean_walls(raw, w: int, h: int) -> list:
|
||||||
@@ -105,7 +109,7 @@ def clean_walls(raw, w: int, h: int) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path):
|
def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path, can_read):
|
||||||
store_dir.mkdir(parents=True, exist_ok=True)
|
store_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def path(mid: str) -> Path:
|
def path(mid: str) -> Path:
|
||||||
@@ -171,7 +175,18 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
def read_map(mid: str, user: dict = Depends(current_user)):
|
def read_map(mid: str, user: dict = Depends(current_user)):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
r = own_map(con, mid, user)
|
r = own_map(con, mid, user)
|
||||||
return {**meta(r), "tokens": json.loads(r["tokens"]), "walls": json.loads(r["walls"])}
|
tokens = json.loads(r["tokens"])
|
||||||
|
for t in tokens: # NPC tokens: tell the editor whether the NPC has a portrait
|
||||||
|
t["portrait"] = ""
|
||||||
|
if t.get("npc_id") and can_read(con, t["npc_id"], user):
|
||||||
|
row = con.execute("SELECT data FROM characters WHERE id=?", (t["npc_id"],)).fetchone()
|
||||||
|
try:
|
||||||
|
t["portrait"] = portrait_tag(json.loads(row["data"]).get("portrait")) if row else ""
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
t["npc_id"] = ""
|
||||||
|
return {**meta(r), "tokens": tokens, "walls": json.loads(r["walls"])}
|
||||||
|
|
||||||
@api.put("/maps/{mid}")
|
@api.put("/maps/{mid}")
|
||||||
def update_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
def update_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
@@ -330,7 +345,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
|
|
||||||
def tokens_for(con, lobby, user, ctx) -> list:
|
def tokens_for(con, lobby, user, ctx) -> list:
|
||||||
sync_players(con, lobby["id"])
|
sync_players(con, lobby["id"])
|
||||||
rows = con.execute("""SELECT t.*, u.username, m.color AS mcolor, c.data FROM lobby_tokens t
|
rows = con.execute("""SELECT t.*, u.username, m.color AS mcolor, c.data, n.data AS ndata FROM lobby_tokens t
|
||||||
|
LEFT JOIN characters n ON n.id = t.npc_id
|
||||||
LEFT JOIN users u ON u.id = t.user_id
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
LEFT JOIN lobby_members m ON m.lobby_id = t.lobby_id AND m.user_id = t.user_id
|
LEFT JOIN lobby_members m ON m.lobby_id = t.lobby_id AND m.user_id = t.user_id
|
||||||
LEFT JOIN characters c ON c.id = m.character_id
|
LEFT JOIN characters c ON c.id = m.character_id
|
||||||
@@ -352,15 +368,17 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
name = r["username"]
|
name = r["username"]
|
||||||
pt = ""
|
pt = ""
|
||||||
if player and r["data"]:
|
src = r["data"] if player else r["ndata"]
|
||||||
|
if src:
|
||||||
try:
|
try:
|
||||||
pt = portrait_tag(json.loads(r["data"]).get("portrait"))
|
pt = portrait_tag(json.loads(src).get("portrait"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt,
|
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt,
|
||||||
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
|
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
|
||||||
"color": (r["mcolor"] or "#999999") if player else r["color"],
|
"color": (r["mcolor"] or "#999999") if player else r["color"],
|
||||||
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None))})
|
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None)),
|
||||||
|
**({"npc_id": r["npc_id"]} if full and r["npc_id"] else {})})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@api.get("/lobbies/{lid}/map")
|
@api.get("/lobbies/{lid}/map")
|
||||||
@@ -434,8 +452,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
if mid:
|
if mid:
|
||||||
r = usable_map(con, str(mid), user)
|
r = usable_map(con, str(mid), user)
|
||||||
for t in json.loads(r["tokens"]):
|
for t in json.loads(r["tokens"]):
|
||||||
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)",
|
npc = t.get("npc_id") if t.get("npc_id") and can_read(con, t["npc_id"], user) else None # only NPCs this DM may open
|
||||||
(uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"])))
|
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc))
|
||||||
sync_players(con, lid)
|
sync_players(con, lid)
|
||||||
event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
|
event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
|
||||||
else:
|
else:
|
||||||
@@ -459,8 +478,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
if con.execute("SELECT COUNT(*) FROM lobby_tokens WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TOKENS:
|
if con.execute("SELECT COUNT(*) FROM lobby_tokens WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TOKENS:
|
||||||
raise HTTPException(409, "Too many tokens")
|
raise HTTPException(409, "Too many tokens")
|
||||||
t = clean_token(body, m["w"], m["h"], keep_id=False)
|
t = clean_token(body, m["w"], m["h"], keep_id=False)
|
||||||
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)",
|
npc = t["npc_id"] if t["npc_id"] and can_read(con, t["npc_id"], user) else None
|
||||||
(t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"])))
|
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc))
|
||||||
hub.notify(lid, "map")
|
hub.notify(lid, "map")
|
||||||
return {"id": t["id"]}
|
return {"id": t["id"]}
|
||||||
|
|
||||||
@@ -499,4 +519,17 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
hub.notify(lid, "map")
|
hub.notify(lid, "map")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.get("/lobbies/{lid}/token-portrait/{tid}")
|
||||||
|
def token_portrait(lid: str, tid: str, user: dict = Depends(current_user)):
|
||||||
|
"""Portrait of an NPC token for the lobby's members (hidden tokens only for the DM)."""
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
t = con.execute("SELECT t.hidden, n.data FROM lobby_tokens t JOIN characters n ON n.id = t.npc_id WHERE t.id=? AND t.lobby_id=?", (tid, lid)).fetchone()
|
||||||
|
if not t or (t["hidden"] and lobby["owner_id"] != user["id"]):
|
||||||
|
raise HTTPException(404, "No portrait")
|
||||||
|
img = portrait_image(json.loads(t["data"]).get("portrait"))
|
||||||
|
if not img:
|
||||||
|
raise HTTPException(404, "No portrait")
|
||||||
|
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
|
||||||
|
|
||||||
return sync_players
|
return sync_players
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
{"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}},
|
{"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}},
|
||||||
{"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}},
|
{"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}},
|
||||||
{"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}},
|
{"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}},
|
||||||
|
{"id": "npc", "name": "NPC / creature (free)", "desc": "Used by the NPC editor: huge pools, no limits.", "npc": true, "creation": {"attr_points": 500, "attr_max_raise": 100, "attr_max": 150, "skill_points_default": 1000, "talents_min": 0, "talents_max": 10, "free_proficiencies": 20, "advanced_unlock_cost": 0}},
|
||||||
{"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}}
|
{"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function App() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
// Used to load a saved character into the creator
|
// Used to load a saved character into the creator
|
||||||
const [loadRequest, setLoadRequest] = useState(null)
|
const [loadRequest, setLoadRequest] = useState(null)
|
||||||
|
const [npcLoad, setNpcLoad] = useState(null)
|
||||||
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
|
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
|
||||||
|
|
||||||
// Ask the server who we are; any later 401 (session expired) sends us back to the login
|
// Ask the server who we are; any later 401 (session expired) sends us back to the login
|
||||||
@@ -55,7 +56,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'], ['maps', 'Maps'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
|
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['npcs', 'NPCs'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -81,6 +82,11 @@ export default function App() {
|
|||||||
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
||||||
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
||||||
)}
|
)}
|
||||||
|
{rules && page === 'npcs' && (
|
||||||
|
<Characters npc onOpen={(entry) => { setNpcLoad(entry); window.location.hash = 'npc' }}
|
||||||
|
onNew={() => { setNpcLoad({ new: true }); window.location.hash = 'npc' }} />
|
||||||
|
)}
|
||||||
|
{rules && page === 'npc' && <Creator npc rules={rules} user={username} loadRequest={npcLoad} clearLoad={() => setNpcLoad(null)} onName={() => {}} />}
|
||||||
{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' }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+28
-11
@@ -19,7 +19,7 @@ function SharePanel({ c, reload }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sharepanel">
|
<div className="sharepanel">
|
||||||
<p className="small muted">These users can view this character (read-only), for example your DM:</p>
|
<p className="small muted">These users can view this entry (read-only), for example another DM:</p>
|
||||||
<div className="chips">
|
<div className="chips">
|
||||||
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
|
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
|
||||||
{c.shared_with.map((u) => (
|
{c.shared_with.map((u) => (
|
||||||
@@ -35,12 +35,13 @@ function SharePanel({ c, reload }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Characters({ onOpen, onNew }) {
|
export default function Characters({ onOpen, onNew, npc = false }) {
|
||||||
|
const what = npc ? 'NPC' : 'character'
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [open, setOpen] = useState(null) // id of the character whose share panel is open
|
const [open, setOpen] = useState(null) // id of the character whose share panel is open
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.'))
|
const load = () => fetch(npc ? '/api/characters?npc=1' : '/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load the list.'))
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
const remove = async (c) => {
|
const remove = async (c) => {
|
||||||
@@ -49,32 +50,46 @@ export default function Characters({ onOpen, onNew }) {
|
|||||||
if (!r || !r.ok) return setErr('Deleting failed.')
|
if (!r || !r.ok) return setErr('Deleting failed.')
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
const copy = async (c) => {
|
||||||
|
const r = await fetch(`/api/characters/${c.id}`).catch(() => null)
|
||||||
|
if (!r || !r.ok) return setErr('Duplicating failed.')
|
||||||
|
const data = await r.json()
|
||||||
|
const p = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, name: `${data.name || 'NPC'} (copy)` }) }).catch(() => null)
|
||||||
|
if (!p || !p.ok) return setErr('Duplicating failed.')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
const kindName = (k) => ({ npc: 'NPC', enemy: 'Enemy', monster: 'Monster', creature: 'Creature', animal: 'Animal', robot: 'Robot / machine' })[k] || ''
|
||||||
|
const thumb = (c) => c.portrait ? <img className="npcthumb" src={`/api/characters/${c.id}/portrait?v=${c.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">{npc ? '?' : ''}</span>
|
||||||
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
|
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page narrow">
|
<section className="page narrow">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<h1>My characters</h1>
|
<h1>{npc ? 'NPCs and creatures' : 'My characters'}</h1>
|
||||||
<button onClick={onNew}>+ New character</button>
|
<button onClick={onNew}>{npc ? '+ New NPC' : '+ New character'}</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
These characters are saved in your account. Only you and the users you share a character with can see it.
|
{npc ? 'Your library of NPCs, enemies, monsters and creatures. Place them as tokens in the map editor or at the table; a DM sees their sheet by clicking the token. You can share NPCs with other DMs.'
|
||||||
Shared users can look at it but not change it.
|
: 'These characters are saved in your account. Only you and the users you share a character with can see it. Shared users can look at it but not change it.'}
|
||||||
</p>
|
</p>
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "New character", then click "Save" on the Summary page.</p>}
|
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "{npc ? 'New NPC' : 'New character'}", then click "{npc ? 'Save NPC' : 'Save'}" on the Summary page.</p>}
|
||||||
<ul className="charlist">
|
<ul className="charlist">
|
||||||
{(data?.mine || []).map((c) => (
|
{(data?.mine || []).map((c) => (
|
||||||
<li key={c.id} className="card">
|
<li key={c.id} className="card">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
<div className="npchead">
|
||||||
|
{thumb(c)}
|
||||||
<div>
|
<div>
|
||||||
<strong>{c.name}</strong>
|
<strong>{c.name}</strong>{npc && c.kind && <span className="badge">{kindName(c.kind)}</span>}
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
|
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button onClick={() => onOpen(c)}>Open</button>
|
<button onClick={() => onOpen(c)}>Open</button>
|
||||||
|
{npc && <button className="ghost" onClick={() => copy(c)}>Duplicate</button>}
|
||||||
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
|
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
|
||||||
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,10 +105,12 @@ export default function Characters({ onOpen, onNew }) {
|
|||||||
<ul className="charlist">
|
<ul className="charlist">
|
||||||
{data.shared.map((c) => (
|
{data.shared.map((c) => (
|
||||||
<li key={c.id} className="card row">
|
<li key={c.id} className="card row">
|
||||||
<div>
|
<div className="npchead">
|
||||||
<strong>{c.name}</strong>
|
{thumb(c)}
|
||||||
|
<div><strong>{c.name}</strong>
|
||||||
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
|
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
|
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+39
-16
@@ -14,9 +14,13 @@ function readDraft(key) {
|
|||||||
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
|
const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
|
||||||
const DRAFT = `aw_draft:${user}` // drafts are kept per account
|
|
||||||
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) {
|
||||||
|
const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account
|
||||||
|
const freshCharacter = () => npc
|
||||||
|
? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' }
|
||||||
|
: applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||||
const [ch, setCh] = useState(() => {
|
const [ch, setCh] = useState(() => {
|
||||||
const d = readDraft(DRAFT)
|
const d = readDraft(DRAFT)
|
||||||
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
||||||
@@ -58,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
if (!loadRequest) return
|
if (!loadRequest) return
|
||||||
if (loadRequest.new) { // "New character" button on the My characters page
|
if (loadRequest.new) { // "New character" button on the My characters page
|
||||||
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
|
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
|
||||||
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) {
|
if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
|
||||||
setBoth(freshCharacter()); setSaved(null); setStep(0)
|
setBoth(freshCharacter()); setSaved(null); setStep(0)
|
||||||
}
|
}
|
||||||
clearLoad()
|
clearLoad()
|
||||||
@@ -74,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
// Direct link #creator/<id> (read only; saving creates a copy)
|
// Direct link #creator/<id> (read only; saving creates a copy)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.location.hash.split('/')[1]
|
const id = window.location.hash.split('/')[1]
|
||||||
if (!id || loadRequest) return
|
if (!id || loadRequest || npc) return
|
||||||
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
||||||
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
|
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
|
||||||
.catch(() => setMsg('Character not found, or it is not shared with you.'))
|
.catch(() => setMsg('Character not found, or it is not shared with you.'))
|
||||||
@@ -94,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
|
|
||||||
const resetAll = () => {
|
const resetAll = () => {
|
||||||
if (!window.confirm('Discard all input and start over?')) return
|
if (!window.confirm('Discard all input and start over?')) return
|
||||||
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0)
|
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0)
|
||||||
}
|
}
|
||||||
const resetThisStep = () => {
|
const resetThisStep = () => {
|
||||||
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
|
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
|
||||||
@@ -154,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
const stepConcept = (
|
const stepConcept = (
|
||||||
<>
|
<>
|
||||||
<h2>Concept</h2>
|
<h2>Concept</h2>
|
||||||
|
{npc && (
|
||||||
<div className="card presetbox">
|
<div className="card presetbox">
|
||||||
|
<div className="grid2">
|
||||||
|
<label>Type
|
||||||
|
<select value={ch.npc_kind || 'npc'} onChange={(e) => set({ npc_kind: e.target.value })}>
|
||||||
|
{NPC_KINDS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Token size (grid squares)
|
||||||
|
<input type="number" min="1" max="8" value={ch.token_size || 1} onChange={(e) => set({ token_size: Math.max(1, Math.min(8, Number(e.target.value) || 1)) })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="small muted">NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!npc && <div className="card presetbox">
|
||||||
<label>Rules preset
|
<label>Rules preset
|
||||||
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
|
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
|
||||||
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{(baseRules.presets || []).filter((p) => !p.npc).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
{ch.preset === 'custom' && <option value="custom">Custom</option>}
|
{ch.preset === 'custom' && <option value="custom">Custom</option>}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -167,11 +186,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
|
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
|
||||||
})()}
|
})()}
|
||||||
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
|
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
|
||||||
</div>
|
</div>}
|
||||||
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div>
|
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div>
|
||||||
<div className="grid2">
|
<div className="grid2">
|
||||||
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
<label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
||||||
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
|
{!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid2">
|
<div className="grid2">
|
||||||
<label>Class (optional)
|
<label>Class (optional)
|
||||||
@@ -189,7 +208,9 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
</div>
|
</div>
|
||||||
{sub && <p className="small muted">{sub.desc}</p>}
|
{sub && <p className="small muted">{sub.desc}</p>}
|
||||||
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
|
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
|
||||||
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
<label>{npc ? 'Description' : 'Background'}<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
||||||
|
{npc && <label>Special abilities and attacks<textarea rows="5" value={ch.abilities || ''} onChange={(e) => set({ abilities: e.target.value })} placeholder="e.g. Bite 2d6, poison spit (range 10, VIT check or poisoned), regenerates 2 HP per round" /></label>}
|
||||||
|
{npc && <label>Loot and drops<textarea rows="3" value={ch.loot || ''} onChange={(e) => set({ loot: e.target.value })} placeholder="e.g. 2 scrap, hide, rusty knife" /></label>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -419,6 +440,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
)
|
)
|
||||||
|
|
||||||
const warnings = []
|
const warnings = []
|
||||||
|
if (npc) { if (c.attrLeft < 0) warnings.push('Too many attribute points spent.'); if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.') } else {
|
||||||
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
|
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
|
||||||
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
|
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
|
||||||
if (!ch.profession) warnings.push('No profession chosen.')
|
if (!ch.profession) warnings.push('No profession chosen.')
|
||||||
@@ -432,6 +454,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
||||||
if (over) warnings.push('Augmentation Capacity exceeded.')
|
if (over) warnings.push('Augmentation Capacity exceeded.')
|
||||||
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
||||||
|
}
|
||||||
|
|
||||||
const gearLines = kitItems(ch, rules)
|
const gearLines = kitItems(ch, rules)
|
||||||
const stepSummary = (
|
const stepSummary = (
|
||||||
@@ -440,14 +463,14 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
||||||
<PaperSheet data={ch} baseRules={baseRules} />
|
<PaperSheet data={ch} baseRules={baseRules} />
|
||||||
<div className="actions wrap">
|
<div className="actions wrap">
|
||||||
<button onClick={() => download('pdf')}>Download as PDF</button>
|
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
|
||||||
<button onClick={() => download('docx')}>Download as Word</button>
|
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
|
||||||
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
|
<button className="ghost" onClick={save}>{saved ? 'Save changes' : npc ? 'Save NPC' : 'Save on server'}</button>
|
||||||
<button className="ghost" onClick={exportJson}>JSON backup</button>
|
<button className="ghost" onClick={exportJson}>JSON backup</button>
|
||||||
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
|
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
|
||||||
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
|
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
|
||||||
</div>
|
</div>
|
||||||
{saved && (
|
{saved && !npc && (
|
||||||
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
|
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -457,7 +480,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page creator">
|
<div className="page creator">
|
||||||
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>
|
{!npc && <p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>}
|
||||||
<ol className="steps">
|
<ol className="steps">
|
||||||
{STEPS.map((s, i) => (
|
{STEPS.map((s, i) => (
|
||||||
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
|
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
|
||||||
|
|||||||
@@ -65,6 +65,42 @@ function Field({ f, value, onChange }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admins can make other accounts admins (and take it away again)
|
||||||
|
function AdminUsers() {
|
||||||
|
const [users, setUsers] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const load = () => fetch('/api/admin/users').then((r) => (r.ok ? r.json() : Promise.reject())).then(setUsers).catch(() => setErr('Could not load the users.'))
|
||||||
|
useEffect(() => { if (open && !users) load() }, [open]) // eslint-disable-line
|
||||||
|
const set = async (u, admin) => {
|
||||||
|
setErr('')
|
||||||
|
const r = await fetch(`/api/admin/users/${encodeURIComponent(u.username)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ admin }) })
|
||||||
|
if (!r.ok) setErr((await r.json().catch(() => ({}))).detail || 'That did not work.')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="card adminusers">
|
||||||
|
<button className="linklike" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? '▾' : '▸'} Users and admins</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<p className="small muted">Admins can use this editor and make other users admins. Admins named in <code>AW_ADMINS</code> on the server can only be changed there.</p>
|
||||||
|
{err && <p className="warn small">{err}</p>}
|
||||||
|
<ul className="userlist">
|
||||||
|
{(users || []).map((u) => (
|
||||||
|
<li key={u.username} className="row">
|
||||||
|
<span><strong>{u.username}</strong>{u.me ? ' (you)' : ''} {u.admin && <span className="chip on">admin</span>}{u.fixed && <span className="muted small"> set on the server</span>}</span>
|
||||||
|
{u.admin
|
||||||
|
? <button className="ghost" disabled={u.fixed} onClick={() => window.confirm(`Take admin rights from ${u.username}?`) && set(u, false)}>Remove admin</button>
|
||||||
|
: <button className="ghost" onClick={() => window.confirm(`Make ${u.username} an admin? Admins can change the rules and make others admins.`) && set(u, true)}>Make admin</button>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Editor({ rules, onReload }) {
|
export default function Editor({ rules, onReload }) {
|
||||||
const [state, setState] = useState(null)
|
const [state, setState] = useState(null)
|
||||||
const [tab, setTab] = useState('equipment')
|
const [tab, setTab] = useState('equipment')
|
||||||
@@ -139,6 +175,7 @@ export default function Editor({ rules, onReload }) {
|
|||||||
<h1>Rules editor</h1>
|
<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.
|
<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>
|
Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p>
|
||||||
|
<AdminUsers />
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
{Object.entries(D).map(([id, x]) => (
|
{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 }) }}>
|
<button key={id} role="tab" aria-selected={tab === id} className={tab === id ? 'on' : ''} onClick={() => { setTab(id); setEditing(''); setQ(''); setMsg({ text: '', bad: false }) }}>
|
||||||
|
|||||||
+21
-12
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||||||
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
|
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
|
||||||
import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx'
|
import { MapView, MapLibrary, NpcPicker, TokenForm, WallLayer } from './MapView.jsx'
|
||||||
|
|
||||||
const J = { 'Content-Type': 'application/json' }
|
const J = { 'Content-Type': 'application/json' }
|
||||||
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||||
@@ -434,8 +434,7 @@ function CharBar({ lobby, sel, onSel }) {
|
|||||||
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
|
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
|
||||||
<span className="pavatar">{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
<span className="pavatar">{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
||||||
<span className="pinfo">
|
<span className="pinfo">
|
||||||
<b>{c ? c.name : 'No character'}</b>
|
<b title={m.username}>{c ? c.name : 'No character'}</b>
|
||||||
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
|
|
||||||
</span>
|
</span>
|
||||||
{lobby.is_dm
|
{lobby.is_dm
|
||||||
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
|
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
|
||||||
@@ -639,7 +638,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
map: () => sock.current.loadMap?.(),
|
map: () => sock.current.loadMap?.(),
|
||||||
messages: () => sock.current.poll?.(),
|
messages: () => sock.current.poll?.(),
|
||||||
lobby: () => sock.current.load?.(),
|
lobby: () => sock.current.load?.(),
|
||||||
sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })),
|
sheet: (n) => { setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })); sock.current.load?.(); sock.current.loadMap?.() }, // a saved character can bring a new name or portrait
|
||||||
})
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(); loadMap()
|
load(); loadMap()
|
||||||
@@ -681,6 +680,12 @@ function LobbyView({ id, rules, user }) {
|
|||||||
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
|
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
|
||||||
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null)
|
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null)
|
||||||
}
|
}
|
||||||
|
const [npcPick, setNpcPick] = useState(false)
|
||||||
|
const addNpcToken = (n) => {
|
||||||
|
const m = mapState.map
|
||||||
|
setNpcPick(false)
|
||||||
|
send(`/api/lobbies/${id}/tokens`, 'POST', { name: n.name, npc_id: n.id, x: m.w / 2, y: m.h / 2, size: n.size || 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
||||||
|
}
|
||||||
const addToken = () => {
|
const addToken = () => {
|
||||||
const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1
|
const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1
|
||||||
send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
||||||
@@ -693,6 +698,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
||||||
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
||||||
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
||||||
|
const npcTok = lobby.is_dm && !viewAs ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc' && t.npc_id) : null
|
||||||
const dest = people.includes(to) ? to : ''
|
const dest = people.includes(to) ? to : ''
|
||||||
const canCheck = !!(me && me.character_id)
|
const canCheck = !!(me && me.character_id)
|
||||||
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
|
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
|
||||||
@@ -709,13 +715,13 @@ function LobbyView({ id, rules, user }) {
|
|||||||
if (own) return setPending({ type: 'dice', sides, count })
|
if (own) return setPending({ type: 'dice', sides, count })
|
||||||
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
|
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
|
||||||
}
|
}
|
||||||
const check = (kind, cid, name) => {
|
const check = (kind, cid, name, npcToken) => {
|
||||||
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, npcToken })
|
||||||
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '' })
|
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '', ...(npcToken ? { npc_token: npcToken } : {}) })
|
||||||
}
|
}
|
||||||
const submitManual = (vals) => {
|
const submitManual = (vals) => {
|
||||||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '' }, () => setPending(null))
|
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '', ...(pending.npcToken ? { npc_token: pending.npcToken } : {}) }, () => 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +744,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
<div className="sheetbar">
|
<div className="sheetbar">
|
||||||
{lobby.is_dm ? (
|
{lobby.is_dm ? (
|
||||||
<span className="seg" role="tablist">
|
<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 === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{npcTok ? `: ${npcTok.name}` : cur ? `: ${cur.character.name}` : ''}</button>
|
||||||
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
|
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
|
||||||
</span>
|
</span>
|
||||||
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
|
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
|
||||||
@@ -746,6 +752,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
</div>
|
</div>
|
||||||
{lobby.is_dm && leftTab === 'lobby'
|
{lobby.is_dm && leftTab === 'lobby'
|
||||||
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
|
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
|
||||||
|
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
|
||||||
: 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} />
|
: 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>
|
||||||
@@ -753,7 +760,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
|
|
||||||
<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={(u) => { setSel(u); setLeftTab('sheet') }} />
|
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); 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 && !lobby.is_dm && <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)} />}
|
||||||
@@ -762,7 +769,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
|
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
|
||||||
const walls = full && showWalls ? mapState.walls || [] : []
|
const walls = full && showWalls ? mapState.walls || [] : []
|
||||||
return (
|
return (
|
||||||
<MapView map={mapState.map} image={mapImg} portraitUrl={(t) => t.portrait ? `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}` : null} tokens={mapState.tokens} grid={showGrid}
|
<MapView map={mapState.map} image={mapImg} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
|
||||||
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
|
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
|
||||||
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}>
|
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}>
|
||||||
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
|
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
|
||||||
@@ -776,8 +783,10 @@ function LobbyView({ id, rules, user }) {
|
|||||||
{lobby.is_dm && mapState.walls?.length > 0 && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'}</button>}
|
{lobby.is_dm && mapState.walls?.length > 0 && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'}</button>}
|
||||||
{full && mapState.walls?.length > 0 && <button className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
|
{full && mapState.walls?.length > 0 && <button className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
|
||||||
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
|
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
|
||||||
{full && <button className="ghost" onClick={addToken}>+ NPC</button>}
|
{full && <button className="ghost" onClick={addToken}>+ Token</button>}
|
||||||
|
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
|
||||||
</div>
|
</div>
|
||||||
|
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
|
||||||
{selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /></div>}
|
{selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /></div>}
|
||||||
</MapView>
|
</MapView>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -165,6 +165,30 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Picks an NPC from the library (own and shared). onPick(npc) gets {id, name, kind, size, portrait}.
|
||||||
|
export function NpcPicker({ onPick, onClose }) {
|
||||||
|
const [list, setList] = useState(null)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
useEffect(() => { fetch('/api/characters?npc=1').then((r) => r.json()).then((d) => setList([...d.mine, ...d.shared])).catch(() => setList([])) }, [])
|
||||||
|
const shown = (list || []).filter((n) => n.name.toLowerCase().includes(q.toLowerCase()))
|
||||||
|
return (
|
||||||
|
<div className="npcpicker card" role="dialog" aria-label="NPC library">
|
||||||
|
<div className="row"><strong>NPC library</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" aria-label="Search NPCs" />
|
||||||
|
<ul className="npcpick">
|
||||||
|
{list === null && <li className="muted small">Loading …</li>}
|
||||||
|
{list && !list.length && <li className="muted small">No NPCs yet. Create some on the <a href="#npcs">NPCs</a> page.</li>}
|
||||||
|
{shown.map((n) => (
|
||||||
|
<li key={n.id}><button className="ghost" onClick={() => onPick(n)}>
|
||||||
|
{n.portrait ? <img className="npcthumb" src={`/api/characters/${n.id}/portrait?v=${n.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">?</span>}
|
||||||
|
<span>{n.name}{n.owner ? <span className="muted small"> · {n.owner}</span> : null}</span>
|
||||||
|
</button></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const uid = () => Math.random().toString(36).slice(2, 12)
|
const uid = () => Math.random().toString(36).slice(2, 12)
|
||||||
|
|
||||||
// Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them.
|
// Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them.
|
||||||
@@ -229,6 +253,12 @@ export function MapEditor({ id, onClose }) {
|
|||||||
const cur = tokens.find((t) => t.id === sel)
|
const cur = tokens.find((t) => t.id === sel)
|
||||||
const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) }
|
const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) }
|
||||||
const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) }
|
const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) }
|
||||||
|
const [picker, setPicker] = useState(false)
|
||||||
|
const addNpc = (n) => {
|
||||||
|
const k = tokens.length + 1
|
||||||
|
const t = { id: uid(), kind: 'npc', can_move: true, name: n.name, npc_id: n.id, portrait: n.portrait || '', x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: n.size || 1, color: COLORS[0], hidden: false }
|
||||||
|
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPicker(false)
|
||||||
|
}
|
||||||
const add = () => {
|
const add = () => {
|
||||||
const n = tokens.length + 1
|
const n = tokens.length + 1
|
||||||
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
|
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
|
||||||
@@ -263,7 +293,7 @@ export function MapEditor({ id, onClose }) {
|
|||||||
setErr('')
|
setErr('')
|
||||||
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
|
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
|
||||||
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden }) => ({ id: i, name: n, x, y, size, color, hidden })) }) })
|
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id })) }) })
|
||||||
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
||||||
setDirty(false); return true
|
setDirty(false); return true
|
||||||
}
|
}
|
||||||
@@ -296,9 +326,10 @@ export function MapEditor({ id, onClose }) {
|
|||||||
<label className="small">Sight range in squares (0 = unlimited)
|
<label className="small">Sight range in squares (0 = unlimited)
|
||||||
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
|
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
|
||||||
<h4>Enemies and NPCs</h4>
|
<h4>Enemies and NPCs</h4>
|
||||||
<button className="on" onClick={add}>+ Add token</button>
|
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => setPicker(!picker)}>+ From NPC library</button></div>
|
||||||
|
{picker && <NpcPicker onPick={addNpc} onClose={() => setPicker(false)} />}
|
||||||
<ul className="tokenlist">
|
<ul className="tokenlist">
|
||||||
{tokens.map((t) => <li key={t.id}><button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}><i style={{ background: t.color }} /> {t.name}{t.hidden ? ' (hidden)' : ''}</button></li>)}
|
{tokens.map((t) => <li key={t.id}><button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}><i style={{ background: t.color }} /> {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button></li>)}
|
||||||
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
|
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
|
||||||
</ul>
|
</ul>
|
||||||
{cur && <TokenForm t={cur} onChange={(p) => patch(cur.id, p)}
|
{cur && <TokenForm t={cur} onChange={(p) => patch(cur.id, p)}
|
||||||
@@ -308,7 +339,7 @@ export function MapEditor({ id, onClose }) {
|
|||||||
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
|
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="mapstage">
|
<div className="mapstage">
|
||||||
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
|
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
|
||||||
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
|
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
|
||||||
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
|
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
|
||||||
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
|
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{data.npc && (data.abilities || data.loot || data.background) && (
|
||||||
|
<div className="psec pnpc">
|
||||||
|
{data.background && <><h3>Description</h3><p className="pre">{data.background}</p></>}
|
||||||
|
{data.abilities && <><h3>Abilities and attacks</h3><p className="pre">{data.abilities}</p></>}
|
||||||
|
{data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="psec pnotes"><h3>Conditions & notes</h3><div className="plines" /></div>
|
<div className="psec pnotes"><h3>Conditions & notes</h3><div className="plines" /></div>
|
||||||
<Foot n={1} />
|
<Foot n={1} />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function Presets({ rules, reload }) {
|
|||||||
const [sel, setSel] = useState(rules.default_preset || 'standard')
|
const [sel, setSel] = useState(rules.default_preset || 'standard')
|
||||||
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const presets = rules.presets || []
|
const presets = (rules.presets || []).filter((p) => !p.npc)
|
||||||
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
||||||
const editable = !!draft
|
const editable = !!draft
|
||||||
const own = !!current?.mine
|
const own = !!current?.mine
|
||||||
|
|||||||
@@ -480,3 +480,18 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
|
|||||||
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
|
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
|
||||||
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
|
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
|
||||||
|
.adminusers { margin: .8rem 0; }
|
||||||
|
.userlist { list-style: none; padding: 0; margin: .5rem 0 0; display: grid; gap: .4rem; }
|
||||||
|
.userlist li { justify-content: space-between; align-items: center; }
|
||||||
|
.chip.on { display: inline-block; }
|
||||||
|
|
||||||
|
.npchead { display: flex; align-items: center; gap: .7rem; }
|
||||||
|
.npcthumb { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; border: 1px solid var(--line, #444); flex: none; }
|
||||||
|
.npcthumb.none { display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,.05); color: var(--muted, #888); }
|
||||||
|
.npchead .badge { margin-left: .5rem; }
|
||||||
|
|
||||||
|
.npcpicker { display: grid; gap: .5rem; max-width: 320px; }
|
||||||
|
.npcpick { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; display: grid; gap: .25rem; }
|
||||||
|
.npcpick button { display: flex; align-items: center; gap: .6rem; width: 100%; text-align: left; }
|
||||||
|
.npcpickwrap { position: absolute; top: 3rem; left: .5rem; z-index: 5; }
|
||||||
|
.pnpc .pre { white-space: pre-wrap; margin: .1rem 0 .6rem; font-size: .85rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user