Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc0a1ba37e | ||
|
|
87caa94a1a | ||
|
|
c1443424c7 | ||
|
|
52ba33d232 | ||
|
|
111dc1232d | ||
|
|
219e4bf347 | ||
|
|
7a927e21a3 | ||
|
|
d4d8cae00f | ||
|
|
9052218927 | ||
|
|
bdf676f1dd | ||
|
|
b77e5eaa4f | ||
|
|
8b8f67c1d0 | ||
|
|
bc0433e356 | ||
|
|
9ba4197bfd | ||
|
|
de908d547f | ||
|
|
efba34a1e2 | ||
|
|
2ccbf4ca67 | ||
|
|
40dc30f950 | ||
|
|
90345ecd38 | ||
|
|
5c821bd7ad | ||
|
|
56f4641564 | ||
|
|
7fbf6f1569 | ||
|
|
373fa6f78b | ||
|
|
8d9d7874f3 | ||
|
|
bc42adcad9 | ||
|
|
684cbf3b4f | ||
|
|
8406f06738 | ||
|
|
25b68cb1df | ||
|
|
ade11494be | ||
|
|
7a9f72a3de | ||
|
|
cb23d5b9ac | ||
|
|
2ed169fc78 | ||
|
|
cbe4b0a0e1 | ||
|
|
9854e6c034 | ||
|
|
e417ff9655 | ||
|
|
ed22b81e97 | ||
|
|
f04c8ccc51 | ||
|
|
ed9390319b | ||
|
|
1f4b5290b2 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dmtools",
|
||||||
|
"runtimeExecutable": "symfony",
|
||||||
|
"runtimeArgs": ["serve", "--no-tls"],
|
||||||
|
"port": 8000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -34,18 +34,19 @@ DEFAULT_URI=https://dmtools.fvandenberg.nl
|
|||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||||
#
|
#
|
||||||
# In Docker (servers) the host is the `database` service and these parts come
|
# In Docker (servers) php talks to the `database` service on its own port
|
||||||
# from docker-compose.yml / .env.local. On the dev box, point DATABASE_URL at
|
# 3306 - always. The host-published port (for a DB client on the server) is
|
||||||
# your local MariaDB in .env.local, e.g.:
|
# a separate thing: docker-compose.yml uses ${DB_HOST_PORT:-3310}, override
|
||||||
# DATABASE_URL="mysql://root:@127.0.0.1:3306/dmtools?serverVersion=10.10.2-MariaDB&charset=utf8mb4"
|
# it in .env.local if 3310 is taken.
|
||||||
DB_HOST=database
|
#
|
||||||
DB_PORT=3306
|
# On the dev box, point DATABASE_URL at your local MariaDB in .env.local:
|
||||||
|
# DATABASE_URL="mysql://root:@127.0.0.1:3307/dmtools?serverVersion=10.10.2-MariaDB&charset=utf8mb4"
|
||||||
DB_NAME=dmtools
|
DB_NAME=dmtools
|
||||||
DB_USER=dmtools
|
DB_USER=dmtools
|
||||||
DB_PASSWORD=ChangeMe
|
DB_PASSWORD=ChangeMe
|
||||||
DB_ROOT_PASSWORD=ChangeMeRoot
|
DB_ROOT_PASSWORD=ChangeMeRoot
|
||||||
DB_SERVER_VERSION=mariadb-12.3.2
|
DB_SERVER_VERSION=mariadb-12.3.2
|
||||||
DATABASE_URL="mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?serverVersion=${DB_SERVER_VERSION}&charset=utf8mb4"
|
DATABASE_URL="mysql://${DB_USER}:${DB_PASSWORD}@database:3306/${DB_NAME}?serverVersion=${DB_SERVER_VERSION}&charset=utf8mb4"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
###> symfony/messenger ###
|
###> symfony/messenger ###
|
||||||
@@ -58,3 +59,14 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
|||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
MAILER_DSN=null://null
|
MAILER_DSN=null://null
|
||||||
###< symfony/mailer ###
|
###< symfony/mailer ###
|
||||||
|
|
||||||
|
# Puzzle relay (src/Command/PuzzleRelayCommand.php): a small WebSocket server,
|
||||||
|
# run separately with `symfony console app:puzzle-relay` (or `php bin/console
|
||||||
|
# app:puzzle-relay`) alongside symfony serve, that syncs cursors and dragged
|
||||||
|
# pieces across the shared puzzle screens. No Symfony/Mercure component covers
|
||||||
|
# this (Mercure needs its own hub process/container); Workerman gives us a
|
||||||
|
# plain-PHP WebSocket server instead.
|
||||||
|
# PUZZLE_RELAY_LISTEN is the address the relay process binds to.
|
||||||
|
PUZZLE_RELAY_LISTEN=0.0.0.0:8091
|
||||||
|
# PUZZLE_RELAY_PUBLIC_URL is the address browsers connect to.
|
||||||
|
PUZZLE_RELAY_PUBLIC_URL=ws://127.0.0.1:8091
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Committed prod defaults (see .env for the full explanation of the cascade).
|
||||||
|
|
||||||
|
# The relay itself still binds 0.0.0.0:8091 inside its own container (see
|
||||||
|
# PUZZLE_RELAY_LISTEN in .env) - it's never published to the host or the
|
||||||
|
# internet directly. Browsers instead reach it through this same domain, via
|
||||||
|
# the /puzzle-ws/ proxy in docker/nginx/default.conf, which forwards to the
|
||||||
|
# puzzle-relay container over the docker network.
|
||||||
|
PUZZLE_RELAY_PUBLIC_URL=wss://dmtools.fvandenberg.nl/puzzle-ws/
|
||||||
@@ -26,7 +26,7 @@ MariaDB/MySQL (WAMP is fine). `.php-version` pins this directory to PHP 8.5.
|
|||||||
cp .env .env.local
|
cp .env .env.local
|
||||||
# then edit .env.local:
|
# then edit .env.local:
|
||||||
# APP_ENV=dev
|
# APP_ENV=dev
|
||||||
# DATABASE_URL="mysql://root:@127.0.0.1:3306/dmtools?serverVersion=10.10.2-MariaDB&charset=utf8mb4"
|
# DATABASE_URL="mysql://root:@127.0.0.1:3307/dmtools?serverVersion=10.10.2-MariaDB&charset=utf8mb4"
|
||||||
|
|
||||||
# 2. Install and build
|
# 2. Install and build
|
||||||
symfony composer install
|
symfony composer install
|
||||||
@@ -51,7 +51,7 @@ Docker only. `docker/setup.sh` pins the compose project name to `dmtools`
|
|||||||
git pull
|
git pull
|
||||||
cp .env .env.local # first time only
|
cp .env .env.local # first time only
|
||||||
# edit .env.local: real APP_SECRET (or let setup.sh generate it),
|
# edit .env.local: real APP_SECRET (or let setup.sh generate it),
|
||||||
# DB_PASSWORD, DB_ROOT_PASSWORD, and NGINX_PORT / DB_PORT if 8085 / 3310 clash
|
# DB_PASSWORD, DB_ROOT_PASSWORD, and NGINX_PORT / DB_HOST_PORT if 8085 / 3310 clash
|
||||||
./docker/setup.sh
|
./docker/setup.sh
|
||||||
docker compose -p dmtools exec php php bin/console app:user:create you@example.com --admin
|
docker compose -p dmtools exec php php bin/console app:user:create you@example.com --admin
|
||||||
```
|
```
|
||||||
@@ -61,9 +61,18 @@ Ports (bound to `127.0.0.1` on the host): nginx `8085` → `:80`, db `3310` →
|
|||||||
### Reverse proxy (Nginx Proxy Manager)
|
### Reverse proxy (Nginx Proxy Manager)
|
||||||
|
|
||||||
Add a proxy host: `dmtools.fvandenberg.nl` → forward to host `dmtools-nginx`,
|
Add a proxy host: `dmtools.fvandenberg.nl` → forward to host `dmtools-nginx`,
|
||||||
port `80`, scheme `http`; request a Let's Encrypt certificate. NPM and
|
port `80`, scheme `http`; request a Let's Encrypt certificate.
|
||||||
`dmtools-nginx` must share the external `docker_default` Docker network (the
|
|
||||||
compose file attaches nginx to it as `proxy`).
|
NPM must be able to resolve `dmtools-nginx`, i.e. be on the `dmtools_default`
|
||||||
|
network. `setup.sh` connects it (`nginx_app_1` by default; override with
|
||||||
|
`NPM_CONTAINER=...`); to do it by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network connect dmtools_default nginx_app_1
|
||||||
|
```
|
||||||
|
|
||||||
|
NPM keeps that connection across restarts; it's only needed again after a
|
||||||
|
`docker compose down` drops the network.
|
||||||
|
|
||||||
### Common commands
|
### Common commands
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ function columnFromTh(th, rowUrl) {
|
|||||||
const short = s.length > 70 ? s.slice(0, 70) + '…' : s;
|
const short = s.length > 70 ? s.slice(0, 70) + '…' : s;
|
||||||
return `<span title="${esc(s)}">${esc(short)}</span>`;
|
return `<span title="${esc(s)}">${esc(short)}</span>`;
|
||||||
};
|
};
|
||||||
|
} else if (th.dataset.format === 'pick') {
|
||||||
|
// a row-select checkbox + quantity, for "add these to the tracker"
|
||||||
|
col.render = (val, type, row) => {
|
||||||
|
if (type !== 'display') return '';
|
||||||
|
return `<span class="track-pick d-inline-flex align-items-center gap-2">
|
||||||
|
<input type="checkbox" class="form-check-input track-check" value="${esc(row.id)}" aria-label="Select">
|
||||||
|
<input type="number" class="form-control form-control-sm track-qty" value="1" min="1" max="50"
|
||||||
|
style="width: 3.75rem" aria-label="Count">
|
||||||
|
</span>`;
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? ''));
|
col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? ''));
|
||||||
}
|
}
|
||||||
@@ -95,3 +105,56 @@ document.addEventListener('DOMContentLoaded', () => initDataTables());
|
|||||||
document.addEventListener('turbo:load', () => initDataTables());
|
document.addEventListener('turbo:load', () => initDataTables());
|
||||||
// Restore the plain <table> before Turbo snapshots the page for its cache.
|
// Restore the plain <table> before Turbo snapshots the page for its cache.
|
||||||
document.addEventListener('turbo:before-cache', destroyDataTables);
|
document.addEventListener('turbo:before-cache', destroyDataTables);
|
||||||
|
|
||||||
|
// --- Remember unfolded stat blocks on the tracker -----------------------
|
||||||
|
// Every damage/heal is a POST + redirect, so without this a stat block the
|
||||||
|
// DM has open snaps shut on each hit. Keyed by the collapse element id.
|
||||||
|
const SB_KEY = 'dmtools.statblocks';
|
||||||
|
|
||||||
|
function readOpenSet() {
|
||||||
|
try {
|
||||||
|
return new Set(JSON.parse(localStorage.getItem(SB_KEY) || '[]'));
|
||||||
|
} catch (e) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreStatblocks() {
|
||||||
|
const open = readOpenSet();
|
||||||
|
document.querySelectorAll('.statblock-collapse').forEach((el) => {
|
||||||
|
const on = open.has(el.id);
|
||||||
|
el.classList.toggle('show', on);
|
||||||
|
document
|
||||||
|
.querySelectorAll(`[data-bs-toggle="collapse"][data-bs-target="#${el.id}"]`)
|
||||||
|
.forEach((btn) => btn.setAttribute('aria-expanded', on ? 'true' : 'false'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberStatblock(el, isOpen) {
|
||||||
|
if (!el.classList.contains('statblock-collapse')) return;
|
||||||
|
const open = readOpenSet();
|
||||||
|
isOpen ? open.add(el.id) : open.delete(el.id);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SB_KEY, JSON.stringify([...open]));
|
||||||
|
} catch (e) {
|
||||||
|
/* private mode - ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('shown.bs.collapse', (e) => rememberStatblock(e.target, true));
|
||||||
|
document.addEventListener('hidden.bs.collapse', (e) => rememberStatblock(e.target, false));
|
||||||
|
document.addEventListener('DOMContentLoaded', restoreStatblocks);
|
||||||
|
document.addEventListener('turbo:load', restoreStatblocks);
|
||||||
|
|
||||||
|
// --- Publish the navbar height as --app-nav-h -------------------------
|
||||||
|
// The storyline chapter page sizes its split view to the viewport minus the
|
||||||
|
// navbar; the navbar grows when it wraps, so measure it rather than guess.
|
||||||
|
function publishNavHeight() {
|
||||||
|
const nav = document.querySelector('.navbar');
|
||||||
|
const h = nav ? Math.round(nav.getBoundingClientRect().height) : 56;
|
||||||
|
document.documentElement.style.setProperty('--app-nav-h', `${h}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', publishNavHeight);
|
||||||
|
document.addEventListener('turbo:load', publishNavHeight);
|
||||||
|
window.addEventListener('resize', publishNavHeight);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,94 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Drag-to-reorder for the art gallery.
|
||||||
|
*
|
||||||
|
* <div data-controller="art-reorder"
|
||||||
|
* data-art-reorder-url-value="/art/reorder"
|
||||||
|
* data-art-reorder-token-value="...">
|
||||||
|
* <div data-art-reorder-target="item" data-art-id="5">
|
||||||
|
* <button draggable="true"
|
||||||
|
* data-action="dragstart->art-reorder#dragStart dragend->art-reorder#dragEnd">⠿</button>
|
||||||
|
* ...
|
||||||
|
* </div>
|
||||||
|
* ...
|
||||||
|
* </div>
|
||||||
|
* <div data-art-reorder-target="status"></div>
|
||||||
|
*
|
||||||
|
* The dragged tile is moved live in the DOM as the pointer crosses other
|
||||||
|
* tiles (dragover, bound on the grid itself via data-action); the new order
|
||||||
|
* is only sent to the server once, on dragend.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['item', 'status'];
|
||||||
|
static values = { url: String, token: String };
|
||||||
|
|
||||||
|
dragStart(event) {
|
||||||
|
const tile = event.currentTarget.closest('[data-art-reorder-target="item"]');
|
||||||
|
if (!tile) return;
|
||||||
|
|
||||||
|
this.dragEl = tile;
|
||||||
|
this.startOrder = this.currentOrder();
|
||||||
|
tile.classList.add('is-dragging');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Firefox requires data to be set for the drag to start at all.
|
||||||
|
event.dataTransfer.setData('text/plain', tile.dataset.artId);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragOver(event) {
|
||||||
|
if (!this.dragEl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const over = event.target.closest('[data-art-reorder-target="item"]');
|
||||||
|
if (!over || over === this.dragEl) return;
|
||||||
|
|
||||||
|
const rect = over.getBoundingClientRect();
|
||||||
|
const before = event.clientX - rect.left < rect.width / 2;
|
||||||
|
over.parentNode.insertBefore(this.dragEl, before ? over : over.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
dragEnd() {
|
||||||
|
if (!this.dragEl) return;
|
||||||
|
this.dragEl.classList.remove('is-dragging');
|
||||||
|
|
||||||
|
const order = this.currentOrder();
|
||||||
|
const changed = order.join(',') !== (this.startOrder || []).join(',');
|
||||||
|
this.dragEl = null;
|
||||||
|
|
||||||
|
if (changed) this.persist(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOrder() {
|
||||||
|
return this.itemTargets.map((el) => el.dataset.artId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async persist(order) {
|
||||||
|
this.showStatus('Saving order…');
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.urlValue, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ _token: this.tokenValue, order }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
this.showStatus('Order saved');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('art reorder save failed', e);
|
||||||
|
this.showStatus('Could not save the new order — reload and try again', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus(text, isError = false) {
|
||||||
|
if (!this.hasStatusTarget) return;
|
||||||
|
clearTimeout(this.statusTimer);
|
||||||
|
this.statusTarget.textContent = text;
|
||||||
|
this.statusTarget.classList.toggle('is-error', isError);
|
||||||
|
this.statusTarget.classList.add('is-visible');
|
||||||
|
this.statusTimer = setTimeout(() => this.statusTarget.classList.remove('is-visible'), isError ? 4000 : 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The docked "Chapter notes" panel on a storyline chapter page.
|
||||||
|
*
|
||||||
|
* <div class="storyline-split" data-controller="chapter-notes">
|
||||||
|
* ...chapter...
|
||||||
|
* <div class="storyline-notes-resize" data-chapter-notes-target="handle"
|
||||||
|
* data-action="pointerdown->chapter-notes#startResize"></div>
|
||||||
|
* <section class="storyline-notes-dock" data-chapter-notes-target="dock">
|
||||||
|
* <button data-action="chapter-notes#toggle" data-chapter-notes-target="toggle">…</button>
|
||||||
|
* ...
|
||||||
|
* </section>
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* Drag the handle to resize; click the header to collapse. Both the pixel
|
||||||
|
* height and the collapsed flag persist per browser (localStorage).
|
||||||
|
*/
|
||||||
|
const H_KEY = 'dmtools.chapterNotesHeight';
|
||||||
|
const C_KEY = 'dmtools.chapterNotesCollapsed';
|
||||||
|
const MIN_PX = 44;
|
||||||
|
const MIN_TOP_PX = 140;
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['dock', 'handle', 'toggle'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.onMove = this.onMove.bind(this);
|
||||||
|
this.endResize = this.endResize.bind(this);
|
||||||
|
|
||||||
|
let stored = null;
|
||||||
|
try {
|
||||||
|
stored = localStorage.getItem(H_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
if (stored) {
|
||||||
|
this.element.style.setProperty('--notes-h', `${this.clamp(parseInt(stored, 10))}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = false;
|
||||||
|
try {
|
||||||
|
collapsed = localStorage.getItem(C_KEY) === '1';
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.setCollapsed(collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.endResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.setCollapsed(!this.dockTarget.classList.contains('is-collapsed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setCollapsed(on) {
|
||||||
|
this.dockTarget.classList.toggle('is-collapsed', on);
|
||||||
|
if (this.hasToggleTarget) {
|
||||||
|
this.toggleTarget.setAttribute('aria-expanded', on ? 'false' : 'true');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(C_KEY, on ? '1' : '0');
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startResize(event) {
|
||||||
|
if (this.dockTarget.classList.contains('is-collapsed')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.resizing = true;
|
||||||
|
this.handleTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'row-resize';
|
||||||
|
window.addEventListener('pointermove', this.onMove);
|
||||||
|
window.addEventListener('pointerup', this.endResize);
|
||||||
|
window.addEventListener('pointercancel', this.endResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMove(event) {
|
||||||
|
if (!this.resizing) return;
|
||||||
|
const rect = this.element.getBoundingClientRect();
|
||||||
|
const px = this.clamp(rect.bottom - event.clientY);
|
||||||
|
this.element.style.setProperty('--notes-h', `${px}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
endResize() {
|
||||||
|
window.removeEventListener('pointermove', this.onMove);
|
||||||
|
window.removeEventListener('pointerup', this.endResize);
|
||||||
|
window.removeEventListener('pointercancel', this.endResize);
|
||||||
|
if (!this.resizing) return;
|
||||||
|
this.resizing = false;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
|
const current = this.element.style.getPropertyValue('--notes-h');
|
||||||
|
if (current.endsWith('px')) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(H_KEY, parseInt(current, 10));
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clamp(px) {
|
||||||
|
if (!Number.isFinite(px)) return MIN_PX;
|
||||||
|
const max = Math.max(MIN_PX, this.element.getBoundingClientRect().height - MIN_TOP_PX);
|
||||||
|
return Math.min(Math.max(px, MIN_PX), max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copies an image to the OS clipboard so it can be pasted into WhatsApp etc.
|
||||||
|
*
|
||||||
|
* <button data-controller="clipboard"
|
||||||
|
* data-clipboard-url-value="/art/5/file"
|
||||||
|
* data-action="clipboard#copy">Copy image</button>
|
||||||
|
*
|
||||||
|
* The Clipboard API only reliably accepts image/png, so non-PNG sources are
|
||||||
|
* redrawn through a canvas first. Needs a secure context (https or localhost).
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { url: String };
|
||||||
|
|
||||||
|
async copy(event) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
|
||||||
|
if (!navigator.clipboard || !window.ClipboardItem) {
|
||||||
|
this.flash(btn, 'Not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.flash(btn, 'Copying…', 0);
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.urlValue, { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
let blob = await res.blob();
|
||||||
|
if (blob.type !== 'image/png') {
|
||||||
|
blob = await this.toPng(blob);
|
||||||
|
}
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||||
|
this.flash(btn, 'Copied!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('clipboard copy failed', e);
|
||||||
|
this.flash(btn, 'Copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toPng(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('image decode failed'));
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
flash(btn, msg, revertAfter = 1800) {
|
||||||
|
if (!btn.dataset.label) btn.dataset.label = btn.textContent;
|
||||||
|
btn.textContent = msg;
|
||||||
|
clearTimeout(this._t);
|
||||||
|
if (revertAfter > 0) {
|
||||||
|
this._t = setTimeout(() => {
|
||||||
|
btn.textContent = btn.dataset.label;
|
||||||
|
}, revertAfter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The shared, no-login puzzle board. Connects to the WebSocket relay
|
||||||
|
* (src/Command/PuzzleRelayCommand.php) so every open tab on this room sees
|
||||||
|
* the same piece positions and each other's cursors, live.
|
||||||
|
*
|
||||||
|
* <div data-controller="puzzle-room"
|
||||||
|
* data-puzzle-room-room-value="chamber-of-motion"
|
||||||
|
* data-puzzle-room-relay-url-value="ws://127.0.0.1:8091">
|
||||||
|
* <form data-puzzle-room-target="joinForm" data-action="submit->puzzle-room#join">...</form>
|
||||||
|
* <div data-puzzle-room-target="board" hidden>
|
||||||
|
* <div data-puzzle-room-target="piece" data-piece-id="a"
|
||||||
|
* data-action="pointerdown->puzzle-room#startDrag">...</div>
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* Room state (piece positions, who's online) lives only in the relay
|
||||||
|
* process's memory - there is no database or cache behind this.
|
||||||
|
*/
|
||||||
|
const CLIENT_KEY = 'dmtools.puzzleClient';
|
||||||
|
const CURSOR_THROTTLE_MS = 40;
|
||||||
|
const PIECE_THROTTLE_MS = 40;
|
||||||
|
const RECONNECT_DELAY_MS = 2000;
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['board', 'piece', 'joinForm', 'nameInput', 'status'];
|
||||||
|
static values = { room: String, relayUrl: String, resetToken: String };
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.cursorEls = new Map();
|
||||||
|
this.pieceEls = new Map();
|
||||||
|
this.defaultPositions = new Map();
|
||||||
|
this.pieceTargets.forEach((el) => {
|
||||||
|
this.pieceEls.set(el.dataset.pieceId, el);
|
||||||
|
this.defaultPositions.set(el.dataset.pieceId, { left: el.style.left, top: el.style.top });
|
||||||
|
});
|
||||||
|
this.dragging = null;
|
||||||
|
|
||||||
|
this.onPointerMove = this.onPointerMove.bind(this);
|
||||||
|
this.onPointerUp = this.onPointerUp.bind(this);
|
||||||
|
this.onBoardPointerMove = this.onBoardPointerMove.bind(this);
|
||||||
|
this.sendCursor = throttle((x, y) => this.send({ type: 'cursor', room: this.roomValue, x, y }), CURSOR_THROTTLE_MS);
|
||||||
|
this.sendPieceMove = throttle((id, x, y) => this.send({ type: 'piece', room: this.roomValue, id, x, y, phase: 'move' }), PIECE_THROTTLE_MS);
|
||||||
|
|
||||||
|
this.client = this.loadClient();
|
||||||
|
if (this.client) {
|
||||||
|
this.startSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.socket?.close();
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
window.removeEventListener('pointermove', this.onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
join(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = this.nameInputTarget.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
this.client = { clientId: crypto.randomUUID(), name: name.slice(0, 40), color: randomColor() };
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(CLIENT_KEY, JSON.stringify(this.client));
|
||||||
|
} catch {
|
||||||
|
/* private mode: identity just won't survive a reload */
|
||||||
|
}
|
||||||
|
this.startSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadClient() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(CLIENT_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startSession() {
|
||||||
|
this.joinFormTarget.hidden = true;
|
||||||
|
this.boardTarget.hidden = false;
|
||||||
|
this.boardTarget.addEventListener('pointermove', this.onBoardPointerMove);
|
||||||
|
this.connectSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectSocket() {
|
||||||
|
this.setStatus('Connecting…');
|
||||||
|
const socket = new WebSocket(this.relayUrlValue);
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
this.send({
|
||||||
|
type: 'join',
|
||||||
|
room: this.roomValue,
|
||||||
|
clientId: this.client.clientId,
|
||||||
|
name: this.client.name,
|
||||||
|
color: this.client.color,
|
||||||
|
});
|
||||||
|
this.heartbeatTimer = setInterval(() => this.send({ type: 'ping' }), HEARTBEAT_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
socket.addEventListener('message', (event) => this.onMessage(event));
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.setStatus('Disconnected — retrying…');
|
||||||
|
this.reconnectTimer = setTimeout(() => this.connectSocket(), RECONNECT_DELAY_MS);
|
||||||
|
});
|
||||||
|
socket.addEventListener('error', () => socket.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(text) {
|
||||||
|
if (this.hasStatusTarget) this.statusTarget.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(payload) {
|
||||||
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||||
|
this.socket.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(event) {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'state':
|
||||||
|
Object.entries(message.pieces || {}).forEach(([id, pos]) => this.placePiece(id, pos.x, pos.y));
|
||||||
|
this.renderPresence(message.clients || []);
|
||||||
|
break;
|
||||||
|
case 'cursor':
|
||||||
|
if (message.clientId !== this.client.clientId) this.renderCursor(message);
|
||||||
|
break;
|
||||||
|
case 'piece':
|
||||||
|
if (message.clientId !== this.client.clientId) this.placePiece(message.id, message.x, message.y);
|
||||||
|
break;
|
||||||
|
case 'presence':
|
||||||
|
this.renderPresence(message.clients || []);
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
this.resetPieces();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
if (!this.resetTokenValue) return;
|
||||||
|
if (!window.confirm('Reset the puzzle? This puts every gear back in the tray for everyone in this room.')) return;
|
||||||
|
this.send({ type: 'reset', room: this.roomValue, resetToken: this.resetTokenValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPieces() {
|
||||||
|
this.dragging = null;
|
||||||
|
for (const [id, el] of this.pieceEls) {
|
||||||
|
const pos = this.defaultPositions.get(id);
|
||||||
|
if (!pos) continue;
|
||||||
|
el.style.left = pos.left;
|
||||||
|
el.style.top = pos.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPresence(clients) {
|
||||||
|
const seen = new Set(clients.map((c) => c.clientId));
|
||||||
|
for (const [clientId, el] of this.cursorEls) {
|
||||||
|
if (!seen.has(clientId)) {
|
||||||
|
el.remove();
|
||||||
|
this.cursorEls.delete(clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const others = clients.filter((c) => c.clientId !== this.client.clientId).length;
|
||||||
|
this.setStatus(others > 0 ? `Connected — ${others} other${others === 1 ? '' : 's'} here` : "Connected — you're the only one here");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCursor(message) {
|
||||||
|
let el = this.cursorEls.get(message.clientId);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.className = 'puzzle-cursor';
|
||||||
|
el.innerHTML = '<span class="puzzle-cursor-dot"></span><span class="puzzle-cursor-label"></span>';
|
||||||
|
el.querySelector('.puzzle-cursor-dot').style.background = message.color;
|
||||||
|
el.querySelector('.puzzle-cursor-label').style.background = message.color;
|
||||||
|
el.querySelector('.puzzle-cursor-label').textContent = message.name;
|
||||||
|
this.boardTarget.appendChild(el);
|
||||||
|
this.cursorEls.set(message.clientId, el);
|
||||||
|
}
|
||||||
|
el.style.left = `${message.x * 100}%`;
|
||||||
|
el.style.top = `${message.y * 100}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
placePiece(id, x, y) {
|
||||||
|
const el = this.pieceEls.get(id);
|
||||||
|
if (!el || this.dragging?.id === id) return;
|
||||||
|
el.style.left = `${x * 100}%`;
|
||||||
|
el.style.top = `${y * 100}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onBoardPointerMove(event) {
|
||||||
|
const { x, y } = this.relativePosition(event);
|
||||||
|
this.sendCursor(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
relativePosition(event) {
|
||||||
|
const rect = this.boardTarget.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: clamp((event.clientX - rect.left) / rect.width, 0, 1),
|
||||||
|
y: clamp((event.clientY - rect.top) / rect.height, 0, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
startDrag(event) {
|
||||||
|
const el = event.currentTarget;
|
||||||
|
el.setPointerCapture(event.pointerId);
|
||||||
|
this.dragging = { id: el.dataset.pieceId, el };
|
||||||
|
el.classList.add('is-dragging');
|
||||||
|
window.addEventListener('pointermove', this.onPointerMove);
|
||||||
|
window.addEventListener('pointerup', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(event) {
|
||||||
|
if (!this.dragging) return;
|
||||||
|
const { x, y } = this.relativePosition(event);
|
||||||
|
this.dragging.el.style.left = `${x * 100}%`;
|
||||||
|
this.dragging.el.style.top = `${y * 100}%`;
|
||||||
|
this.sendPieceMove(this.dragging.id, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(event) {
|
||||||
|
if (!this.dragging) return;
|
||||||
|
const { x, y } = this.relativePosition(event);
|
||||||
|
this.send({ type: 'piece', room: this.roomValue, id: this.dragging.id, x, y, phase: 'drop' });
|
||||||
|
this.dragging.el.classList.remove('is-dragging');
|
||||||
|
this.dragging = null;
|
||||||
|
window.removeEventListener('pointermove', this.onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', this.onPointerUp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function throttle(fn, ms) {
|
||||||
|
let last = 0;
|
||||||
|
let timer = null;
|
||||||
|
return (...args) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = ms - (now - last);
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
last = now;
|
||||||
|
fn(...args);
|
||||||
|
} else {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
last = Date.now();
|
||||||
|
fn(...args);
|
||||||
|
}, remaining);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomColor() {
|
||||||
|
return `hsl(${Math.floor(Math.random() * 360)}, 70%, 55%)`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sidebar table of contents on a storyline chapter page.
|
||||||
|
*
|
||||||
|
* - the caret unfolds/folds a chapter's section list
|
||||||
|
* - links for the chapter being read (target "link", href="#section-…")
|
||||||
|
* jump inside the split view's scrolling pane
|
||||||
|
* - the section currently at the top of the pane is highlighted
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['link'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.pane = this.element.closest('.storyline-split-main');
|
||||||
|
this.onScroll = () => {
|
||||||
|
if (this.frame) return;
|
||||||
|
this.frame = requestAnimationFrame(() => {
|
||||||
|
this.frame = null;
|
||||||
|
this.highlight();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
(this.pane || window).addEventListener('scroll', this.onScroll, { passive: true });
|
||||||
|
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||||
|
this.highlight();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
(this.pane || window).removeEventListener('scroll', this.onScroll);
|
||||||
|
window.removeEventListener('scroll', this.onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
fold(event) {
|
||||||
|
const item = event.currentTarget.closest('.storyline-toc-item');
|
||||||
|
const open = item.classList.toggle('is-open');
|
||||||
|
event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
jump(event) {
|
||||||
|
const id = event.currentTarget.getAttribute('href').slice(1);
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
event.preventDefault();
|
||||||
|
el.scrollIntoView({ block: 'start' });
|
||||||
|
history.replaceState(null, '', `#${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
linkTargetConnected(link) {
|
||||||
|
link.addEventListener('click', (e) => this.jump({ currentTarget: link, preventDefault: () => e.preventDefault() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
highlight() {
|
||||||
|
if (!this.hasLinkTarget) return;
|
||||||
|
// In the split view the pane scrolls; on small screens the window does.
|
||||||
|
const paneScrolls = this.pane && getComputedStyle(this.pane).overflowY !== 'visible';
|
||||||
|
const top = paneScrolls ? this.pane.getBoundingClientRect().top : 0;
|
||||||
|
let current = null;
|
||||||
|
for (const link of this.linkTargets) {
|
||||||
|
const el = document.getElementById(link.getAttribute('href').slice(1));
|
||||||
|
if (el && el.getBoundingClientRect().top - top <= 90) current = link;
|
||||||
|
}
|
||||||
|
this.linkTargets.forEach((l) => l.classList.toggle('is-current', l === current));
|
||||||
|
if (current && this.pane) {
|
||||||
|
const list = current.closest('.storyline-toc-sections');
|
||||||
|
if (list && list.scrollHeight > list.clientHeight) {
|
||||||
|
const c = current.getBoundingClientRect();
|
||||||
|
const l = list.getBoundingClientRect();
|
||||||
|
if (c.top < l.top || c.bottom > l.bottom) current.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Collects the ticked rows (+ their quantity) from a DataTable and POSTs them
|
||||||
|
* to the combat tracker.
|
||||||
|
*
|
||||||
|
* <div data-controller="tracker-picker"
|
||||||
|
* data-tracker-picker-url-value="/tracker/add"
|
||||||
|
* data-tracker-picker-token-value="...">
|
||||||
|
* <button data-action="tracker-picker#submit">Add to tracker</button>
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { url: String, token: String };
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
const picks = [...document.querySelectorAll('.track-check:checked')].map((cb) => {
|
||||||
|
const qtyEl = cb.closest('.track-pick')?.querySelector('.track-qty');
|
||||||
|
const qty = Math.max(1, Math.min(50, parseInt(qtyEl?.value ?? '1', 10) || 1));
|
||||||
|
return { id: cb.value, qty };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (picks.length === 0) {
|
||||||
|
window.alert('Tick at least one enemy first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = this.element.querySelector('.encounter-select');
|
||||||
|
let encounter = select ? select.value : 'new';
|
||||||
|
let name = '';
|
||||||
|
if (encounter === 'new') {
|
||||||
|
name = window.prompt('Name for the new encounter', 'Encounter');
|
||||||
|
if (name === null) return; // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'post';
|
||||||
|
form.action = this.urlValue;
|
||||||
|
form.insertAdjacentHTML('beforeend',
|
||||||
|
`<input type="hidden" name="_token" value="${this.tokenValue}">` +
|
||||||
|
`<input type="hidden" name="encounter" value="${encounter}">` +
|
||||||
|
`<input type="hidden" name="name" value="${name.replace(/"/g, '"')}">`);
|
||||||
|
picks.forEach((p, i) => {
|
||||||
|
form.insertAdjacentHTML('beforeend',
|
||||||
|
`<input type="hidden" name="picks[${i}][id]" value="${p.id}">` +
|
||||||
|
`<input type="hidden" name="picks[${i}][qty]" value="${p.qty}">`);
|
||||||
|
});
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 513 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
@@ -89,3 +89,747 @@ table.dataTable {
|
|||||||
.ft-amp {
|
.ft-amp {
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Art gallery ------------------------------------------------------- */
|
||||||
|
.art-thumb {
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.4rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
border-top-left-radius: inherit;
|
||||||
|
border-top-right-radius: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.art-thumb img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain; /* whole image, never cropped */
|
||||||
|
}
|
||||||
|
.art-full img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag-to-reorder handle, top-left of each tile */
|
||||||
|
.art-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.35rem;
|
||||||
|
left: 0.35rem;
|
||||||
|
z-index: 2;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
cursor: grab;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.art-drag-handle:hover { opacity: 1; color: var(--bs-body-color); }
|
||||||
|
.art-drag-handle:active { cursor: grabbing; }
|
||||||
|
.art-tile.is-dragging { opacity: 0.4; }
|
||||||
|
.art-tile.is-drag-over .card { outline: 2px dashed var(--bs-primary); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.art-reorder-status {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 1080;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
background: var(--bs-body-color);
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.art-reorder-status.is-visible { opacity: 0.92; }
|
||||||
|
.art-reorder-status.is-error { background: var(--bs-danger); color: #fff; }
|
||||||
|
|
||||||
|
/* --- Character sheets -------------------------------------------------- */
|
||||||
|
.min-w-0 { min-width: 0; }
|
||||||
|
|
||||||
|
.char-portrait {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.char-portrait img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.char-portrait--card { height: 150px; border-radius: 0.375rem 0.375rem 0 0; border-width: 0 0 1px; }
|
||||||
|
.char-portrait--hero { height: 220px; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.char-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.char-meta > div { display: flex; flex-direction: column; }
|
||||||
|
.char-meta dt {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.char-meta dd { margin: 0.05rem 0 0; font-weight: 500; }
|
||||||
|
|
||||||
|
.char-ability-table td:first-child,
|
||||||
|
.char-ability-table th:first-child,
|
||||||
|
.char-skill-table td:first-child,
|
||||||
|
.char-skill-table th:first-child { white-space: nowrap; }
|
||||||
|
.char-ability-table tr.is-proficient,
|
||||||
|
.char-skill-table tr.is-proficient { font-weight: 600; }
|
||||||
|
.char-ability-table tr.is-proficient td:first-child,
|
||||||
|
.char-skill-table tr.is-proficient td:first-child {
|
||||||
|
box-shadow: inset 2px 0 0 var(--bs-primary);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.char-skill-table { font-size: 0.875rem; }
|
||||||
|
|
||||||
|
/* --- Tracker: unfoldable stat blocks --------------------------------- */
|
||||||
|
[data-bs-toggle="collapse"] .sb-caret {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
[data-bs-toggle="collapse"][aria-expanded="true"] .sb-caret {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.statblock-collapse h3 {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Overview tiles ------------------------------------------------------ */
|
||||||
|
.home-tile {
|
||||||
|
transition: border-color 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.home-tile:hover {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Storyline: chapter index ----------------------------------------- */
|
||||||
|
.storyline-chapter-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 60rem;
|
||||||
|
}
|
||||||
|
.storyline-chapter-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.storyline-chapter-card:hover {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.storyline-chapter-num {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.storyline-chapter-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.storyline-chapter-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.storyline-chapter-summary {
|
||||||
|
display: block;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.storyline-chapter-go {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Storyline: chapter layout + table of contents ------------------- */
|
||||||
|
.storyline-toc {
|
||||||
|
position: sticky;
|
||||||
|
top: 0.25rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.storyline-toc-link {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.storyline-toc-link:hover {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
}
|
||||||
|
.storyline-toc-link.is-active {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
border-left-color: var(--bs-primary);
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.storyline-toc-num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unfoldable per-chapter section lists */
|
||||||
|
.storyline-toc-row { display: flex; align-items: flex-start; gap: 0.15rem; }
|
||||||
|
.storyline-toc-row .storyline-toc-link { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.storyline-toc-fold {
|
||||||
|
flex: 0 0 1.4rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
button.storyline-toc-fold:hover { color: var(--bs-body-color); }
|
||||||
|
.storyline-toc-item.is-open > .storyline-toc-row > button.storyline-toc-fold { transform: rotate(90deg); }
|
||||||
|
.storyline-toc-sections {
|
||||||
|
display: none;
|
||||||
|
margin: 0.1rem 0 0.4rem 1.4rem;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 45vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.storyline-toc-sections::-webkit-scrollbar { display: none; }
|
||||||
|
.storyline-toc-item.is-open > .storyline-toc-sections { display: block; }
|
||||||
|
.storyline-toc-sub {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-left: 2px solid var(--bs-border-color);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.storyline-toc-sub:hover { color: var(--bs-body-color); background: var(--bs-tertiary-bg); }
|
||||||
|
.storyline-toc-sub.is-current {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
border-left-color: var(--bs-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.storyline-toc-sub .storyline-toc-num { flex: 0 0 1.4rem; }
|
||||||
|
.storyline-section { scroll-margin-top: 0.75rem; }
|
||||||
|
|
||||||
|
/* --- Storyline: the document itself --------------------------------- */
|
||||||
|
.storyline-doc {
|
||||||
|
max-width: 46rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.storyline-doc-head {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
.storyline-eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
.storyline-doc-head h1 {
|
||||||
|
margin: 0.35rem 0 0.5rem;
|
||||||
|
font-size: clamp(1.9rem, 4vw, 2.6rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.storyline-standfirst {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.storyline-doc p {
|
||||||
|
margin: 0 0 1.1rem;
|
||||||
|
}
|
||||||
|
.storyline-doc u {
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
}
|
||||||
|
.storyline-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 2.75rem 0 1rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.storyline-section span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.storyline-lead {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
.storyline-lead::first-letter {
|
||||||
|
/* subtle emphasis on the opening line of a section */
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.storyline-turn {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin: 1.75rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pull-quotes for in-world spoken lines */
|
||||||
|
.storyline-quote {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.6rem 0 0.6rem 1.25rem;
|
||||||
|
border-left: 3px solid var(--bs-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
.storyline-quote::before { content: "\201C"; }
|
||||||
|
.storyline-quote::after { content: "\201D"; }
|
||||||
|
.storyline-quote--close {
|
||||||
|
border-left: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GM callouts */
|
||||||
|
.storyline-callout {
|
||||||
|
position: relative;
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
padding: 1.1rem 1.25rem 1.1rem 1.25rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-left: 3px solid var(--bs-info, #6ea8fe);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
.storyline-callout > :last-child { margin-bottom: 0; }
|
||||||
|
.storyline-callout::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
display: block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.storyline-callout--warn {
|
||||||
|
border-left-color: var(--bs-warning, #ffc107);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The escalation ladder in the Final Crisis */
|
||||||
|
.storyline-ladder {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
counter-reset: rung;
|
||||||
|
}
|
||||||
|
.storyline-ladder li {
|
||||||
|
counter-increment: rung;
|
||||||
|
position: relative;
|
||||||
|
padding: 0.55rem 0.9rem 0.55rem 2.75rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.storyline-ladder li::before {
|
||||||
|
content: counter(rung);
|
||||||
|
position: absolute;
|
||||||
|
left: 0.9rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick-reference panels (numbers + cast) */
|
||||||
|
.storyline-ref {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
.storyline-ref-panel {
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.storyline-ref-panel h3 {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.storyline-ref-panel dl { margin: 0; }
|
||||||
|
.storyline-ref-panel dl > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
.storyline-ref-panel dt {
|
||||||
|
flex: 0 0 6.5rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
.storyline-ref-panel dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.storyline-ref-panel ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
.storyline-ref-panel li { margin-bottom: 0.5rem; }
|
||||||
|
.storyline-ref-panel li:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Chapter pager */
|
||||||
|
.storyline-pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
.storyline-pager-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
max-width: 48%;
|
||||||
|
}
|
||||||
|
.storyline-pager-link:hover { border-color: var(--bs-primary); }
|
||||||
|
.storyline-pager-dir {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.storyline-pager-title { font-weight: 600; }
|
||||||
|
|
||||||
|
/* --- Storyline chapter page: split view (content above, notes docked) --- */
|
||||||
|
/* On wide/tall screens the chapter page fills the viewport: the top pane
|
||||||
|
scrolls the chapter, the bottom pane is a fixed-height notes dock. Falls
|
||||||
|
back to normal document flow on small screens (see media query below). */
|
||||||
|
.storyline-chapter-page { overflow: hidden; }
|
||||||
|
.storyline-chapter-page main.app-main {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: calc(100vh - var(--app-nav-h, 3.5rem));
|
||||||
|
height: calc(100dvh - var(--app-nav-h, 3.5rem));
|
||||||
|
}
|
||||||
|
.storyline-chapter-page main.app-main > .container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.storyline-split {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* --notes-h is set by the resize handle; default is one third */
|
||||||
|
--notes-h: 33%;
|
||||||
|
}
|
||||||
|
.storyline-split-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
/* Scroll stays available; the bar is just hidden. */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* legacy Edge */
|
||||||
|
}
|
||||||
|
.storyline-split-main::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Edge */
|
||||||
|
}
|
||||||
|
.storyline-notes-resize {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 10px;
|
||||||
|
margin: 0 -0.5rem;
|
||||||
|
cursor: row-resize;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--bs-border-color), var(--bs-border-color)) center/2.5rem 2px no-repeat;
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
.storyline-notes-resize:hover { background-color: var(--bs-tertiary-bg); }
|
||||||
|
.storyline-notes-dock {
|
||||||
|
flex: 0 0 var(--notes-h);
|
||||||
|
min-height: 2.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
.storyline-notes-bar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.4rem 0.1rem;
|
||||||
|
}
|
||||||
|
.storyline-notes-toggle {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.storyline-notes-caret { transition: transform 0.15s ease; display: inline-block; }
|
||||||
|
.storyline-notes-dock.is-collapsed { flex: 0 0 auto !important; }
|
||||||
|
.storyline-notes-dock.is-collapsed .storyline-notes-body { display: none; }
|
||||||
|
.storyline-notes-dock.is-collapsed .storyline-notes-caret { transform: rotate(-90deg); }
|
||||||
|
.storyline-notes-resize:has(+ .storyline-notes-dock.is-collapsed) { display: none; }
|
||||||
|
|
||||||
|
.storyline-notes-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.storyline-notes-form {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.storyline-notes-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
resize: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.storyline-notes-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small or short screens: no fixed split, just stack notes under the chapter. */
|
||||||
|
@media (max-width: 767.98px), (max-height: 560px) {
|
||||||
|
.storyline-chapter-page { overflow: visible; }
|
||||||
|
.storyline-chapter-page main.app-main {
|
||||||
|
height: auto;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
.storyline-chapter-page main.app-main > .container { display: block; height: auto; }
|
||||||
|
.storyline-split { display: block; }
|
||||||
|
.storyline-split-main { overflow: visible; }
|
||||||
|
.storyline-notes-resize { display: none; }
|
||||||
|
.storyline-notes-dock {
|
||||||
|
flex-basis: auto;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
border-top-width: 2px;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.storyline-notes-text { min-height: 9rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chapter list: kicker line above each title */
|
||||||
|
.storyline-chapter-kicker {
|
||||||
|
display: block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Storyline: session / adventure chapter components -------------- */
|
||||||
|
|
||||||
|
/* At-a-glance metadata band under the chapter header */
|
||||||
|
.storyline-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
|
||||||
|
gap: 0.6rem 1.75rem;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.storyline-meta > div { display: flex; flex-direction: column; }
|
||||||
|
.storyline-meta dt {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.storyline-meta dd { margin: 0.1rem 0 0; font-weight: 500; }
|
||||||
|
|
||||||
|
/* In-world handout / posted notice */
|
||||||
|
.storyline-handout {
|
||||||
|
max-width: 24rem;
|
||||||
|
margin: 1.75rem auto;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 5px var(--bs-body-bg), 0 0 0 6px var(--bs-border-color);
|
||||||
|
font-family: ui-monospace, "Cascadia Code", "Consolas", monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.storyline-handout strong {
|
||||||
|
display: block;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label + body rows: skill checks, day prompts */
|
||||||
|
.storyline-rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.storyline-rows li {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
.storyline-rows li:last-child { border-bottom: 1px solid var(--bs-border-color); }
|
||||||
|
.storyline-rows .k {
|
||||||
|
flex: 0 0 9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.storyline-rows li { flex-direction: column; gap: 0.15rem; }
|
||||||
|
.storyline-rows .k { flex-basis: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Question / answer + itemised lists */
|
||||||
|
.storyline-qa { margin: 1.25rem 0; }
|
||||||
|
.storyline-qa > div {
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
.storyline-qa > div:last-child { border-bottom: 1px solid var(--bs-border-color); }
|
||||||
|
.storyline-qa dt { font-weight: 600; }
|
||||||
|
.storyline-qa dd { margin: 0.15rem 0 0; color: var(--bs-secondary-color); }
|
||||||
|
|
||||||
|
/* Staged encounter */
|
||||||
|
.storyline-stages {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
.storyline-stage {
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-left: 3px solid var(--bs-primary);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
.storyline-stage h4 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
}
|
||||||
|
.storyline-stage > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* A dramatic beat: SCRATCH / sign text / milestone */
|
||||||
|
.storyline-beat {
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 1.75rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Before / after legend distortion */
|
||||||
|
.storyline-legend {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.storyline-legend li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.storyline-legend .from {
|
||||||
|
text-align: right;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.storyline-legend .arrow { color: var(--bs-primary); font-size: 1.1rem; }
|
||||||
|
.storyline-legend .to { font-style: italic; }
|
||||||
|
@media (max-width: 45rem) {
|
||||||
|
.storyline-legend li { grid-template-columns: 1fr; gap: 0.2rem; }
|
||||||
|
.storyline-legend .from { text-align: left; }
|
||||||
|
.storyline-legend .arrow { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"symfony/web-link": "8.1.*",
|
"symfony/web-link": "8.1.*",
|
||||||
"symfony/yaml": "8.1.*",
|
"symfony/yaml": "8.1.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.0",
|
||||||
|
"workerman/workerman": "^5.2"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ security:
|
|||||||
# Note: Only the *first* matching rule is applied
|
# Note: Only the *first* matching rule is applied
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/login$, roles: PUBLIC_ACCESS }
|
- { path: ^/login$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/puzzle/, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ Fairweather,"Vera Fairweather",18,Human,"Shop apprentice",,"Established villager
|
|||||||
Thorn,"Bramble Thorn",45,Halfling,Beekeeper,"Husband of Poppy","Established villager",Donor,Low,,
|
Thorn,"Bramble Thorn",45,Halfling,Beekeeper,"Husband of Poppy","Established villager",Donor,Low,,
|
||||||
Thorn,"Poppy Thorn",42,Halfling,Beekeeper,"Wife of Bramble","Established villager",Donor,Low,,
|
Thorn,"Poppy Thorn",42,Halfling,Beekeeper,"Wife of Bramble","Established villager",Donor,Low,,
|
||||||
Thorn,"Wren Thorn",19,Halfling,"Beekeeper apprentice",,"Established villager",Donor,Low,"Bramble Thorn","Poppy Thorn"
|
Thorn,"Wren Thorn",19,Halfling,"Beekeeper apprentice",,"Established villager",Donor,Low,"Bramble Thorn","Poppy Thorn"
|
||||||
|
Thorn,"Clover Thorn",18,Halfling,"Beekeeper apprentice","Younger sister of Wren","Established villager",Donor,Low,"Bramble Thorn","Poppy Thorn"
|
||||||
Thorn,"Bryony Thorn",8,Halfling,Child,"Younger sister of Wren","Young villager",None,Low,"Bramble Thorn","Poppy Thorn"
|
Thorn,"Bryony Thorn",8,Halfling,Child,"Younger sister of Wren","Young villager",None,Low,"Bramble Thorn","Poppy Thorn"
|
||||||
Vale,"Eamon Vale",41,Human,"Farmer / heir","The inheritance recipient, recently encountered Aldren","Established villager",Donor,Critical,,
|
Vale,"Eamon Vale",41,Human,"Farmer / heir","The inheritance recipient, recently encountered Aldren","Established villager",Donor,Critical,,
|
||||||
Vale,"Maris Vale",39,Human,Housekeeper,"Partner of Eamon, currently caring for household","Established villager",Donor,Medium,,
|
Vale,"Maris Vale",39,Human,Housekeeper,"Partner of Eamon, currently caring for household","Established villager",Donor,Medium,,
|
||||||
|
|||||||
|
@@ -1,9 +1,17 @@
|
|||||||
# dmtools stack - servers only (testing + live). Not used locally.
|
# dmtools stack - servers only (testing + live). Not used locally.
|
||||||
#
|
#
|
||||||
# Reached only through the shared Nginx Proxy Manager, which terminates TLS for
|
# Reached through Nginx Proxy Manager, which terminates TLS for
|
||||||
# dmtools.fvandenberg.nl and forwards to the `nginx` container by name over the
|
# dmtools.fvandenberg.nl and forwards to the `nginx` container by name.
|
||||||
# external `docker_default` network. Published host ports are bound to loopback
|
# NPM is not on a shared proxy network here - it is connected to each
|
||||||
# for local curl / DB-client access on the server itself.
|
# stack's own default network by hand. After the first `up`, run once:
|
||||||
|
#
|
||||||
|
# docker network connect dmtools_default nginx_app_1
|
||||||
|
#
|
||||||
|
# (NPM keeps that connection across restarts; only needed again after a
|
||||||
|
# `docker compose down` removes the network.)
|
||||||
|
#
|
||||||
|
# Published host ports are bound to loopback for local curl / DB-client
|
||||||
|
# access on the server itself.
|
||||||
#
|
#
|
||||||
# Bring up with docker/setup.sh (pins the compose project name to `dmtools`).
|
# Bring up with docker/setup.sh (pins the compose project name to `dmtools`).
|
||||||
|
|
||||||
@@ -13,10 +21,17 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
container_name: dmtools-php
|
container_name: dmtools-php
|
||||||
|
# Real container env vars always beat Symfony's own .env.$APP_ENV cascade
|
||||||
|
# (that's the point of Dotenv - it never overrides a var the platform
|
||||||
|
# already set) - so .env.prod has to be loaded here too, or its values
|
||||||
|
# (e.g. PUZZLE_RELAY_PUBLIC_URL) never reach the app; the plain .env
|
||||||
|
# default silently wins instead. Order matters: later files win.
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
- path: .env.local
|
- path: .env.local
|
||||||
required: false
|
required: false
|
||||||
|
- path: .env.prod
|
||||||
|
required: false
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/html:cached
|
- .:/var/www/html:cached
|
||||||
- php_var:/var/www/html/var
|
- php_var:/var/www/html/var
|
||||||
@@ -25,6 +40,23 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
puzzle-relay:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/php/Dockerfile
|
||||||
|
container_name: dmtools-puzzle-relay
|
||||||
|
command: ["php", "bin/console", "app:puzzle-relay"]
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
- path: .env.local
|
||||||
|
required: false
|
||||||
|
- path: .env.prod
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- .:/var/www/html:cached
|
||||||
|
- php_var:/var/www/html/var
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.30-alpine
|
image: nginx:1.30-alpine
|
||||||
container_name: dmtools-nginx
|
container_name: dmtools-nginx
|
||||||
@@ -35,9 +67,7 @@ services:
|
|||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- php
|
- php
|
||||||
networks:
|
- puzzle-relay
|
||||||
- default
|
|
||||||
- proxy
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
database:
|
database:
|
||||||
@@ -52,7 +82,7 @@ services:
|
|||||||
- --character-set-server=utf8mb4
|
- --character-set-server=utf8mb4
|
||||||
- --collation-server=utf8mb4_unicode_ci
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${DB_PORT:-3310}:3306"
|
- "127.0.0.1:${DB_HOST_PORT:-3310}:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- database_data:/var/lib/mysql
|
- database_data:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -66,10 +96,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
database_data:
|
database_data:
|
||||||
php_var:
|
php_var:
|
||||||
|
|
||||||
networks:
|
|
||||||
# Pre-existing network the server's Nginx Proxy Manager uses to reach backend
|
|
||||||
# containers. Created outside this stack - must already exist on the host.
|
|
||||||
proxy:
|
|
||||||
name: docker_default
|
|
||||||
external: true
|
|
||||||
|
|||||||
@@ -48,8 +48,26 @@ server {
|
|||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Proxies the puzzle relay's WebSocket connections (src/Command/
|
||||||
|
# PuzzleRelayCommand.php, a separate long-running container - see
|
||||||
|
# docker-compose.yml) through this same domain, so the browser can use a
|
||||||
|
# plain wss://dmtools.fvandenberg.nl/puzzle-ws/ URL instead of a second
|
||||||
|
# exposed port. NPM must also have "Websockets Support" enabled for this
|
||||||
|
# proxy host, or the Upgrade header never reaches here.
|
||||||
|
location /puzzle-ws/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $puzzle_relay dmtools-puzzle-relay:8091;
|
||||||
|
proxy_pass http://$puzzle_relay/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
}
|
||||||
|
|
||||||
error_log /var/log/nginx/dmtools_error.log;
|
error_log /var/log/nginx/dmtools_error.log;
|
||||||
access_log /var/log/nginx/dmtools_access.log;
|
access_log /var/log/nginx/dmtools_access.log;
|
||||||
|
|
||||||
client_max_body_size 20M;
|
client_max_body_size 10M;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
FROM php:8.5-fpm-alpine
|
FROM php:8.5-fpm-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache bash git unzip
|
||||||
bash \
|
|
||||||
git \
|
# Install PHP extensions with mlocati/php-extension-installer: it pulls the
|
||||||
icu-dev \
|
# right system libraries itself, builds, and cleans up - much less fragile
|
||||||
libzip-dev \
|
# than hand-maintaining apk build deps.
|
||||||
zip \
|
COPY --from=mlocati/php-extension-installer:latest /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
unzip \
|
# pcntl and posix aren't needed by php-fpm itself, but the puzzle-relay
|
||||||
$PHPIZE_DEPS \
|
# service (docker-compose.yml) runs this same image as a Workerman process,
|
||||||
&& docker-php-ext-configure intl \
|
# and Workerman requires both on Linux (its Windows-only code path, the one
|
||||||
&& docker-php-ext-install -j"$(nproc)" \
|
# used in local dev, doesn't need them - which is why this was missed there).
|
||||||
|
RUN install-php-extensions \
|
||||||
intl \
|
intl \
|
||||||
pdo_mysql \
|
pdo_mysql \
|
||||||
opcache \
|
opcache \
|
||||||
zip \
|
zip \
|
||||||
&& apk del $PHPIZE_DEPS
|
pcntl \
|
||||||
|
posix
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ memory_limit = 256M
|
|||||||
max_execution_time = 60
|
max_execution_time = 60
|
||||||
date.timezone = Europe/Amsterdam
|
date.timezone = Europe/Amsterdam
|
||||||
|
|
||||||
upload_max_filesize = 20M
|
upload_max_filesize = 8M
|
||||||
post_max_size = 21M
|
post_max_size = 10M
|
||||||
|
|
||||||
realpath_cache_size = 4096K
|
realpath_cache_size = 4096K
|
||||||
realpath_cache_ttl = 600
|
realpath_cache_ttl = 600
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ done
|
|||||||
echo "Restarting stack: $PROJECT"
|
echo "Restarting stack: $PROJECT"
|
||||||
(cd "$ROOT_DIR" && docker compose -p "$PROJECT" -f docker-compose.yml down --remove-orphans) || true
|
(cd "$ROOT_DIR" && docker compose -p "$PROJECT" -f docker-compose.yml down --remove-orphans) || true
|
||||||
|
|
||||||
docker rm -f dmtools-php dmtools-nginx dmtools-db 2>/dev/null || true
|
docker rm -f dmtools-php dmtools-puzzle-relay dmtools-nginx dmtools-db 2>/dev/null || true
|
||||||
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
|
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
|
||||||
docker network rm "$net" || true
|
docker network rm "$net" || true
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ set -euo pipefail
|
|||||||
# ./docker/setup.sh --down # stop and remove containers
|
# ./docker/setup.sh --down # stop and remove containers
|
||||||
#
|
#
|
||||||
# NGINX_PORT=8086 ./docker/setup.sh # if 8085 is already taken on this host
|
# NGINX_PORT=8086 ./docker/setup.sh # if 8085 is already taken on this host
|
||||||
# (or set NGINX_PORT / DB_PORT once in .env.local and every run picks it up)
|
# (or set NGINX_PORT / DB_HOST_PORT once in .env.local and every run picks it up)
|
||||||
|
|
||||||
ROOT_DIR=$(cd "$(dirname "$0")"/.. && pwd)
|
ROOT_DIR=$(cd "$(dirname "$0")"/.. && pwd)
|
||||||
PROJECT=dmtools
|
PROJECT=dmtools
|
||||||
|
|
||||||
for var in NGINX_PORT DB_PORT; do
|
for var in NGINX_PORT DB_HOST_PORT; do
|
||||||
if [ -z "${!var:-}" ] && grep -q "^${var}=" "$ROOT_DIR/.env.local" 2>/dev/null; then
|
if [ -z "${!var:-}" ] && grep -q "^${var}=" "$ROOT_DIR/.env.local" 2>/dev/null; then
|
||||||
export "$var=$(grep "^${var}=" "$ROOT_DIR/.env.local" | tail -1 | cut -d= -f2-)"
|
export "$var=$(grep "^${var}=" "$ROOT_DIR/.env.local" | tail -1 | cut -d= -f2-)"
|
||||||
fi
|
fi
|
||||||
@@ -65,7 +65,7 @@ fi
|
|||||||
|
|
||||||
# docker-compose v1 can choke recreating a container in place on any config
|
# docker-compose v1 can choke recreating a container in place on any config
|
||||||
# change; removing them first sidesteps that. Safe: state lives in named volumes.
|
# change; removing them first sidesteps that. Safe: state lives in named volumes.
|
||||||
dc rm -fs php nginx database 2>/dev/null || true
|
dc rm -fs php puzzle-relay nginx database 2>/dev/null || true
|
||||||
|
|
||||||
BUILD_ARGS=()
|
BUILD_ARGS=()
|
||||||
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
|
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
|
||||||
@@ -97,7 +97,7 @@ if grep -q '^APP_SECRET=$' "$ROOT_DIR/.env" 2>/dev/null \
|
|||||||
&& ! grep -q '^APP_SECRET=' "$ROOT_DIR/.env.local" 2>/dev/null; then
|
&& ! grep -q '^APP_SECRET=' "$ROOT_DIR/.env.local" 2>/dev/null; then
|
||||||
echo "Generating APP_SECRET in .env.local..."
|
echo "Generating APP_SECRET in .env.local..."
|
||||||
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
|
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
|
||||||
dc up -d php # pick up the new env value
|
dc up -d php puzzle-relay # pick up the new env value
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Creating database if it doesn't exist..."
|
echo "Creating database if it doesn't exist..."
|
||||||
@@ -114,6 +114,22 @@ pexec php bin/console asset-map:compile
|
|||||||
# php-fpm runs as www-data and must be able to write there at runtime.
|
# php-fpm runs as www-data and must be able to write there at runtime.
|
||||||
pexec chown -R www-data:www-data var
|
pexec chown -R www-data:www-data var
|
||||||
|
|
||||||
|
# php.ini sets opcache.validate_timestamps=0, so php-fpm keeps serving the
|
||||||
|
# old compiled code/templates until it restarts. Bounce it so a --no-build
|
||||||
|
# run (git pull + this script) actually picks up the new cache. puzzle-relay
|
||||||
|
# is a single long-running process, not php-fpm workers, but it's exactly as
|
||||||
|
# stale otherwise: it keeps running whatever code was loaded when it started.
|
||||||
|
dc restart php puzzle-relay
|
||||||
|
|
||||||
|
# Make sure Nginx Proxy Manager can reach this stack's nginx by name.
|
||||||
|
# Harmless (and a no-op) if already connected; NPM keeps it across restarts.
|
||||||
|
NPM_CONTAINER="${NPM_CONTAINER:-nginx_app_1}"
|
||||||
|
if docker inspect "$NPM_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
docker network connect "${PROJECT}_default" "$NPM_CONTAINER" 2>/dev/null \
|
||||||
|
&& echo "Connected $NPM_CONTAINER to ${PROJECT}_default." \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
|
||||||
APP_URL="http://localhost:${NGINX_PORT:-8085}"
|
APP_URL="http://localhost:${NGINX_PORT:-8085}"
|
||||||
cat <<EOT
|
cat <<EOT
|
||||||
|
|
||||||
@@ -121,6 +137,10 @@ Setup complete!
|
|||||||
|
|
||||||
Open the app: $APP_URL (real access is via https://dmtools.fvandenberg.nl through NPM)
|
Open the app: $APP_URL (real access is via https://dmtools.fvandenberg.nl through NPM)
|
||||||
|
|
||||||
|
NPM proxy host: scheme http, forward "dmtools-nginx" port 80.
|
||||||
|
If NPM ($NPM_CONTAINER) can't reach it, run:
|
||||||
|
docker network connect ${PROJECT}_default $NPM_CONTAINER
|
||||||
|
|
||||||
Create the first user:
|
Create the first user:
|
||||||
$DOCKER_COMPOSE -p $PROJECT exec php php bin/console app:user:create you@example.com --admin
|
$DOCKER_COMPOSE -p $PROJECT exec php php bin/console app:user:create you@example.com --admin
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260906143919 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create artwork table (uploaded art + name)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE artwork (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(200) NOT NULL, filename VARCHAR(120) NOT NULL, original_name VARCHAR(255) DEFAULT NULL, mime_type VARCHAR(100) NOT NULL, size INT NOT NULL, uploaded_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE artwork');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260906171352 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create combatant table (running HP tracker)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE combatant (id INT AUTO_INCREMENT NOT NULL, label VARCHAR(120) NOT NULL, max_hp SMALLINT NOT NULL, current_hp SMALLINT NOT NULL, position SMALLINT NOT NULL, added_at DATETIME NOT NULL, enemy_id INT DEFAULT NULL, INDEX IDX_807DA381900C982F (enemy_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE combatant ADD CONSTRAINT FK_807DA381900C982F FOREIGN KEY (enemy_id) REFERENCES enemy (id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE combatant DROP FOREIGN KEY FK_807DA381900C982F');
|
||||||
|
$this->addSql('DROP TABLE combatant');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260906172225 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add encounter table; combatants belong to an encounter';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE encounter (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(120) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||||
|
|
||||||
|
// add nullable first, rehome any pre-existing combatants, then enforce
|
||||||
|
$this->addSql('ALTER TABLE combatant ADD encounter_id INT DEFAULT NULL');
|
||||||
|
$this->addSql("INSERT INTO encounter (name, created_at, updated_at) SELECT 'Combat', NOW(), NOW() FROM DUAL WHERE EXISTS (SELECT 1 FROM combatant WHERE encounter_id IS NULL)");
|
||||||
|
$this->addSql('UPDATE combatant SET encounter_id = (SELECT MAX(id) FROM encounter) WHERE encounter_id IS NULL');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE combatant ADD CONSTRAINT FK_807DA381D6E2FADC FOREIGN KEY (encounter_id) REFERENCES encounter (id) ON DELETE CASCADE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_807DA381D6E2FADC ON combatant (encounter_id)');
|
||||||
|
$this->addSql('ALTER TABLE combatant CHANGE encounter_id encounter_id INT NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE combatant DROP FOREIGN KEY FK_807DA381D6E2FADC');
|
||||||
|
$this->addSql('DROP INDEX IDX_807DA381D6E2FADC ON combatant');
|
||||||
|
$this->addSql('ALTER TABLE combatant DROP encounter_id');
|
||||||
|
$this->addSql('DROP TABLE encounter');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260906173325 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Snapshot armor class onto each combatant';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE combatant ADD armor_class SMALLINT DEFAULT 10 NOT NULL');
|
||||||
|
$this->addSql('UPDATE combatant c JOIN enemy e ON c.enemy_id = e.id SET c.armor_class = e.armor_class');
|
||||||
|
$this->addSql('ALTER TABLE combatant ALTER armor_class DROP DEFAULT');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE combatant DROP armor_class');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260908164955 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return "Add chapter_note table for the DM's per-chapter storyline notes";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// ENGINE = InnoDB is pinned explicitly: the local WAMP MariaDB defaults
|
||||||
|
// to MyISAM, which would silently drop the unique index behaviour.
|
||||||
|
$this->addSql('CREATE TABLE chapter_note (id INT AUTO_INCREMENT NOT NULL, slug VARCHAR(120) NOT NULL, notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_chapter_note_slug (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE chapter_note');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260915151210 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add artwork.position for the drag-to-reorder gallery, backfilled by upload date';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE artwork ADD position INT NOT NULL DEFAULT 0');
|
||||||
|
|
||||||
|
// Backfill existing rows so the gallery keeps its current (upload-date) order
|
||||||
|
// until someone drags a piece; new uploads get their position in PHP.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE artwork a
|
||||||
|
JOIN (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (ORDER BY uploaded_at ASC, id ASC) AS rn
|
||||||
|
FROM artwork
|
||||||
|
) ranked ON ranked.id = a.id
|
||||||
|
SET a.position = ranked.rn
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Drop the bootstrap default now that every row has a real position;
|
||||||
|
// the mapping has no default, so schema:validate expects none either.
|
||||||
|
$this->addSql('ALTER TABLE artwork CHANGE position position INT NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE artwork DROP position');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260922064937 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add character_sheet table for player character sheets';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE character_sheet (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(160) NOT NULL, player_name VARCHAR(120) DEFAULT NULL, race VARCHAR(120) DEFAULT NULL, character_class VARCHAR(80) DEFAULT NULL, level SMALLINT NOT NULL, background VARCHAR(120) DEFAULT NULL, alignment VARCHAR(40) DEFAULT NULL, experience_points INT DEFAULT NULL, armor_class SMALLINT DEFAULT NULL, initiative SMALLINT DEFAULT NULL, speed VARCHAR(60) DEFAULT NULL, hit_point_maximum SMALLINT DEFAULT NULL, current_hit_points SMALLINT DEFAULT NULL, temporary_hit_points SMALLINT DEFAULT NULL, hit_dice VARCHAR(40) DEFAULT NULL, proficiency_bonus SMALLINT NOT NULL, inspiration TINYINT NOT NULL, strength SMALLINT NOT NULL, dexterity SMALLINT NOT NULL, constitution SMALLINT NOT NULL, intelligence SMALLINT NOT NULL, wisdom SMALLINT NOT NULL, charisma SMALLINT NOT NULL, saving_throw_proficiencies JSON NOT NULL, skill_proficiencies JSON NOT NULL, attacks JSON NOT NULL, other_proficiencies_languages LONGTEXT DEFAULT NULL, equipment LONGTEXT DEFAULT NULL, features_traits LONGTEXT DEFAULT NULL, spellcasting LONGTEXT DEFAULT NULL, personality_traits LONGTEXT DEFAULT NULL, ideals LONGTEXT DEFAULT NULL, bonds LONGTEXT DEFAULT NULL, flaws LONGTEXT DEFAULT NULL, appearance LONGTEXT DEFAULT NULL, backstory LONGTEXT DEFAULT NULL, allies_organizations LONGTEXT DEFAULT NULL, treasure LONGTEXT DEFAULT NULL, game_notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_character_name (name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE character_sheet');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters: skill expertise (double proficiency bonus).
|
||||||
|
*/
|
||||||
|
final class Version20261004104738 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add skill_expertise to character_sheet';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Existing rows need a valid JSON value before the column can be NOT NULL.
|
||||||
|
$this->addSql('ALTER TABLE character_sheet ADD skill_expertise JSON DEFAULT NULL');
|
||||||
|
$this->addSql("UPDATE character_sheet SET skill_expertise = '[]'");
|
||||||
|
$this->addSql('ALTER TABLE character_sheet MODIFY skill_expertise JSON NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE character_sheet DROP skill_expertise');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Puzzle\PuzzleResetTokenSigner;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Workerman\Connection\TcpConnection;
|
||||||
|
use Workerman\Timer;
|
||||||
|
use Workerman\Worker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small WebSocket relay that lets several browsers looking at the same
|
||||||
|
* puzzle room see each other's cursor and drag the same pieces around.
|
||||||
|
*
|
||||||
|
* Rooms live only in this process's memory (no database, no cache): if the
|
||||||
|
* relay restarts, every room resets. Run it separately from the web server,
|
||||||
|
* e.g. `symfony console app:puzzle-relay` next to `symfony serve`.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:puzzle-relay',
|
||||||
|
description: 'Runs the WebSocket relay that syncs cursors and dragged pieces across the shared puzzle screens.',
|
||||||
|
)]
|
||||||
|
final class PuzzleRelayCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Clients ping every 15s (see puzzle_room_controller.js); a connection
|
||||||
|
* that's gone quiet for longer than this is presumed dead (closed tab,
|
||||||
|
* lost network, phone asleep) and gets dropped so it stops showing up
|
||||||
|
* as a ghost cursor/piece-holder for everyone else.
|
||||||
|
*/
|
||||||
|
private const HEARTBEAT_TIMEOUT_SECONDS = 45;
|
||||||
|
|
||||||
|
/** @var array<string, array<int, TcpConnection>> room => connection id => connection */
|
||||||
|
private array $connections = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<int, array{clientId: string, name: string, color: string}>> room => connection id => presence */
|
||||||
|
private array $presence = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array{x: float, y: float}>> room => piece id => position */
|
||||||
|
private array $pieces = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'PUZZLE_RELAY_LISTEN')]
|
||||||
|
private readonly string $listenAddress,
|
||||||
|
#[Autowire('%kernel.project_dir%')]
|
||||||
|
private readonly string $projectDir,
|
||||||
|
private readonly PuzzleResetTokenSigner $resetTokenSigner,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$output->writeln("Puzzle relay listening on ws://{$this->listenAddress} (Ctrl+C to stop)");
|
||||||
|
|
||||||
|
$logDir = $this->projectDir.'/var/log';
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
mkdir($logDir, 0777, true);
|
||||||
|
}
|
||||||
|
Worker::$logFile = $logDir.'/puzzle-relay.log';
|
||||||
|
Worker::$pidFile = $logDir.'/puzzle-relay.pid';
|
||||||
|
Worker::$stdoutFile = $logDir.'/puzzle-relay-stdout.log';
|
||||||
|
// On Linux, Worker::runAll() parses start/stop/restart/... off the
|
||||||
|
// real $argv - which here holds Symfony console's own arguments
|
||||||
|
// (bin/console app:puzzle-relay), not a command it recognizes, so it
|
||||||
|
// printed its usage text and exited immediately. This is Workerman's
|
||||||
|
// own escape hatch for exactly that: getArgv() appends $command's
|
||||||
|
// words to $argv before parsing, without touching $argv itself.
|
||||||
|
// (Windows skips this argv parsing entirely, which is why local dev
|
||||||
|
// never hit it.)
|
||||||
|
Worker::$command = 'start';
|
||||||
|
|
||||||
|
$worker = new Worker("websocket://{$this->listenAddress}");
|
||||||
|
$worker->onConnect = static function (TcpConnection $connection): void {
|
||||||
|
$connection->lastSeenAt = time();
|
||||||
|
};
|
||||||
|
$worker->onMessage = $this->onMessage(...);
|
||||||
|
$worker->onClose = $this->onClose(...);
|
||||||
|
$worker->onWorkerStart = function () use ($worker): void {
|
||||||
|
Timer::add(10, fn () => $this->reapDeadConnections($worker));
|
||||||
|
};
|
||||||
|
|
||||||
|
Worker::runAll();
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onMessage(TcpConnection $connection, string $data): void
|
||||||
|
{
|
||||||
|
$message = json_decode($data, true);
|
||||||
|
if (!\is_array($message) || !\is_string($message['type'] ?? null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->lastSeenAt = time();
|
||||||
|
|
||||||
|
match ($message['type']) {
|
||||||
|
'join' => $this->handleJoin($connection, $message),
|
||||||
|
'cursor' => $this->handleCursor($connection, $message),
|
||||||
|
'piece' => $this->handlePiece($connection, $message),
|
||||||
|
'reset' => $this->handleReset($connection, $message),
|
||||||
|
'ping' => null,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reapDeadConnections(Worker $worker): void
|
||||||
|
{
|
||||||
|
$cutoff = time() - self::HEARTBEAT_TIMEOUT_SECONDS;
|
||||||
|
foreach ($worker->connections as $connection) {
|
||||||
|
if (($connection->lastSeenAt ?? 0) < $cutoff) {
|
||||||
|
$connection->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handleJoin(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $this->roomFrom($message);
|
||||||
|
if (null === $room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->room = $room;
|
||||||
|
$connection->clientId = (string) ($message['clientId'] ?? '');
|
||||||
|
$connection->name = mb_substr((string) ($message['name'] ?? 'Player'), 0, 40);
|
||||||
|
$connection->color = (string) ($message['color'] ?? '#8a8a8a');
|
||||||
|
|
||||||
|
$this->connections[$room][$connection->id] = $connection;
|
||||||
|
$this->presence[$room][$connection->id] = [
|
||||||
|
'clientId' => $connection->clientId,
|
||||||
|
'name' => $connection->name,
|
||||||
|
'color' => $connection->color,
|
||||||
|
];
|
||||||
|
|
||||||
|
$connection->send(json_encode([
|
||||||
|
'type' => 'state',
|
||||||
|
'pieces' => $this->pieces[$room] ?? new \stdClass(),
|
||||||
|
'clients' => array_values($this->presence[$room]),
|
||||||
|
], \JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$this->broadcastPresence($room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handleCursor(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
if (!\is_string($room)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->broadcast($room, [
|
||||||
|
'type' => 'cursor',
|
||||||
|
'clientId' => $connection->clientId,
|
||||||
|
'name' => $connection->name,
|
||||||
|
'color' => $connection->color,
|
||||||
|
'x' => (float) ($message['x'] ?? 0),
|
||||||
|
'y' => (float) ($message['y'] ?? 0),
|
||||||
|
], except: $connection->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handlePiece(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
$id = $message['id'] ?? null;
|
||||||
|
if (!\is_string($room) || !\is_string($id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$x = (float) ($message['x'] ?? 0);
|
||||||
|
$y = (float) ($message['y'] ?? 0);
|
||||||
|
$this->pieces[$room][$id] = ['x' => $x, 'y' => $y];
|
||||||
|
|
||||||
|
$this->broadcast($room, [
|
||||||
|
'type' => 'piece',
|
||||||
|
'id' => $id,
|
||||||
|
'x' => $x,
|
||||||
|
'y' => $y,
|
||||||
|
'phase' => \in_array($message['phase'] ?? null, ['move', 'drop'], true) ? $message['phase'] : 'move',
|
||||||
|
'clientId' => $connection->clientId,
|
||||||
|
], except: $connection->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handleReset(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
$token = $message['resetToken'] ?? null;
|
||||||
|
if (!\is_string($room) || !\is_string($token) || !$this->resetTokenSigner->verify($room, $token)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->pieces[$room]);
|
||||||
|
|
||||||
|
$this->broadcast($room, ['type' => 'reset']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onClose(TcpConnection $connection): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
if (!\is_string($room)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->connections[$room][$connection->id], $this->presence[$room][$connection->id]);
|
||||||
|
$this->broadcastPresence($room);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function broadcastPresence(string $room): void
|
||||||
|
{
|
||||||
|
$this->broadcast($room, [
|
||||||
|
'type' => 'presence',
|
||||||
|
'clients' => array_values($this->presence[$room] ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
|
private function broadcast(string $room, array $payload, ?int $except = null): void
|
||||||
|
{
|
||||||
|
$encoded = json_encode($payload, \JSON_THROW_ON_ERROR);
|
||||||
|
foreach ($this->connections[$room] ?? [] as $id => $connection) {
|
||||||
|
if ($id === $except) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$connection->send($encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function roomFrom(array $message): ?string
|
||||||
|
{
|
||||||
|
$room = $message['room'] ?? null;
|
||||||
|
|
||||||
|
return \is_string($room) && '' !== $room ? $room : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use App\Repository\CharacterRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the party's character sheets onto an environment. Unlike the enemy
|
||||||
|
* seed command this isn't reference data - it's the players' own sheets -
|
||||||
|
* but the same "pick from a list, idempotent, skip what's already there"
|
||||||
|
* mechanism is the reproducible way to get them onto testing/live without
|
||||||
|
* retyping the whole sheet into the web form.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:character:seed',
|
||||||
|
description: 'Add ready-made party character sheets to the characters table.',
|
||||||
|
)]
|
||||||
|
class SeedCharactersCommand extends Command
|
||||||
|
{
|
||||||
|
private const ADD_ALL = 'All of them';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly CharacterRepository $characters,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('names', InputArgument::IS_ARRAY, 'Character names to add (case-insensitive); prompts with a list if omitted')
|
||||||
|
->addOption('all', null, InputOption::VALUE_NONE, 'Add every available character without prompting')
|
||||||
|
->addOption('purge', null, InputOption::VALUE_NONE, 'Remove the seeded characters instead of adding them');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$catalog = [];
|
||||||
|
foreach ($this->sheets() as $row) {
|
||||||
|
$catalog[$row['name']] = $row;
|
||||||
|
}
|
||||||
|
$available = array_keys($catalog);
|
||||||
|
|
||||||
|
if ($input->getOption('purge')) {
|
||||||
|
$removed = 0;
|
||||||
|
foreach ($available as $name) {
|
||||||
|
if ($c = $this->characters->findOneBy(['name' => $name])) {
|
||||||
|
$this->em->remove($c);
|
||||||
|
++$removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
$io->success(sprintf('Removed %d seeded character(s).', $removed));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected = $this->resolveSelection($input, $io, $available);
|
||||||
|
if (null === $selected) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$added = $skipped = 0;
|
||||||
|
foreach ($selected as $name) {
|
||||||
|
if ($this->characters->findOneBy(['name' => $name])) {
|
||||||
|
++$skipped;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->em->persist($this->hydrate($catalog[$name]));
|
||||||
|
++$added;
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$io->success(sprintf('%d character(s) added, %d already present.', $added, $skipped));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $available
|
||||||
|
*
|
||||||
|
* @return list<string>|null the chosen names, or null on an unknown-name error
|
||||||
|
*/
|
||||||
|
private function resolveSelection(InputInterface $input, SymfonyStyle $io, array $available): ?array
|
||||||
|
{
|
||||||
|
/** @var list<string> $requested */
|
||||||
|
$requested = $input->getArgument('names');
|
||||||
|
|
||||||
|
if ($requested) {
|
||||||
|
$selected = [];
|
||||||
|
foreach ($requested as $name) {
|
||||||
|
$match = null;
|
||||||
|
foreach ($available as $known) {
|
||||||
|
if (0 === strcasecmp($known, $name)) {
|
||||||
|
$match = $known;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (null === $match) {
|
||||||
|
$io->error(sprintf('Unknown character "%s". Available: %s', $name, implode(', ', $available)));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$selected[] = $match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($selected));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption('all') || !$input->isInteractive()) {
|
||||||
|
return $available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string> $choice */
|
||||||
|
$choice = $io->choice('Which character(s) do you want to add?', [...$available, self::ADD_ALL], null, true);
|
||||||
|
|
||||||
|
return \in_array(self::ADD_ALL, $choice, true) ? $available : $choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function hydrate(array $row): Character
|
||||||
|
{
|
||||||
|
$c = new Character();
|
||||||
|
foreach ($row as $prop => $value) {
|
||||||
|
$c->{'set'.ucfirst($prop)}($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sheets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'name' => 'Darragh Fermwyr', 'playerName' => 'PH',
|
||||||
|
'race' => 'Halfling, stout', 'characterClass' => 'Ranger', 'level' => 2,
|
||||||
|
'background' => 'Urchin', 'alignment' => 'CN',
|
||||||
|
'armorClass' => 15, 'initiative' => 3, 'speed' => '25 ft.',
|
||||||
|
'hitPointMaximum' => 16, 'hitDice' => '2d10',
|
||||||
|
'proficiencyBonus' => 2,
|
||||||
|
'strength' => 12, 'dexterity' => 17, 'constitution' => 14,
|
||||||
|
'intelligence' => 8, 'wisdom' => 13, 'charisma' => 12,
|
||||||
|
'savingThrowProficiencies' => ['Strength', 'Dexterity'],
|
||||||
|
'skillProficiencies' => ['Athletics', 'Perception', 'Sleight of Hand', 'Stealth', 'Survival'],
|
||||||
|
'skillExpertise' => ['Stealth'],
|
||||||
|
'attacks' => [
|
||||||
|
['name' => 'Dart', 'bonus' => '+5', 'damage' => '1d4+5', 'range' => '20/60 ft. (×10 carried)'],
|
||||||
|
['name' => 'Dagger', 'bonus' => '+5', 'damage' => '1d4+3(5)', 'range' => '20/60 ft. (×3 carried)'],
|
||||||
|
['name' => 'Spear', 'bonus' => '+3', 'damage' => '1d8(6)+1(3)', 'range' => '20/60 ft. (×1 carried)'],
|
||||||
|
],
|
||||||
|
'otherProficienciesLanguages' => "Languages: Halfling, Common, Axion, Doile\n\nTools: Disguise kit, Thieves' tools",
|
||||||
|
'equipment' => "Small knife\nMap of the city\nPet bees\nToken of parents\nCommon clothes\nStudded leather armor\nSpear\n10 darts\n3 daggers\nExplorer's pack",
|
||||||
|
'featuresTraits' => "Stout Resilience\nLucky\nBrave\nNimble\n\nCity Secrets\n\nFavored Foe\nDeft Explorer (expertise in Stealth)\nFighting Style: Throwing\nSpellcasting",
|
||||||
|
'spellcasting' => "Spellcasting ability: Wisdom (spell save DC 11, spell attack bonus +3)\n\nLevel 1 (2 slots): Hunter's Mark, Beast Bond",
|
||||||
|
'personalityTraits' => 'I eat like a pig and have bad manners',
|
||||||
|
'ideals' => "I help the people who help me, that's what keeps us alive",
|
||||||
|
'bonds' => "My town is my home and I'll fight to defend it",
|
||||||
|
'flaws' => "It's not stealing if I need it more than someone else",
|
||||||
|
'appearance' => "Age 18, 3'0\", 40 lbs. Brown eyes, brown hair.",
|
||||||
|
'treasure' => '10 gp',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Owayn Fermwyr', 'playerName' => 'BJ',
|
||||||
|
'race' => 'Halfling, stout', 'characterClass' => 'Fighter', 'level' => 1,
|
||||||
|
'background' => 'Folk Hero', 'alignment' => 'NG',
|
||||||
|
'armorClass' => 18, 'initiative' => 2, 'speed' => '25 ft.',
|
||||||
|
'hitPointMaximum' => 13, 'hitDice' => '1d10',
|
||||||
|
'proficiencyBonus' => 2,
|
||||||
|
'strength' => 15, 'dexterity' => 14, 'constitution' => 16,
|
||||||
|
'intelligence' => 8, 'wisdom' => 13, 'charisma' => 8,
|
||||||
|
'savingThrowProficiencies' => ['Strength', 'Constitution'],
|
||||||
|
'skillProficiencies' => ['Animal Handling', 'Athletics', 'Perception', 'Survival'],
|
||||||
|
'attacks' => [
|
||||||
|
['name' => 'Warhammer', 'bonus' => '+4', 'damage' => '1d8+4', 'range' => 'Melee, versatile (1d10)'],
|
||||||
|
['name' => 'Light crossbow', 'bonus' => '+4', 'damage' => '1d8+2', 'range' => '80/320 ft.'],
|
||||||
|
['name' => 'Handaxe', 'bonus' => '+4', 'damage' => '1d6+4', 'range' => '20/60 ft. (×2 carried)'],
|
||||||
|
],
|
||||||
|
'otherProficienciesLanguages' => "Languages: Common, Halfling, Axion\n\nTools: Tinker's tools, Land vehicles",
|
||||||
|
'equipment' => "Chain mail (disadvantage on Stealth)\nShield\nBackpack\nBedroll\nMess kit\nTinderbox\n10 torches\n10 rations\nWaterskin\n50 ft. hempen rope\nTinker's tools\nShovel\nIron pot\nCommon clothes",
|
||||||
|
'featuresTraits' => "Lucky - When you roll a 1, reroll and take the new result\nBrave - Advantage against being frightened\nNimbleness - You can move through the space of any creature larger than you\nStout Resilience - Advantage on saves against poison, resistance against poison damage\n\nRustic Hospitality\n\nFighting Style: Dueling\nSecond Wind - Once per short rest, heal 1d10 + level as a bonus action",
|
||||||
|
'personalityTraits' => 'Thinking is for other people, I prefer action.',
|
||||||
|
'ideals' => 'I have to protect what I am responsible for.',
|
||||||
|
'bonds' => 'I protect my family no matter what.',
|
||||||
|
'flaws' => 'I often act before I think.',
|
||||||
|
'appearance' => 'Age 24. A large scar runs over the middle of his forehead, the top of his nose and his right cheek.',
|
||||||
|
'backstory' => "Owayn was born 24 years ago as the oldest of a set of twins (together with Aoife). This was a joyous occasion as twins mean good luck among the Oakwanderers.\n\nIn his early youth was nothing special, although his development was a bit slower than that of Aoife. He startes walking later, started to talk later and with many other skills it took him longer to master is. This made him let his sisters (Aoife and Noirhin (Nora)) do the talking when needed. Not that he was shy shy he just knew his sisters where better in this.\n\nFrom an early early age like liked being outside and helping his father and uncles around the farm. Lifting, carrying and fixing things. He look a particular liking to herding the families sheep and goats.\n\nAs the family grew and the house got more crowded Owayn often set out for longer tracks and sometimes even slept outside. When not busy on the farm he often went to the training court of the village to train with weapons as Penthorfils is a dangerous area.\nAs soon as he was of the appropriate age he joined the city militia and on times when there is less work on the farm he will join patrols. The family provided him with the gear for joining, paid by giving up their spot in line.\n\nHe tried to teach his younger sibling how to use weapons, but the results were mixed.\n\nWhen he was sixteen he took the goats and sheep to a particular meadow in the mountain, where certain herbs frow that make the goats milk taste better. On the second day on of his shepherd dogs, named Greyfur, started to bark aggressively. Owayn hurried toward the dog and saw a pack of wolves stalking the herd.\nThe pack of wolves was not deterred by the usual shouting and barking and attacked. In a vicious fight Owayn managed to kill the leader of the pack and the other dogs managed to chased of the rest. Unfortunately Greyfur died, something that Owayn still regrets.\nWounded Owayn let the herd back to village, where he was heralded as a hero for a short while. A large scar running over the middle of his forehead and over the top of his nose and over his right cheek is a reminder of this deed.\n\nDuring take time he was flirting with Carysh Gwehyd and after the fight with the wolf this seems to turning to love. But this slowly fizzled out as time went on. Carysh was deterred by the scar and a more redrawn Owayn trying to cope with the loss of Greyfur. Since that Owayn hasn't been in a relationship.",
|
||||||
|
'treasure' => '10 gp',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Dunnchadh', 'playerName' => 'Rick',
|
||||||
|
'race' => 'Halfling, ghostwise', 'characterClass' => 'Druid (Circle of the Blighted)', 'level' => 2,
|
||||||
|
'background' => 'The misunderstood', 'experiencePoints' => 300,
|
||||||
|
'armorClass' => 15, 'initiative' => 2, 'speed' => '25 ft.',
|
||||||
|
'hitPointMaximum' => 17, 'hitDice' => '2d8',
|
||||||
|
'proficiencyBonus' => 2,
|
||||||
|
'strength' => 8, 'dexterity' => 14, 'constitution' => 14,
|
||||||
|
'intelligence' => 14, 'wisdom' => 16, 'charisma' => 8,
|
||||||
|
'savingThrowProficiencies' => ['Intelligence', 'Wisdom'],
|
||||||
|
'skillProficiencies' => ['Intimidation', 'Nature', 'Perception', 'Sleight of Hand', 'Stealth'],
|
||||||
|
'attacks' => [
|
||||||
|
['name' => 'Thorn Whip', 'bonus' => '+5', 'damage' => '1d6 piercing', 'range' => 'Melee, 30 ft. (pull up to 10 ft. closer)'],
|
||||||
|
['name' => 'Primal Savagery', 'bonus' => '+5', 'damage' => '1d10 acid', 'range' => 'Melee (5 ft.)'],
|
||||||
|
],
|
||||||
|
'otherProficienciesLanguages' => "Languages: Common, Druidic, Halfling\n\nTools: Herbalism kit, Thieves' tools, Disguise kit\n\nArmor: Light, medium, shields\n\nWeapons: Club, dagger, dart, javelin, mace, quarterstaff, scimitar, sickle, sling, spear",
|
||||||
|
'equipment' => "Leather armor\nWooden shield\nMess kit\nBackpack, with:\n- Bedroll\n- 2 costumes\n- Thieves' tools\n- 5 days of rations\n- Waterskin\n- Disguise kit\n- Tinderbox\n- 5 torches\n- 50 ft. hempen rope",
|
||||||
|
'featuresTraits' => "Ghostwise Halfling\nLucky - When I roll a 1 on an attack roll, ability check, or saving throw, I can reroll the die and must use the new roll\nBrave - Advantage on saves against being frightened\nHalfling Nimbleness - I can move through the space of any creature that is of a size larger than me\nSilent Speech - I can speak telepathically to any one creature within 30 feet of me. It only understands me if we share a language\n\nFalse Identity - I have created a second identity that includes documentation, established acquaintances, and disguises that allow me to assume that persona. I can forge documents, including official papers and personal letters, as long as I have seen an example of the kind of document or the handwriting I am trying to copy\n\nDruidic\nBlighted Shape - Proficiency in Intimidation; my Wild Shape forms gain +2 AC and 60 ft. darkvision (or +60 ft. if they already have it)\nDefile Ground (1/short rest) - Bonus action: blight a 10 ft. radius within 60 ft. for 1 minute; difficult terrain for enemies, and creatures on it take an extra 1d4 necrotic the first time they're hit by an attack each turn. Bonus action to move it up to 30 ft.\nWild Shape (2/short rest) - Max CR 1/4, no flying or swimming speed, up to 1 hour",
|
||||||
|
'spellcasting' => "Spellcasting ability: Wisdom (spell save DC 13, spell attack bonus +5)\n\nCantrips: Thorn Whip, Primal Savagery\n\nLevel 1 (3 slots): prepares 5 spells from the druid list",
|
||||||
|
'personalityTraits' => 'I like to watch things wither and decay. It makes me feel alive.',
|
||||||
|
'ideals' => 'Ashes to ashes, dust to dust. The cycle must always continue.',
|
||||||
|
'bonds' => 'Better to be misunderstood than to forfeit my beliefs.',
|
||||||
|
'flaws' => 'I will be passive aggressive.',
|
||||||
|
'appearance' => 'Also goes by Duncan.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Aoife Fermwyr', 'playerName' => 'Gerdien',
|
||||||
|
'race' => 'Halfling, Mark of Hospitality', 'characterClass' => 'Paladin (Oath of the Ancients)', 'level' => 2,
|
||||||
|
'background' => 'City Watch', 'experiencePoints' => 300,
|
||||||
|
'armorClass' => 18, 'initiative' => 0, 'speed' => '25 ft.',
|
||||||
|
'hitPointMaximum' => 21, 'hitDice' => '2d10',
|
||||||
|
'proficiencyBonus' => 2,
|
||||||
|
'strength' => 14, 'dexterity' => 10, 'constitution' => 14,
|
||||||
|
'intelligence' => 8, 'wisdom' => 12, 'charisma' => 16,
|
||||||
|
'savingThrowProficiencies' => ['Wisdom', 'Charisma'],
|
||||||
|
'skillProficiencies' => ['Athletics', 'Insight', 'Medicine', 'Persuasion'],
|
||||||
|
'attacks' => [
|
||||||
|
['name' => 'Warhammer', 'bonus' => '+4', 'damage' => '1d8+2 bludgeoning', 'range' => 'Melee, versatile (1d10)'],
|
||||||
|
['name' => 'Javelin', 'bonus' => '+4', 'damage' => '1d6+2 piercing', 'range' => 'Melee or 30/120 ft. (×5 carried)'],
|
||||||
|
['name' => 'Dagger', 'bonus' => '+4', 'damage' => '1d4+2 piercing', 'range' => 'Melee or 20/60 ft. (finesse, light)'],
|
||||||
|
],
|
||||||
|
'otherProficienciesLanguages' => "Languages: Common, Halfling, Elven, Axion\n\nArmor: Light, medium, heavy, shields\n\nWeapons: Simple, martial",
|
||||||
|
'equipment' => "Chain mail (disadvantage on Stealth)\nShield\nWarhammer\n5 javelins\nHoly symbol\nCook's utensils\nUniform of my unit\nInsignia of rank\nHorn\nManacles\nBelt pouch (with coins)\nBackpack, with:\n- Bedroll\n- Mess kit\n- Tinderbox\n- 10 torches\n- Rations\n- Waterskin\n- 50 ft. hempen rope",
|
||||||
|
'featuresTraits' => "Halfling, Dragonmark of Hospitality\nLucky - When I roll a 1 on an attack, check, or save, I can reroll it but must use the new roll\nBrave - Advantage on saves against being frightened\nHalfling Nimbleness - I can move through the space of Medium and larger creatures\nEver Hospitable - I add +1d4 to Charisma (Persuasion), brewer's supplies and cook's utensils checks\nInnkeeper's Magic - I know Prestidigitation, and can cast Purify Food and Drink and Unseen Servant once each per long rest (Charisma)\nSpells of the Mark - Extra spells on my class spell list\n\nWatcher's Eye - My experience in enforcing the law, and dealing with lawbreakers, gives me a feel for local laws and criminals. I can easily find the local outpost of the watch, guards or a similar organization, and just as easily pick out the dens of criminal activity in a community. I am far more likely to be welcome in the former locations rather than the latter, however.\n\nDivine Sense (4/long rest) - Action: until the end of my next turn, sense celestials, fiends and undead, and consecrated or desecrated places within 60 ft. that aren't behind total cover\nLay on Hands (pool of 10 HP/long rest) - Action: heal a touched living creature from the pool, or spend 5 points to neutralize a poison or disease\nDivine Smite - When I hit with a melee weapon attack, expend a spell slot for +2d8 radiant damage, +1d8 per slot level above 1st and +1d8 against undead and fiends\nFighting Style: Protection - Reaction: impose disadvantage on an attack against someone within 5 ft. of me, while I wield a shield and can see the attacker\nSpellcasting - Prepared paladin spells, Charisma, holy symbol as focus\n\nOptional: Additional Paladin Spells (TCoE 52), Dunamancy Spells (EGtW 186) - each still pending DM's approval",
|
||||||
|
'spellcasting' => "Spellcasting ability: Charisma (spell save DC 13, spell attack bonus +5)\n\nPaladin, level 1 (2 slots, 4 to prepare): Bless, Protection from Evil and Good, Shield of Faith\n\nHalfling (Innkeeper's Magic): Prestidigitation (cantrip); Purify Food and Drink, Unseen Servant (1/long rest each)",
|
||||||
|
'personalityTraits' => "I'm always respectful and polite. I approach problems head-on. A simple, direct course is the best path to a solution.",
|
||||||
|
'ideals' => 'Greater Good: Our fate is to give our lives in the defense of others. (Good)',
|
||||||
|
'bonds' => 'I fight for those who cannot fight for themselves.',
|
||||||
|
'flaws' => 'A disastrous mistake I made in battle cost many lives - I will do anything to keep that mistake a secret.',
|
||||||
|
'treasure' => '25 gp',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// His sheet is still sparse; this is everything it has so far. AC 16
|
||||||
|
// reads the sheet's "14+2" as scale mail + shield; no shield is listed yet.
|
||||||
|
'name' => 'Ruadhri', 'playerName' => 'Adriaan',
|
||||||
|
'race' => 'Halfling, stout', 'characterClass' => 'Cleric', 'level' => 2,
|
||||||
|
'background' => 'Hermit',
|
||||||
|
'armorClass' => 16, 'initiative' => 0, 'speed' => '25 ft.',
|
||||||
|
'hitPointMaximum' => 19, 'hitDice' => '2d8',
|
||||||
|
'proficiencyBonus' => 2,
|
||||||
|
'strength' => 10, 'dexterity' => 10, 'constitution' => 15,
|
||||||
|
'intelligence' => 15, 'wisdom' => 15, 'charisma' => 8,
|
||||||
|
'savingThrowProficiencies' => ['Wisdom', 'Charisma'],
|
||||||
|
'skillProficiencies' => ['Insight', 'Medicine', 'Nature', 'Persuasion', 'Religion'],
|
||||||
|
'attacks' => [
|
||||||
|
['name' => 'Mace', 'bonus' => '+2', 'damage' => '1d6 bludgeoning', 'range' => 'Melee'],
|
||||||
|
['name' => 'Quarterstaff', 'bonus' => '+2', 'damage' => '1d6/1d8 bludgeoning', 'range' => 'Melee, versatile'],
|
||||||
|
['name' => 'Spell attack', 'bonus' => '+4', 'damage' => 'Save DC 12', 'range' => ''],
|
||||||
|
],
|
||||||
|
'otherProficienciesLanguages' => "Languages: Common, Halfling, Sylvan\n\nTools: Herbalism kit",
|
||||||
|
'equipment' => "Scale mail\nScroll case\nWinter blanket\nCommon clothes\nHerbalism kit\nExplorer's pack",
|
||||||
|
'spellcasting' => "Spellcasting ability: Wisdom (spell save DC 12, spell attack bonus +4)\n\nCantrips: Guidance, Thaumaturgy, Light, Shillelagh",
|
||||||
|
'treasure' => "17 gp\nHerbs worth 60 gp\n2 recipes",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Enemy;
|
||||||
|
use App\Enum\CreatureSize;
|
||||||
|
use App\Enum\CreatureType;
|
||||||
|
use App\Repository\EnemyRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:enemy:seed',
|
||||||
|
description: 'Add ready-made creatures (SRD monsters and homebrew) to the enemies table.',
|
||||||
|
)]
|
||||||
|
class SeedEnemiesCommand extends Command
|
||||||
|
{
|
||||||
|
private const ADD_ALL = 'All of them';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly EnemyRepository $enemies,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('names', InputArgument::IS_ARRAY, 'Creature names to add (case-insensitive); prompts with a list if omitted')
|
||||||
|
->addOption('all', null, InputOption::VALUE_NONE, 'Add every available creature without prompting')
|
||||||
|
->addOption('purge', null, InputOption::VALUE_NONE, 'Remove the seeded creatures instead of adding them');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$catalog = [];
|
||||||
|
foreach ($this->creatures() as $row) {
|
||||||
|
$catalog[$row['name']] = $row;
|
||||||
|
}
|
||||||
|
$available = array_keys($catalog);
|
||||||
|
|
||||||
|
if ($input->getOption('purge')) {
|
||||||
|
$removed = 0;
|
||||||
|
foreach ($available as $name) {
|
||||||
|
if ($e = $this->enemies->findOneBy(['name' => $name])) {
|
||||||
|
$this->em->remove($e);
|
||||||
|
++$removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
$io->success(sprintf('Removed %d seeded creature(s).', $removed));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected = $this->resolveSelection($input, $io, $available);
|
||||||
|
if (null === $selected) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$added = $skipped = 0;
|
||||||
|
foreach ($selected as $name) {
|
||||||
|
if ($this->enemies->findOneBy(['name' => $name])) {
|
||||||
|
++$skipped;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->em->persist($this->hydrate($catalog[$name]));
|
||||||
|
++$added;
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$io->success(sprintf('%d creature(s) added, %d already present.', $added, $skipped));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $available
|
||||||
|
*
|
||||||
|
* @return list<string>|null the chosen names, or null on an unknown-name error
|
||||||
|
*/
|
||||||
|
private function resolveSelection(InputInterface $input, SymfonyStyle $io, array $available): ?array
|
||||||
|
{
|
||||||
|
/** @var list<string> $requested */
|
||||||
|
$requested = $input->getArgument('names');
|
||||||
|
|
||||||
|
if ($requested) {
|
||||||
|
$selected = [];
|
||||||
|
foreach ($requested as $name) {
|
||||||
|
$match = null;
|
||||||
|
foreach ($available as $known) {
|
||||||
|
if (0 === strcasecmp($known, $name)) {
|
||||||
|
$match = $known;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (null === $match) {
|
||||||
|
$io->error(sprintf('Unknown creature "%s". Available: %s', $name, implode(', ', $available)));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$selected[] = $match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($selected));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption('all') || !$input->isInteractive()) {
|
||||||
|
return $available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string> $choice */
|
||||||
|
$choice = $io->choice('Which creature(s) do you want to add?', [...$available, self::ADD_ALL], null, true);
|
||||||
|
|
||||||
|
return \in_array(self::ADD_ALL, $choice, true) ? $available : $choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function hydrate(array $row): Enemy
|
||||||
|
{
|
||||||
|
$e = new Enemy();
|
||||||
|
foreach ($row as $prop => $value) {
|
||||||
|
$e->{'set'.ucfirst($prop)}(
|
||||||
|
'size' === $prop ? CreatureSize::from($value)
|
||||||
|
: ('type' === $prop ? CreatureType::from($value) : $value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ready-made stat blocks, trimmed to what the app stores. SRD 5.1 entries are
|
||||||
|
* open content; "Homebrew" entries are campaign-specific.
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function creatures(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'name' => 'Goblin', 'size' => 'Small', 'type' => 'Humanoid', 'subtype' => 'goblinoid',
|
||||||
|
'alignment' => 'neutral evil', 'armorClass' => 15, 'armorClassNote' => 'leather armor, shield',
|
||||||
|
'hitPoints' => 7, 'hitDice' => '2d6', 'speed' => '30 ft.',
|
||||||
|
'strength' => 8, 'dexterity' => 14, 'constitution' => 10, 'intelligence' => 10, 'wisdom' => 8, 'charisma' => 8,
|
||||||
|
'challengeRating' => '1/4', 'experiencePoints' => 50, 'proficiencyBonus' => 2,
|
||||||
|
'skills' => 'Stealth +6', 'senses' => 'darkvision 60 ft., passive Perception 9',
|
||||||
|
'languages' => 'Common, Goblin',
|
||||||
|
'traits' => "Nimble Escape. The goblin can take the Disengage or Hide action as a bonus action on each of its turns.",
|
||||||
|
'actions' => "Scimitar. Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 5 (1d6 + 2) slashing damage.\nShortbow. Ranged Weapon Attack: +4 to hit, range 80/320 ft., one target. Hit: 5 (1d6 + 2) piercing damage.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Wolf', 'size' => 'Medium', 'type' => 'Beast', 'alignment' => 'unaligned',
|
||||||
|
'armorClass' => 13, 'armorClassNote' => 'natural armor', 'hitPoints' => 11, 'hitDice' => '2d8 + 2', 'speed' => '40 ft.',
|
||||||
|
'strength' => 12, 'dexterity' => 15, 'constitution' => 12, 'intelligence' => 3, 'wisdom' => 12, 'charisma' => 6,
|
||||||
|
'challengeRating' => '1/4', 'experiencePoints' => 50, 'proficiencyBonus' => 2,
|
||||||
|
'skills' => 'Perception +3, Stealth +4', 'senses' => 'passive Perception 13',
|
||||||
|
'traits' => "Keen Hearing and Smell. The wolf has advantage on Wisdom (Perception) checks that rely on hearing or smell.\nPack Tactics. The wolf has advantage on an attack roll against a creature if at least one of the wolf's allies is within 5 ft. of the creature and the ally isn't incapacitated.",
|
||||||
|
'actions' => "Bite. Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 7 (2d4 + 2) piercing damage. If the target is a creature, it must succeed on a DC 11 Strength saving throw or be knocked prone.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Boar', 'size' => 'Medium', 'type' => 'Beast', 'alignment' => 'unaligned',
|
||||||
|
'armorClass' => 11, 'armorClassNote' => 'natural armor', 'hitPoints' => 11, 'hitDice' => '2d8 + 2', 'speed' => '40 ft.',
|
||||||
|
'strength' => 13, 'dexterity' => 11, 'constitution' => 12, 'intelligence' => 2, 'wisdom' => 9, 'charisma' => 5,
|
||||||
|
'challengeRating' => '1/4', 'experiencePoints' => 50, 'proficiencyBonus' => 2,
|
||||||
|
'senses' => 'passive Perception 9',
|
||||||
|
'traits' => "Charge. If the boar moves at least 20 ft. straight toward a target and then hits it with a tusk attack on the same turn, the target takes an extra 3 (1d6) slashing damage. If the target is a creature, it must succeed on a DC 11 Strength saving throw or be knocked prone.\nRelentless (Recharges after a Short or Long Rest). If the boar takes 7 damage or less that would reduce it to 0 hit points, it is reduced to 1 hit point instead.",
|
||||||
|
'actions' => "Tusk. Melee Weapon Attack: +3 to hit, reach 5 ft., one target. Hit: 4 (1d6 + 1) slashing damage.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Zombie', 'size' => 'Medium', 'type' => 'Undead', 'alignment' => 'neutral evil',
|
||||||
|
'armorClass' => 8, 'hitPoints' => 22, 'hitDice' => '3d8 + 9', 'speed' => '20 ft.',
|
||||||
|
'strength' => 13, 'dexterity' => 6, 'constitution' => 16, 'intelligence' => 3, 'wisdom' => 6, 'charisma' => 5,
|
||||||
|
'challengeRating' => '1/4', 'experiencePoints' => 50, 'proficiencyBonus' => 2,
|
||||||
|
'savingThrows' => 'Wis +0', 'damageImmunities' => 'poison', 'conditionImmunities' => 'poisoned',
|
||||||
|
'senses' => 'darkvision 60 ft., passive Perception 8',
|
||||||
|
'languages' => "understands the languages it knew in life but can't speak",
|
||||||
|
'traits' => "Undead Fortitude. If damage reduces the zombie to 0 hit points, it must make a Constitution saving throw with a DC of 5 + the damage taken, unless the damage is radiant or from a critical hit. On a success, the zombie drops to 1 hit point instead.",
|
||||||
|
'actions' => "Slam. Melee Weapon Attack: +3 to hit, reach 5 ft., one target. Hit: 4 (1d6 + 1) bludgeoning damage.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Bandit Captain', 'size' => 'Medium', 'type' => 'Humanoid', 'alignment' => 'any non-lawful alignment',
|
||||||
|
'armorClass' => 15, 'armorClassNote' => 'studded leather', 'hitPoints' => 65, 'hitDice' => '10d8 + 20', 'speed' => '30 ft.',
|
||||||
|
'strength' => 15, 'dexterity' => 16, 'constitution' => 14, 'intelligence' => 14, 'wisdom' => 11, 'charisma' => 14,
|
||||||
|
'challengeRating' => '2', 'experiencePoints' => 450, 'proficiencyBonus' => 2,
|
||||||
|
'savingThrows' => 'Str +4, Dex +5, Wis +2', 'skills' => 'Athletics +4, Deception +4',
|
||||||
|
'senses' => 'passive Perception 10', 'languages' => 'any two languages',
|
||||||
|
'actions' => "Multiattack. The captain makes three melee attacks: two with its scimitar and one with its dagger. Or the captain makes two ranged attacks with its daggers.\nScimitar. Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 6 (1d6 + 3) slashing damage.\nDagger. Melee or Ranged Weapon Attack: +5 to hit, reach 5 ft. or range 20/60 ft., one target. Hit: 5 (1d4 + 3) piercing damage.",
|
||||||
|
'reactions' => "Parry. The captain adds 2 to its AC against one melee attack that would hit it. To do so, the captain must see the attacker and be wielding a melee weapon.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Ogre', 'size' => 'Large', 'type' => 'Giant', 'alignment' => 'chaotic evil',
|
||||||
|
'armorClass' => 11, 'armorClassNote' => 'hide armor', 'hitPoints' => 59, 'hitDice' => '7d10 + 21', 'speed' => '40 ft.',
|
||||||
|
'strength' => 19, 'dexterity' => 8, 'constitution' => 16, 'intelligence' => 5, 'wisdom' => 7, 'charisma' => 7,
|
||||||
|
'challengeRating' => '2', 'experiencePoints' => 450, 'proficiencyBonus' => 2,
|
||||||
|
'senses' => 'darkvision 60 ft., passive Perception 8', 'languages' => 'Common, Giant',
|
||||||
|
'actions' => "Greatclub. Melee Weapon Attack: +6 to hit, reach 5 ft., one target. Hit: 13 (2d8 + 4) bludgeoning damage.\nJavelin. Melee or Ranged Weapon Attack: +6 to hit, reach 5 ft. or range 30/120 ft., one target. Hit: 11 (2d6 + 4) piercing damage.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Owlbear', 'size' => 'Large', 'type' => 'Monstrosity', 'alignment' => 'unaligned',
|
||||||
|
'armorClass' => 13, 'armorClassNote' => 'natural armor', 'hitPoints' => 59, 'hitDice' => '7d10 + 21', 'speed' => '40 ft.',
|
||||||
|
'strength' => 20, 'dexterity' => 12, 'constitution' => 17, 'intelligence' => 3, 'wisdom' => 12, 'charisma' => 7,
|
||||||
|
'challengeRating' => '3', 'experiencePoints' => 700, 'proficiencyBonus' => 2,
|
||||||
|
'skills' => 'Perception +3', 'senses' => 'darkvision 60 ft., passive Perception 13',
|
||||||
|
'traits' => "Keen Sight and Smell. The owlbear has advantage on Wisdom (Perception) checks that rely on sight or smell.",
|
||||||
|
'actions' => "Multiattack. The owlbear makes two attacks: one with its beak and one with its claws.\nBeak. Melee Weapon Attack: +7 to hit, reach 5 ft., one creature. Hit: 10 (1d10 + 5) piercing damage.\nClaws. Melee Weapon Attack: +7 to hit, reach 5 ft., one target. Hit: 14 (2d8 + 5) slashing damage.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Wight', 'size' => 'Medium', 'type' => 'Undead', 'alignment' => 'neutral evil',
|
||||||
|
'armorClass' => 14, 'armorClassNote' => 'studded leather', 'hitPoints' => 45, 'hitDice' => '6d8 + 18', 'speed' => '30 ft.',
|
||||||
|
'strength' => 15, 'dexterity' => 14, 'constitution' => 16, 'intelligence' => 10, 'wisdom' => 13, 'charisma' => 15,
|
||||||
|
'challengeRating' => '3', 'experiencePoints' => 700, 'proficiencyBonus' => 2,
|
||||||
|
'skills' => 'Perception +3, Stealth +4',
|
||||||
|
'damageResistances' => "necrotic; bludgeoning, piercing, and slashing from nonmagical attacks that aren't silvered",
|
||||||
|
'damageImmunities' => 'poison', 'conditionImmunities' => 'exhaustion, poisoned',
|
||||||
|
'senses' => 'darkvision 60 ft., passive Perception 13',
|
||||||
|
'languages' => 'the languages it knew in life',
|
||||||
|
'traits' => "Sunlight Sensitivity. While in sunlight, the wight has disadvantage on attack rolls, as well as on Wisdom (Perception) checks that rely on sight.",
|
||||||
|
'actions' => "Multiattack. The wight makes two longsword attacks or two longbow attacks. It can use its Life Drain in place of one longsword attack.\nLife Drain. Melee Weapon Attack: +4 to hit, reach 5 ft., one creature. Hit: 5 (1d6 + 2) necrotic damage. The target must succeed on a DC 13 Constitution saving throw or its hit point maximum is reduced by an amount equal to the damage taken. This reduction lasts until the target finishes a long rest. The target dies if this effect reduces its hit point maximum to 0.\nLongsword. Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 6 (1d8 + 2) slashing damage, or 7 (1d10 + 2) slashing damage if used with two hands.\nLongbow. Ranged Weapon Attack: +4 to hit, range 150/600 ft., one target. Hit: 6 (1d8 + 2) piercing damage.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Adult Red Dragon', 'size' => 'Huge', 'type' => 'Dragon', 'alignment' => 'chaotic evil',
|
||||||
|
'armorClass' => 19, 'armorClassNote' => 'natural armor', 'hitPoints' => 256, 'hitDice' => '19d12 + 133',
|
||||||
|
'speed' => '40 ft., climb 40 ft., fly 80 ft.',
|
||||||
|
'strength' => 27, 'dexterity' => 10, 'constitution' => 25, 'intelligence' => 16, 'wisdom' => 13, 'charisma' => 21,
|
||||||
|
'challengeRating' => '17', 'experiencePoints' => 18000, 'proficiencyBonus' => 6,
|
||||||
|
'savingThrows' => 'Dex +6, Con +13, Wis +7, Cha +11', 'skills' => 'Perception +13, Stealth +6',
|
||||||
|
'damageImmunities' => 'fire',
|
||||||
|
'senses' => 'blindsight 60 ft., darkvision 120 ft., passive Perception 23',
|
||||||
|
'languages' => 'Common, Draconic',
|
||||||
|
'traits' => "Legendary Resistance (3/Day). If the dragon fails a saving throw, it can choose to succeed instead.",
|
||||||
|
'actions' => "Multiattack. The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws.\nBite. Melee Weapon Attack: +14 to hit, reach 10 ft., one target. Hit: 19 (2d10 + 8) piercing damage plus 7 (2d6) fire damage.\nClaw. Melee Weapon Attack: +14 to hit, reach 5 ft., one target. Hit: 15 (2d6 + 8) slashing damage.\nTail. Melee Weapon Attack: +14 to hit, reach 15 ft., one target. Hit: 17 (2d8 + 8) bludgeoning damage.\nFrightful Presence. Each creature of the dragon's choice within 120 ft. and aware of it must succeed on a DC 19 Wisdom saving throw or become frightened for 1 minute. A creature can repeat the save at the end of each of its turns; on a success it is immune to this dragon's Frightful Presence for 24 hours.\nFire Breath (Recharge 5-6). The dragon exhales fire in a 60-foot cone. Each creature in that area must make a DC 21 Dexterity saving throw, taking 63 (18d6) fire damage on a failed save, or half as much on a success.",
|
||||||
|
'legendaryActions' => "The dragon can take 3 legendary actions, choosing from the options below. Only one legendary action can be used at a time and only at the end of another creature's turn. The dragon regains spent legendary actions at the start of its turn.\nDetect. The dragon makes a Wisdom (Perception) check.\nTail Attack. The dragon makes a tail attack.\nWing Attack (Costs 2 Actions). The dragon beats its wings. Each creature within 10 ft. must succeed on a DC 22 Dexterity saving throw or take 15 (2d6 + 8) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed.",
|
||||||
|
'source' => 'SRD 5.1',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Hearthburrow', 'size' => 'Tiny', 'type' => 'Fey', 'subtype' => 'household spirit',
|
||||||
|
'alignment' => 'chaotic neutral', 'armorClass' => 13,
|
||||||
|
'hitPoints' => 18, 'hitDice' => '4d4 + 8', 'speed' => '25 ft., climb 20 ft.',
|
||||||
|
'strength' => 6, 'dexterity' => 16, 'constitution' => 14, 'intelligence' => 10, 'wisdom' => 14, 'charisma' => 13,
|
||||||
|
'challengeRating' => '1/2', 'experiencePoints' => 100, 'proficiencyBonus' => 2,
|
||||||
|
'savingThrows' => 'Dex +5', 'skills' => 'Perception +4, Sleight of Hand +5, Stealth +7',
|
||||||
|
'senses' => 'darkvision 60 ft., passive Perception 14',
|
||||||
|
'languages' => 'understands Common, speaks Sylvan',
|
||||||
|
'traits' => "Household Camouflage. While inside a building, the hearthburrow has advantage on Dexterity (Stealth) checks.\nWall Scurry. It moves through openings as narrow as 2 inches without squeezing. If it ends its turn inside a wall, floor, chimney, or similar space, it must emerge at the start of its next turn.\nOffended Hospitality. The hearthburrow is not trying to kill anyone. Unless seriously injured or cornered, it frightens, annoys, and inconveniences intruders to drive them out rather than dealing lethal damage. If reduced below 6 hit points, it flees beneath the hearth.",
|
||||||
|
'actions' => "Scratch. Melee Weapon Attack: +5 to hit, reach 5 ft., one creature. Hit: 5 (1d4 + 3) slashing damage.\nPilfer. The hearthburrow steals one Tiny unattended object, or one Tiny object carried by a creature within 5 ft. (DC 13 Dexterity saving throw to prevent). It favors keys, buttons, coins, cutlery, and anything shiny.\nMimic Voice. The hearthburrow imitates a short sound or phrase it has heard in the last 24 hours. A listener notices something is off with a DC 13 Wisdom (Insight) check. This deals no damage and exists purely to be annoying.",
|
||||||
|
'legendaryActions' => "Cottage Actions. While inside its cottage, on initiative count 20 (losing initiative ties) the hearthburrow takes one Cottage Action. It can't use the same one two rounds in a row.\nFlying Crockery. A household object flies at one creature: DC 12 Dexterity saving throw or 3 (1d6) bludgeoning damage.\nPulled Rug. One creature standing on the floor: DC 12 Dexterity saving throw or fall prone.\nSlamming Shutter. A shutter, door, or cupboard slams; one creature within 5 ft. of it: DC 12 Dexterity saving throw or 2 (1d4) bludgeoning damage, plus a deafening bang.\nSnuffed Flame. One nonmagical candle, lantern, or fire in the cottage goes out and must be relit normally.\nChimney Soot. Soot bursts out in a 10-foot radius: each creature there makes a DC 12 Constitution saving throw or is blinded until the end of its next turn. The area is lightly obscured until a window is opened.\nWhere Did My Boot Go? One unattended Tiny or Small mundane object is relocated somewhere inconvenient (a boot up the chimney, a key inside a teapot). No saving throw.",
|
||||||
|
'description' => "A tiny house-fey that has decided your lodging is its home. It does not fight to kill; it wants intruders gone.\n\nTemper. Track secretly from 0 to 6, starting at 4. 0 friendly, 1 curious, 2 mischievous, 3 annoyed, 4 angry, 5 furious, 6 attacks or drives everyone out.\n-1: leaving food, offering milk, repairing something in the cottage, speaking politely to the unseen creature.\n-2: correctly performing the old ritual.\n+1: breaking furniture, stealing from its collection, insulting or threatening it.\n+2: deliberately hurting the hearthburrow.\nRun it as a social and environmental encounter first; go to combat only if Temper reaches 6 or it is cornered.",
|
||||||
|
'source' => 'Homebrew',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Artwork;
|
||||||
|
use App\Form\ArtworkType;
|
||||||
|
use App\Repository\ArtworkRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
class ArtController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire('%kernel.project_dir%/var/uploads/art')]
|
||||||
|
private readonly string $artDir,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/art', name: 'app_art', methods: ['GET'])]
|
||||||
|
public function index(ArtworkRepository $repo): Response
|
||||||
|
{
|
||||||
|
return $this->render('art/index.html.twig', [
|
||||||
|
'artworks' => $repo->findAllOrdered(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a drag-reordered gallery: body is {_token, order: [id, id, ...]}.
|
||||||
|
*/
|
||||||
|
#[Route('/art/reorder', name: 'app_art_reorder', methods: ['POST'])]
|
||||||
|
public function reorder(Request $request, ArtworkRepository $repo, EntityManagerInterface $em): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = json_decode($request->getContent(), true) ?? [];
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('reorder-art', (string) ($payload['_token'] ?? ''))) {
|
||||||
|
return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = array_map('intval', \is_array($payload['order'] ?? null) ? $payload['order'] : []);
|
||||||
|
if (!$order) {
|
||||||
|
return $this->json(['error' => 'No order given.'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$byId = [];
|
||||||
|
foreach ($repo->findBy(['id' => $order]) as $artwork) {
|
||||||
|
$byId[$artwork->getId()] = $artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
$position = 1;
|
||||||
|
foreach ($order as $id) {
|
||||||
|
$byId[$id]?->setPosition($position++);
|
||||||
|
}
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $this->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/art/upload', name: 'app_art_upload', methods: ['GET', 'POST'])]
|
||||||
|
public function upload(Request $request, EntityManagerInterface $em, ArtworkRepository $repo): Response
|
||||||
|
{
|
||||||
|
$artwork = new Artwork();
|
||||||
|
$form = $this->createForm(ArtworkType::class, $artwork);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var UploadedFile $file */
|
||||||
|
$file = $form->get('file')->getData();
|
||||||
|
$ext = $file->guessExtension() ?: 'bin';
|
||||||
|
$stored = bin2hex(random_bytes(16)).'.'.$ext;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!is_dir($this->artDir)) {
|
||||||
|
mkdir($this->artDir, 0775, true);
|
||||||
|
}
|
||||||
|
$file->move($this->artDir, $stored);
|
||||||
|
} catch (FileException) {
|
||||||
|
$this->addFlash('error', 'Could not store the uploaded file.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_art_upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork
|
||||||
|
->setFilename($stored)
|
||||||
|
->setOriginalName($file->getClientOriginalName())
|
||||||
|
->setMimeType($file->getClientMimeType())
|
||||||
|
->setSize((int) (@filesize($this->artDir.'/'.$stored) ?: 0))
|
||||||
|
->setPosition($repo->nextPosition());
|
||||||
|
|
||||||
|
$em->persist($artwork);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Uploaded "%s".', $artwork->getName()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_art');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('art/upload.html.twig', ['form' => $form]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/art/{id}', name: 'app_art_show', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||||
|
public function show(Artwork $artwork): Response
|
||||||
|
{
|
||||||
|
return $this->render('art/show.html.twig', ['art' => $artwork]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/art/{id}/file', name: 'app_art_file', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||||
|
public function serve(Artwork $artwork): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$path = $this->artDir.'/'.$artwork->getFilename();
|
||||||
|
if (!is_file($path)) {
|
||||||
|
throw $this->createNotFoundException('File missing on disk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($path);
|
||||||
|
$response->headers->set('Content-Type', $artwork->getMimeType());
|
||||||
|
$response->setContentDisposition(
|
||||||
|
HeaderUtils::DISPOSITION_INLINE,
|
||||||
|
$artwork->getOriginalName() ?? $artwork->getFilename(),
|
||||||
|
);
|
||||||
|
$response->setPrivate();
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/art/{id}/delete', name: 'app_art_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function delete(Request $request, Artwork $artwork, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('delete-art-'.$artwork->getId(), (string) $request->request->get('_token'))) {
|
||||||
|
$path = $this->artDir.'/'.$artwork->getFilename();
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
$em->remove($artwork);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Deleted "%s".', $artwork->getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_art');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use App\Form\CharacterGameNotesType;
|
||||||
|
use App\Form\CharacterType;
|
||||||
|
use App\Repository\CharacterRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
@@ -10,9 +16,111 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
class CharacterController extends AbstractController
|
class CharacterController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/characters', name: 'app_characters')]
|
#[Route('/characters', name: 'app_characters', methods: ['GET'])]
|
||||||
public function index(): Response
|
public function index(CharacterRepository $repo): Response
|
||||||
{
|
{
|
||||||
return $this->render('characters/index.html.twig');
|
return $this->render('characters/index.html.twig', [
|
||||||
|
'characters' => $repo->findAllOrdered(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-play detail page: the sheet read-only, plus the game-notes field.
|
||||||
|
*/
|
||||||
|
#[Route('/characters/{id}', name: 'app_characters_show', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
|
||||||
|
public function show(Request $request, Character $character, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(CharacterGameNotesType::class, $character);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Game notes saved.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_characters_show', ['id' => $character->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('characters/show.html.twig', [
|
||||||
|
'character' => $character,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/characters/new', name: 'app_characters_new', methods: ['GET', 'POST'])]
|
||||||
|
public function new(Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
return $this->handleForm($request, $em, new Character());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/characters/{id}/edit', name: 'app_characters_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
|
||||||
|
public function edit(Request $request, Character $character, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
return $this->handleForm($request, $em, $character);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/characters/{id}/delete', name: 'app_characters_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function delete(Request $request, Character $character, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('delete-character-'.$character->getId(), (string) $request->request->get('_token'))) {
|
||||||
|
$name = $character->getName();
|
||||||
|
$em->remove($character);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Deleted "%s".', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleForm(Request $request, EntityManagerInterface $em, Character $character): Response
|
||||||
|
{
|
||||||
|
$isNew = null === $character->getId();
|
||||||
|
$form = $this->createForm(CharacterType::class, $character);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$character->setAttacks($this->attacksFromRequest($request));
|
||||||
|
|
||||||
|
if ($isNew) {
|
||||||
|
$em->persist($character);
|
||||||
|
}
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('%s %s.', $isNew ? 'Added' : 'Saved', $character->getName()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_characters_show', ['id' => $character->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('characters/edit.html.twig', [
|
||||||
|
'character' => $character,
|
||||||
|
'form' => $form,
|
||||||
|
'is_new' => $isNew,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attack rows aren't a Symfony form field (they're a small repeatable
|
||||||
|
* table, not a single value) - read them straight off the request instead,
|
||||||
|
* dropping any row left completely blank.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, bonus: string, damage: string, range: string}>
|
||||||
|
*/
|
||||||
|
private function attacksFromRequest(Request $request): array
|
||||||
|
{
|
||||||
|
$rows = $request->request->all('attacks');
|
||||||
|
$attacks = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
$bonus = trim((string) ($row['bonus'] ?? ''));
|
||||||
|
$damage = trim((string) ($row['damage'] ?? ''));
|
||||||
|
$range = trim((string) ($row['range'] ?? ''));
|
||||||
|
|
||||||
|
if ('' === $name && '' === $bonus && '' === $damage && '' === $range) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attacks[] = ['name' => $name, 'bonus' => $bonus, 'damage' => $damage, 'range' => $range];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attacks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Entity\Enemy;
|
|||||||
use App\Form\EnemyGameNotesType;
|
use App\Form\EnemyGameNotesType;
|
||||||
use App\Form\EnemyType;
|
use App\Form\EnemyType;
|
||||||
use App\Repository\EnemyRepository;
|
use App\Repository\EnemyRepository;
|
||||||
|
use App\Repository\EncounterRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -18,9 +19,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class EnemyController extends AbstractController
|
class EnemyController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/enemies', name: 'app_enemies', methods: ['GET'])]
|
#[Route('/enemies', name: 'app_enemies', methods: ['GET'])]
|
||||||
public function index(): Response
|
public function index(Request $request, EncounterRepository $encounters): Response
|
||||||
{
|
{
|
||||||
return $this->render('enemy/index.html.twig');
|
return $this->render('enemy/index.html.twig', [
|
||||||
|
'encounters' => $encounters->recent(),
|
||||||
|
'selected_encounter' => $request->query->get('encounter'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,6 +100,20 @@ class EnemyController extends AbstractController
|
|||||||
return $this->handleForm($request, $em, $enemy);
|
return $this->handleForm($request, $em, $enemy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/enemies/{id}/delete', name: 'app_enemies_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function delete(Request $request, Enemy $enemy, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('delete-enemy-'.$enemy->getId(), (string) $request->request->get('_token'))) {
|
||||||
|
$name = $enemy->getName();
|
||||||
|
// Combatants keep their snapshotted stats; enemy_id is set null by the FK.
|
||||||
|
$em->remove($enemy);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Deleted "%s".', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_enemies');
|
||||||
|
}
|
||||||
|
|
||||||
private function handleForm(Request $request, EntityManagerInterface $em, Enemy $enemy): Response
|
private function handleForm(Request $request, EntityManagerInterface $em, Enemy $enemy): Response
|
||||||
{
|
{
|
||||||
$isNew = null === $enemy->getId();
|
$isNew = null === $enemy->getId();
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Puzzle\PuzzleResetTokenSigner;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared, no-login puzzle screens: everyone with the link sees the same
|
||||||
|
* board, the same pieces, and each other's cursors, synced over the
|
||||||
|
* WebSocket relay in {@see \App\Command\PuzzleRelayCommand}.
|
||||||
|
*/
|
||||||
|
final class PuzzleController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'PUZZLE_RELAY_PUBLIC_URL')]
|
||||||
|
private readonly string $relayPublicUrl,
|
||||||
|
private readonly PuzzleResetTokenSigner $resetTokenSigner,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/puzzle/{room}', name: 'app_puzzle_room', requirements: ['room' => '[a-z0-9-]+'], methods: ['GET'])]
|
||||||
|
public function room(string $room): Response
|
||||||
|
{
|
||||||
|
return $this->render('puzzle/room.html.twig', [
|
||||||
|
'room' => $room,
|
||||||
|
'relayPublicUrl' => $this->relayPublicUrl,
|
||||||
|
// Only a logged-in user gets a token that lets them reset the shared
|
||||||
|
// board; anonymous players can play but not wipe it for everyone.
|
||||||
|
'resetToken' => $this->getUser() ? $this->resetTokenSigner->sign($room) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Form\ChapterNoteType;
|
||||||
|
use App\Repository\ChapterNoteRepository;
|
||||||
|
use App\Storyline\ChapterOutline;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
@@ -10,9 +15,95 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
class StorylineController extends AbstractController
|
class StorylineController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/storyline', name: 'app_storyline')]
|
/**
|
||||||
|
* The storyline is a set of hand-written chapters. Each entry points at a
|
||||||
|
* Twig partial under templates/storyline/chapters/. Add a chapter by adding
|
||||||
|
* a row here and the matching template; order in this array is reading order.
|
||||||
|
*
|
||||||
|
* - eyebrow: the kicker shown above the chapter title ("Overview", "Prologue",
|
||||||
|
* "Session 1", ...)
|
||||||
|
* - tag: 1-2 characters for the little badge in the chapter list / sidebar
|
||||||
|
*
|
||||||
|
* @var array<string, array{title: string, eyebrow: string, tag: string, summary: string, template: string}>
|
||||||
|
*/
|
||||||
|
private const CHAPTERS = [
|
||||||
|
'overview' => [
|
||||||
|
'title' => 'Campaign Overview',
|
||||||
|
'eyebrow' => 'Overview',
|
||||||
|
'tag' => 'Ov',
|
||||||
|
'summary' => 'The whole shape of the Brackenford campaign, from the sealing of Kyuss four centuries ago to the final crisis — the truth behind the blood, the red herring around Sir Aldren, and how it can resolve.',
|
||||||
|
'template' => 'storyline/chapters/overview.html.twig',
|
||||||
|
],
|
||||||
|
'prologue-the-long-road' => [
|
||||||
|
'title' => 'Prologue — The Long Road to Brackenford',
|
||||||
|
'eyebrow' => 'Prologue',
|
||||||
|
'tag' => 'Pr',
|
||||||
|
'summary' => "The courier job that carries the party to Brackenford: hired in Bellhaven to deliver Edric Vale's estate to his nephew Eamon, twenty days on the road, and one night in a guest cottage that won't let anyone sleep. Levels 1 to 2, about 2.5 hours.",
|
||||||
|
'template' => 'storyline/chapters/prologue-the-long-road.html.twig',
|
||||||
|
],
|
||||||
|
'session-1-outside-blood' => [
|
||||||
|
'title' => 'Session 1 — Outside Blood',
|
||||||
|
'eyebrow' => 'Session 1',
|
||||||
|
'tag' => 'S1',
|
||||||
|
'summary' => "The party arrives in Brackenford two hours after Eamon Vale met Aldren. A warm welcome from the Thorn beekeepers, a family hearing of Edric's death, a signature nobody can get because the council has Eamon — and, late at night, something dripping from the collection cart. Level 2, about 2.5 hours.",
|
||||||
|
'template' => 'storyline/chapters/session-1-outside-blood.html.twig',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
#[Route('/storyline', name: 'app_storyline', methods: ['GET'])]
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return $this->render('storyline/index.html.twig');
|
return $this->render('storyline/index.html.twig', [
|
||||||
|
'chapters' => self::CHAPTERS,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/storyline/{slug}', name: 'app_storyline_chapter', requirements: ['slug' => '[a-z0-9-]+'], methods: ['GET', 'POST'])]
|
||||||
|
public function chapter(string $slug, Request $request, ChapterNoteRepository $notes, EntityManagerInterface $em, ChapterOutline $outline): Response
|
||||||
|
{
|
||||||
|
if (!isset(self::CHAPTERS[$slug])) {
|
||||||
|
throw $this->createNotFoundException('No such storyline chapter.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$note = $notes->findOrCreate($slug);
|
||||||
|
$form = $this->createForm(ChapterNoteType::class, $note);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
if (null === $note->getId()) {
|
||||||
|
$em->persist($note);
|
||||||
|
}
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Chapter notes saved.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_storyline_chapter', ['slug' => $slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render every chapter once: the current one is shown (with anchors),
|
||||||
|
// the rest only feed the sidebar's per-section jump lists.
|
||||||
|
$sections = [];
|
||||||
|
$body = '';
|
||||||
|
foreach (self::CHAPTERS as $s => $c) {
|
||||||
|
$processed = $outline->process($this->renderView($c['template']));
|
||||||
|
$sections[$s] = $processed['sections'];
|
||||||
|
if ($s === $slug) {
|
||||||
|
$body = $processed['html'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$slugs = array_keys(self::CHAPTERS);
|
||||||
|
$pos = array_search($slug, $slugs, true);
|
||||||
|
|
||||||
|
return $this->render('storyline/chapter.html.twig', [
|
||||||
|
'chapters' => self::CHAPTERS,
|
||||||
|
'slug' => $slug,
|
||||||
|
'chapter' => self::CHAPTERS[$slug],
|
||||||
|
'body' => $body,
|
||||||
|
'sections' => $sections,
|
||||||
|
'note' => $note,
|
||||||
|
'form' => $form,
|
||||||
|
'prev' => $pos > 0 ? $slugs[$pos - 1] : null,
|
||||||
|
'next' => $pos < \count($slugs) - 1 ? $slugs[$pos + 1] : null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Combatant;
|
||||||
|
use App\Entity\Encounter;
|
||||||
|
use App\Repository\EncounterRepository;
|
||||||
|
use App\Repository\EnemyRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
class TrackerController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/tracker', name: 'app_tracker', methods: ['GET'])]
|
||||||
|
public function index(EncounterRepository $repo): Response
|
||||||
|
{
|
||||||
|
return $this->render('tracker/index.html.twig', [
|
||||||
|
'encounters' => $repo->recent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/new', name: 'app_tracker_new', methods: ['POST'])]
|
||||||
|
public function new(Request $request, EncounterRepository $repo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid('tracker-new', (string) $request->request->get('_token'))) {
|
||||||
|
return $this->redirectToRoute('app_tracker');
|
||||||
|
}
|
||||||
|
$encounter = new Encounter();
|
||||||
|
$encounter->setName($this->cleanName($request->request->get('name'), $repo));
|
||||||
|
$em->persist($encounter);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/add', name: 'app_tracker_add', methods: ['POST'])]
|
||||||
|
public function add(Request $request, EnemyRepository $enemies, EncounterRepository $repo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid('track-add', (string) $request->request->get('_token'))) {
|
||||||
|
return $this->redirectToRoute('app_enemies');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = (string) $request->request->get('encounter', 'new');
|
||||||
|
if ('new' === $target || '' === $target) {
|
||||||
|
$encounter = (new Encounter())->setName($this->cleanName($request->request->get('name'), $repo));
|
||||||
|
$em->persist($encounter);
|
||||||
|
} else {
|
||||||
|
$encounter = $repo->find((int) $target);
|
||||||
|
if (!$encounter) {
|
||||||
|
return $this->redirectToRoute('app_enemies');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$em->flush(); // so nextPosition / counts see a real id
|
||||||
|
|
||||||
|
$combatantRepo = $em->getRepository(Combatant::class);
|
||||||
|
$pos = $combatantRepo->nextPosition($encounter);
|
||||||
|
$added = 0;
|
||||||
|
foreach ($request->request->all('picks') as $pick) {
|
||||||
|
$enemy = isset($pick['id']) ? $enemies->find((int) $pick['id']) : null;
|
||||||
|
if (!$enemy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$qty = max(1, min(50, (int) ($pick['qty'] ?? 1)));
|
||||||
|
$existing = $combatantRepo->countFromEnemy($encounter, $enemy);
|
||||||
|
|
||||||
|
for ($i = 1; $i <= $qty; ++$i) {
|
||||||
|
$label = ($existing + $qty > 1)
|
||||||
|
? sprintf('%s %d', $enemy->getName(), $existing + $i)
|
||||||
|
: $enemy->getName();
|
||||||
|
|
||||||
|
$encounter->addCombatant(
|
||||||
|
(new Combatant())
|
||||||
|
->setEnemy($enemy)
|
||||||
|
->setLabel($label)
|
||||||
|
->setArmorClass($enemy->getArmorClass())
|
||||||
|
->setMaxHp($enemy->getHitPoints())
|
||||||
|
->setCurrentHp($enemy->getHitPoints())
|
||||||
|
->setPosition($pos++)
|
||||||
|
);
|
||||||
|
++$added;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$encounter->touch();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
if ($added) {
|
||||||
|
$this->addFlash('success', sprintf('Added %d combatant%s.', $added, 1 === $added ? '' : 's'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}', name: 'app_tracker_show', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||||
|
public function show(Encounter $encounter): Response
|
||||||
|
{
|
||||||
|
return $this->render('tracker/show.html.twig', ['encounter' => $encounter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}/rename', name: 'app_tracker_rename', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function rename(Request $request, Encounter $encounter, EncounterRepository $repo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
$encounter->setName($this->cleanName($request->request->get('name'), $repo));
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}/clear', name: 'app_tracker_clear', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function clear(Request $request, Encounter $encounter, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
foreach ($encounter->getCombatants()->toArray() as $c) {
|
||||||
|
$encounter->removeCombatant($c);
|
||||||
|
$em->remove($c);
|
||||||
|
}
|
||||||
|
$encounter->touch();
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Combatants cleared.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}/delete', name: 'app_tracker_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function delete(Request $request, Encounter $encounter, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
$em->remove($encounter);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Deleted "%s".', $encounter->getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/combatant/{id}/hp', name: 'app_tracker_hp', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function hp(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$amount = abs((int) $request->request->get('amount', 0));
|
||||||
|
if ($amount > 0) {
|
||||||
|
'heal' === $request->request->get('mode')
|
||||||
|
? $combatant->applyHealing($amount)
|
||||||
|
: $combatant->applyDamage($amount);
|
||||||
|
$combatant->getEncounter()?->touch();
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $combatant->getEncounter()?->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/combatant/{id}/remove', name: 'app_tracker_combatant_remove', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function removeCombatant(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$encounterId = $combatant->getEncounter()?->getId();
|
||||||
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
$combatant->getEncounter()?->touch();
|
||||||
|
$em->remove($combatant);
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounterId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanName(mixed $raw, EncounterRepository $repo): string
|
||||||
|
{
|
||||||
|
$name = trim((string) $raw);
|
||||||
|
if ('' !== $name) {
|
||||||
|
return mb_substr($name, 0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Encounter '.(\count($repo->findAll()) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\ArtworkRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ArtworkRepository::class)]
|
||||||
|
#[ORM\Table(name: 'artwork')]
|
||||||
|
class Artwork
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 200)]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
/** Randomised on-disk name under var/uploads/art/. */
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
private string $filename = '';
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $originalName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100)]
|
||||||
|
private string $mimeType = 'application/octet-stream';
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::INTEGER)]
|
||||||
|
private int $size = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $uploadedAt;
|
||||||
|
|
||||||
|
/** Manual sort order for the gallery; smaller sorts first. Set on upload, changed by drag-reorder. */
|
||||||
|
#[ORM\Column(type: Types::INTEGER)]
|
||||||
|
private int $position = 0;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->uploadedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilename(): string
|
||||||
|
{
|
||||||
|
return $this->filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFilename(string $filename): static
|
||||||
|
{
|
||||||
|
$this->filename = $filename;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOriginalName(): ?string
|
||||||
|
{
|
||||||
|
return $this->originalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOriginalName(?string $originalName): static
|
||||||
|
{
|
||||||
|
$this->originalName = $originalName;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMimeType(): string
|
||||||
|
{
|
||||||
|
return $this->mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMimeType(string $mimeType): static
|
||||||
|
{
|
||||||
|
$this->mimeType = $mimeType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSize(): int
|
||||||
|
{
|
||||||
|
return $this->size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSize(int $size): static
|
||||||
|
{
|
||||||
|
$this->size = $size;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUploadedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->uploadedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\ChapterNoteRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DM's running notes for one storyline chapter. Chapters themselves are
|
||||||
|
* hand-written Twig (see StorylineController::CHAPTERS); this table only holds
|
||||||
|
* the mutable play notes, keyed by the chapter slug.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: ChapterNoteRepository::class)]
|
||||||
|
#[ORM\Table(name: 'chapter_note')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_chapter_note_slug', columns: ['slug'])]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
class ChapterNote
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
private string $slug;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $notes = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function __construct(string $slug)
|
||||||
|
{
|
||||||
|
$this->slug = $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ORM\PrePersist]
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSlug(): string
|
||||||
|
{
|
||||||
|
return $this->slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNotes(): ?string
|
||||||
|
{
|
||||||
|
return $this->notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNotes(?string $notes): static
|
||||||
|
{
|
||||||
|
$this->notes = $notes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\CharacterRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player character sheet, close to the paper 5e sheet: identity, combat
|
||||||
|
* stats, ability scores, proficiencies, and the freeform narrative boxes.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: CharacterRepository::class)]
|
||||||
|
#[ORM\Table(name: 'character_sheet')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_character_name', columns: ['name'])]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
class Character
|
||||||
|
{
|
||||||
|
/** Skill name => governing ability, in character-sheet order. */
|
||||||
|
public const SKILLS = [
|
||||||
|
'Acrobatics' => 'Dexterity',
|
||||||
|
'Animal Handling' => 'Wisdom',
|
||||||
|
'Arcana' => 'Intelligence',
|
||||||
|
'Athletics' => 'Strength',
|
||||||
|
'Deception' => 'Charisma',
|
||||||
|
'History' => 'Intelligence',
|
||||||
|
'Insight' => 'Wisdom',
|
||||||
|
'Intimidation' => 'Charisma',
|
||||||
|
'Investigation' => 'Intelligence',
|
||||||
|
'Medicine' => 'Wisdom',
|
||||||
|
'Nature' => 'Intelligence',
|
||||||
|
'Perception' => 'Wisdom',
|
||||||
|
'Performance' => 'Charisma',
|
||||||
|
'Persuasion' => 'Charisma',
|
||||||
|
'Religion' => 'Intelligence',
|
||||||
|
'Sleight of Hand' => 'Dexterity',
|
||||||
|
'Stealth' => 'Dexterity',
|
||||||
|
'Survival' => 'Wisdom',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The six abilities, in character-sheet order. */
|
||||||
|
public const ABILITIES = ['Strength', 'Dexterity', 'Constitution', 'Intelligence', 'Wisdom', 'Charisma'];
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
// --- Identity ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[ORM\Column(length: 160)]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120, nullable: true)]
|
||||||
|
private ?string $playerName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120, nullable: true)]
|
||||||
|
private ?string $race = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 80, nullable: true)]
|
||||||
|
private ?string $characterClass = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $level = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120, nullable: true)]
|
||||||
|
private ?string $background = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 40, nullable: true)]
|
||||||
|
private ?string $alignment = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $experiencePoints = null;
|
||||||
|
|
||||||
|
// --- Combat -------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $armorClass = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $initiative = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 60, nullable: true)]
|
||||||
|
private ?string $speed = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $hitPointMaximum = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $currentHitPoints = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $temporaryHitPoints = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 40, nullable: true)]
|
||||||
|
private ?string $hitDice = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $proficiencyBonus = 2;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $inspiration = false;
|
||||||
|
|
||||||
|
// --- Ability scores -----------------------------------------------------
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $strength = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $dexterity = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $constitution = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $intelligence = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $wisdom = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $charisma = 10;
|
||||||
|
|
||||||
|
/** @var list<string> Ability names ("Strength", ...) this character is proficient in saves for. */
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $savingThrowProficiencies = [];
|
||||||
|
|
||||||
|
/** @var list<string> Skill names (keys of self::SKILLS) this character is proficient in. */
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $skillProficiencies = [];
|
||||||
|
|
||||||
|
/** @var list<string> Skills (keys of self::SKILLS) with expertise: proficiency bonus counts double. */
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $skillExpertise = [];
|
||||||
|
|
||||||
|
// --- Attacks: one row per weapon/spell attack, table-shaped on the sheet ---
|
||||||
|
|
||||||
|
/** @var list<array{name: string, bonus: string, damage: string, range: string}> */
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $attacks = [];
|
||||||
|
|
||||||
|
// --- Freeform boxes, straight off the paper sheet -----------------------
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $otherProficienciesLanguages = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $equipment = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $featuresTraits = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $spellcasting = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $personalityTraits = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $ideals = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $bonds = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $flaws = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $appearance = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $backstory = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $alliesOrganizations = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $treasure = null;
|
||||||
|
|
||||||
|
// --- DM ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** DM's live notes, written during play. Never touched by editing the sheet. */
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $gameNotes = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
#[ORM\PrePersist]
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Computed --------------------------------------------------------
|
||||||
|
|
||||||
|
public function abilityModifier(int $score): int
|
||||||
|
{
|
||||||
|
return (int) floor(($score - 10) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abilityScore(string $ability): int
|
||||||
|
{
|
||||||
|
return match ($ability) {
|
||||||
|
'Strength' => $this->strength,
|
||||||
|
'Dexterity' => $this->dexterity,
|
||||||
|
'Constitution' => $this->constitution,
|
||||||
|
'Intelligence' => $this->intelligence,
|
||||||
|
'Wisdom' => $this->wisdom,
|
||||||
|
'Charisma' => $this->charisma,
|
||||||
|
default => 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isProficientInSave(string $ability): bool
|
||||||
|
{
|
||||||
|
return \in_array($ability, $this->savingThrowProficiencies, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveModifier(string $ability): int
|
||||||
|
{
|
||||||
|
return $this->abilityModifier($this->abilityScore($ability))
|
||||||
|
+ ($this->isProficientInSave($ability) ? $this->proficiencyBonus : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isProficientInSkill(string $skill): bool
|
||||||
|
{
|
||||||
|
return \in_array($skill, $this->skillProficiencies, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expertise implies proficiency, even if the skill wasn't also ticked as proficient. */
|
||||||
|
public function hasExpertiseInSkill(string $skill): bool
|
||||||
|
{
|
||||||
|
return \in_array($skill, $this->skillExpertise, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function skillModifier(string $skill): int
|
||||||
|
{
|
||||||
|
$ability = self::SKILLS[$skill] ?? 'Strength';
|
||||||
|
$proficiency = match (true) {
|
||||||
|
$this->hasExpertiseInSkill($skill) => 2 * $this->proficiencyBonus,
|
||||||
|
$this->isProficientInSkill($skill) => $this->proficiencyBonus,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->abilityModifier($this->abilityScore($ability)) + $proficiency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function passivePerception(): int
|
||||||
|
{
|
||||||
|
return 10 + $this->skillModifier('Perception');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AssetMapper logical path of this character's portrait, e.g.
|
||||||
|
* "image/characters/DarraghOakwanderer.png". The file may not exist.
|
||||||
|
*/
|
||||||
|
public function getPortraitPath(): string
|
||||||
|
{
|
||||||
|
return 'image/characters/'.str_replace(' ', '', $this->name).'.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. "Halfling Ranger 2". */
|
||||||
|
public function getMetaLine(): string
|
||||||
|
{
|
||||||
|
$classLevel = trim(($this->characterClass ?? '').($this->characterClass && $this->level ? ' '.$this->level : ''));
|
||||||
|
|
||||||
|
return implode(' ', array_filter([$this->race, $classLevel]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPlayerName(): ?string
|
||||||
|
{
|
||||||
|
return $this->playerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPlayerName(?string $playerName): static
|
||||||
|
{
|
||||||
|
$this->playerName = $playerName;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRace(): ?string
|
||||||
|
{
|
||||||
|
return $this->race;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRace(?string $race): static
|
||||||
|
{
|
||||||
|
$this->race = $race;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCharacterClass(): ?string
|
||||||
|
{
|
||||||
|
return $this->characterClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCharacterClass(?string $characterClass): static
|
||||||
|
{
|
||||||
|
$this->characterClass = $characterClass;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLevel(): int
|
||||||
|
{
|
||||||
|
return $this->level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLevel(int $level): static
|
||||||
|
{
|
||||||
|
$this->level = $level;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBackground(): ?string
|
||||||
|
{
|
||||||
|
return $this->background;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBackground(?string $background): static
|
||||||
|
{
|
||||||
|
$this->background = $background;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAlignment(): ?string
|
||||||
|
{
|
||||||
|
return $this->alignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAlignment(?string $alignment): static
|
||||||
|
{
|
||||||
|
$this->alignment = $alignment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExperiencePoints(): ?int
|
||||||
|
{
|
||||||
|
return $this->experiencePoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExperiencePoints(?int $experiencePoints): static
|
||||||
|
{
|
||||||
|
$this->experiencePoints = $experiencePoints;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getArmorClass(): ?int
|
||||||
|
{
|
||||||
|
return $this->armorClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setArmorClass(?int $armorClass): static
|
||||||
|
{
|
||||||
|
$this->armorClass = $armorClass;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInitiative(): ?int
|
||||||
|
{
|
||||||
|
return $this->initiative;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInitiative(?int $initiative): static
|
||||||
|
{
|
||||||
|
$this->initiative = $initiative;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpeed(): ?string
|
||||||
|
{
|
||||||
|
return $this->speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSpeed(?string $speed): static
|
||||||
|
{
|
||||||
|
$this->speed = $speed;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHitPointMaximum(): ?int
|
||||||
|
{
|
||||||
|
return $this->hitPointMaximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHitPointMaximum(?int $hitPointMaximum): static
|
||||||
|
{
|
||||||
|
$this->hitPointMaximum = $hitPointMaximum;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentHitPoints(): ?int
|
||||||
|
{
|
||||||
|
return $this->currentHitPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCurrentHitPoints(?int $currentHitPoints): static
|
||||||
|
{
|
||||||
|
$this->currentHitPoints = $currentHitPoints;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemporaryHitPoints(): ?int
|
||||||
|
{
|
||||||
|
return $this->temporaryHitPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTemporaryHitPoints(?int $temporaryHitPoints): static
|
||||||
|
{
|
||||||
|
$this->temporaryHitPoints = $temporaryHitPoints;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHitDice(): ?string
|
||||||
|
{
|
||||||
|
return $this->hitDice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHitDice(?string $hitDice): static
|
||||||
|
{
|
||||||
|
$this->hitDice = $hitDice;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProficiencyBonus(): int
|
||||||
|
{
|
||||||
|
return $this->proficiencyBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProficiencyBonus(int $proficiencyBonus): static
|
||||||
|
{
|
||||||
|
$this->proficiencyBonus = $proficiencyBonus;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isInspiration(): bool
|
||||||
|
{
|
||||||
|
return $this->inspiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInspiration(bool $inspiration): static
|
||||||
|
{
|
||||||
|
$this->inspiration = $inspiration;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStrength(): int
|
||||||
|
{
|
||||||
|
return $this->strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStrength(int $strength): static
|
||||||
|
{
|
||||||
|
$this->strength = $strength;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDexterity(): int
|
||||||
|
{
|
||||||
|
return $this->dexterity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDexterity(int $dexterity): static
|
||||||
|
{
|
||||||
|
$this->dexterity = $dexterity;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConstitution(): int
|
||||||
|
{
|
||||||
|
return $this->constitution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setConstitution(int $constitution): static
|
||||||
|
{
|
||||||
|
$this->constitution = $constitution;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIntelligence(): int
|
||||||
|
{
|
||||||
|
return $this->intelligence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIntelligence(int $intelligence): static
|
||||||
|
{
|
||||||
|
$this->intelligence = $intelligence;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWisdom(): int
|
||||||
|
{
|
||||||
|
return $this->wisdom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWisdom(int $wisdom): static
|
||||||
|
{
|
||||||
|
$this->wisdom = $wisdom;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCharisma(): int
|
||||||
|
{
|
||||||
|
return $this->charisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCharisma(int $charisma): static
|
||||||
|
{
|
||||||
|
$this->charisma = $charisma;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function getSavingThrowProficiencies(): array
|
||||||
|
{
|
||||||
|
return $this->savingThrowProficiencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $savingThrowProficiencies */
|
||||||
|
public function setSavingThrowProficiencies(array $savingThrowProficiencies): static
|
||||||
|
{
|
||||||
|
$this->savingThrowProficiencies = array_values($savingThrowProficiencies);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function getSkillProficiencies(): array
|
||||||
|
{
|
||||||
|
return $this->skillProficiencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $skillProficiencies */
|
||||||
|
public function setSkillProficiencies(array $skillProficiencies): static
|
||||||
|
{
|
||||||
|
$this->skillProficiencies = array_values($skillProficiencies);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function getSkillExpertise(): array
|
||||||
|
{
|
||||||
|
return $this->skillExpertise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $skillExpertise */
|
||||||
|
public function setSkillExpertise(array $skillExpertise): static
|
||||||
|
{
|
||||||
|
$this->skillExpertise = array_values($skillExpertise);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{name: string, bonus: string, damage: string, range: string}> */
|
||||||
|
public function getAttacks(): array
|
||||||
|
{
|
||||||
|
return $this->attacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{name: string, bonus: string, damage: string, range: string}> $attacks */
|
||||||
|
public function setAttacks(array $attacks): static
|
||||||
|
{
|
||||||
|
$this->attacks = array_values($attacks);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOtherProficienciesLanguages(): ?string
|
||||||
|
{
|
||||||
|
return $this->otherProficienciesLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOtherProficienciesLanguages(?string $otherProficienciesLanguages): static
|
||||||
|
{
|
||||||
|
$this->otherProficienciesLanguages = $otherProficienciesLanguages;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEquipment(): ?string
|
||||||
|
{
|
||||||
|
return $this->equipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEquipment(?string $equipment): static
|
||||||
|
{
|
||||||
|
$this->equipment = $equipment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFeaturesTraits(): ?string
|
||||||
|
{
|
||||||
|
return $this->featuresTraits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFeaturesTraits(?string $featuresTraits): static
|
||||||
|
{
|
||||||
|
$this->featuresTraits = $featuresTraits;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpellcasting(): ?string
|
||||||
|
{
|
||||||
|
return $this->spellcasting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSpellcasting(?string $spellcasting): static
|
||||||
|
{
|
||||||
|
$this->spellcasting = $spellcasting;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPersonalityTraits(): ?string
|
||||||
|
{
|
||||||
|
return $this->personalityTraits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPersonalityTraits(?string $personalityTraits): static
|
||||||
|
{
|
||||||
|
$this->personalityTraits = $personalityTraits;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIdeals(): ?string
|
||||||
|
{
|
||||||
|
return $this->ideals;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIdeals(?string $ideals): static
|
||||||
|
{
|
||||||
|
$this->ideals = $ideals;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBonds(): ?string
|
||||||
|
{
|
||||||
|
return $this->bonds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBonds(?string $bonds): static
|
||||||
|
{
|
||||||
|
$this->bonds = $bonds;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFlaws(): ?string
|
||||||
|
{
|
||||||
|
return $this->flaws;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFlaws(?string $flaws): static
|
||||||
|
{
|
||||||
|
$this->flaws = $flaws;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAppearance(): ?string
|
||||||
|
{
|
||||||
|
return $this->appearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAppearance(?string $appearance): static
|
||||||
|
{
|
||||||
|
$this->appearance = $appearance;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBackstory(): ?string
|
||||||
|
{
|
||||||
|
return $this->backstory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBackstory(?string $backstory): static
|
||||||
|
{
|
||||||
|
$this->backstory = $backstory;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAlliesOrganizations(): ?string
|
||||||
|
{
|
||||||
|
return $this->alliesOrganizations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAlliesOrganizations(?string $alliesOrganizations): static
|
||||||
|
{
|
||||||
|
$this->alliesOrganizations = $alliesOrganizations;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTreasure(): ?string
|
||||||
|
{
|
||||||
|
return $this->treasure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTreasure(?string $treasure): static
|
||||||
|
{
|
||||||
|
$this->treasure = $treasure;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGameNotes(): ?string
|
||||||
|
{
|
||||||
|
return $this->gameNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGameNotes(?string $gameNotes): static
|
||||||
|
{
|
||||||
|
$this->gameNotes = $gameNotes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\CombatantRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line on the running combat HP tracker. Stats are copied from the Enemy
|
||||||
|
* when added, so editing the Enemy mid-fight doesn't change the tracker.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: CombatantRepository::class)]
|
||||||
|
#[ORM\Table(name: 'combatant')]
|
||||||
|
class Combatant
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Encounter::class, inversedBy: 'combatants')]
|
||||||
|
#[ORM\JoinColumn(name: 'encounter_id', nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Encounter $encounter = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Enemy::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'enemy_id', onDelete: 'SET NULL', nullable: true)]
|
||||||
|
private ?Enemy $enemy = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
private string $label = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $armorClass = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $maxHp = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $currentHp = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $position = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $addedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->addedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDown(): bool
|
||||||
|
{
|
||||||
|
return $this->currentHp <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0-100, for the HP bar. */
|
||||||
|
public function hpPercent(): int
|
||||||
|
{
|
||||||
|
if ($this->maxHp <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round(max(0, min($this->currentHp, $this->maxHp)) / $this->maxHp * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyDamage(int $amount): void
|
||||||
|
{
|
||||||
|
$this->currentHp = max(0, $this->currentHp - abs($amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyHealing(int $amount): void
|
||||||
|
{
|
||||||
|
$this->currentHp = min($this->maxHp, $this->currentHp + abs($amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEncounter(): ?Encounter
|
||||||
|
{
|
||||||
|
return $this->encounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEncounter(?Encounter $encounter): static
|
||||||
|
{
|
||||||
|
$this->encounter = $encounter;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnemy(): ?Enemy
|
||||||
|
{
|
||||||
|
return $this->enemy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnemy(?Enemy $enemy): static
|
||||||
|
{
|
||||||
|
$this->enemy = $enemy;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getArmorClass(): int
|
||||||
|
{
|
||||||
|
return $this->armorClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setArmorClass(int $armorClass): static
|
||||||
|
{
|
||||||
|
$this->armorClass = $armorClass;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMaxHp(): int
|
||||||
|
{
|
||||||
|
return $this->maxHp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMaxHp(int $maxHp): static
|
||||||
|
{
|
||||||
|
$this->maxHp = $maxHp;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentHp(): int
|
||||||
|
{
|
||||||
|
return $this->currentHp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCurrentHp(int $currentHp): static
|
||||||
|
{
|
||||||
|
$this->currentHp = $currentHp;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAddedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->addedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EncounterRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named combat: a list of combatants with their HP, kept for prep and reuse.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: EncounterRepository::class)]
|
||||||
|
#[ORM\Table(name: 'encounter')]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
class Encounter
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
/** @var Collection<int, Combatant> */
|
||||||
|
#[ORM\OneToMany(mappedBy: 'encounter', targetEntity: Combatant::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['position' => 'ASC', 'id' => 'ASC'])]
|
||||||
|
private Collection $combatants;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
$this->combatants = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Combatant> */
|
||||||
|
public function getCombatants(): Collection
|
||||||
|
{
|
||||||
|
return $this->combatants;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addCombatant(Combatant $combatant): static
|
||||||
|
{
|
||||||
|
if (!$this->combatants->contains($combatant)) {
|
||||||
|
$this->combatants->add($combatant);
|
||||||
|
$combatant->setEncounter($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeCombatant(Combatant $combatant): static
|
||||||
|
{
|
||||||
|
$this->combatants->removeElement($combatant);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Artwork;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Image;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotNull;
|
||||||
|
|
||||||
|
class ArtworkType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name', TextType::class, [
|
||||||
|
'help' => 'Shown in the gallery.',
|
||||||
|
])
|
||||||
|
->add('file', FileType::class, [
|
||||||
|
'label' => 'Image file',
|
||||||
|
'mapped' => false,
|
||||||
|
'help' => 'PNG, JPEG, GIF or WebP, up to 8 MB.',
|
||||||
|
'constraints' => [
|
||||||
|
new NotNull(message: 'Please choose an image.'),
|
||||||
|
new Image(
|
||||||
|
maxSize: '8M',
|
||||||
|
mimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||||
|
mimeTypesMessage: 'Upload a PNG, JPEG, GIF or WebP image.',
|
||||||
|
maxSizeMessage: 'The image is too large ({{ size }} {{ suffix }}). Max is 8 MB.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => Artwork::class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\ChapterNote;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DM's running log for a storyline chapter.
|
||||||
|
*/
|
||||||
|
class ChapterNoteType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->add('notes', TextareaType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => false,
|
||||||
|
'attr' => [
|
||||||
|
'rows' => 12,
|
||||||
|
'placeholder' => "What happened at the table: the story the party told about the cottage, names they gave NPCs, promises made, threads to pick up next time…",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => ChapterNote::class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only editable thing on the Character detail page: the DM's live play notes.
|
||||||
|
*/
|
||||||
|
class CharacterGameNotesType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->add('gameNotes', TextareaType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => false,
|
||||||
|
'attr' => ['rows' => 14, 'placeholder' => 'How this character played at the table…'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => Character::class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full character sheet, minus game notes (that lives on the detail page)
|
||||||
|
* and minus attacks (a small repeatable table handled by hand in the
|
||||||
|
* template/controller, since it's a list of rows rather than a single value).
|
||||||
|
*/
|
||||||
|
class CharacterType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$opt = ['required' => false];
|
||||||
|
$ability = ['attr' => ['min' => 1, 'max' => 30]];
|
||||||
|
$block = ['required' => false, 'attr' => ['rows' => 3]];
|
||||||
|
$note = ['required' => false, 'attr' => ['rows' => 2]];
|
||||||
|
|
||||||
|
$builder
|
||||||
|
// Identity
|
||||||
|
->add('name', TextType::class)
|
||||||
|
->add('playerName', TextType::class, $opt + ['label' => 'Player name'])
|
||||||
|
->add('race', TextType::class, $opt + ['help' => 'e.g. Halfling, stout'])
|
||||||
|
->add('characterClass', TextType::class, $opt + ['label' => 'Class', 'help' => 'e.g. Ranger'])
|
||||||
|
->add('level', IntegerType::class, ['attr' => ['min' => 1, 'max' => 20]])
|
||||||
|
->add('background', TextType::class, $opt)
|
||||||
|
->add('alignment', TextType::class, $opt + ['help' => 'e.g. CN'])
|
||||||
|
->add('experiencePoints', IntegerType::class, $opt + ['label' => 'XP'])
|
||||||
|
// Combat
|
||||||
|
->add('armorClass', IntegerType::class, $opt + ['label' => 'AC'])
|
||||||
|
->add('initiative', IntegerType::class, $opt)
|
||||||
|
->add('speed', TextType::class, $opt + ['help' => 'e.g. 25 ft.'])
|
||||||
|
->add('hitPointMaximum', IntegerType::class, $opt + ['label' => 'HP maximum'])
|
||||||
|
->add('currentHitPoints', IntegerType::class, $opt + ['label' => 'Current HP'])
|
||||||
|
->add('temporaryHitPoints', IntegerType::class, $opt + ['label' => 'Temporary HP'])
|
||||||
|
->add('hitDice', TextType::class, $opt + ['help' => 'e.g. 2d10'])
|
||||||
|
->add('proficiencyBonus', IntegerType::class)
|
||||||
|
->add('inspiration', CheckboxType::class, ['required' => false])
|
||||||
|
// Ability scores
|
||||||
|
->add('strength', IntegerType::class, $ability)
|
||||||
|
->add('dexterity', IntegerType::class, $ability)
|
||||||
|
->add('constitution', IntegerType::class, $ability)
|
||||||
|
->add('intelligence', IntegerType::class, $ability)
|
||||||
|
->add('wisdom', IntegerType::class, $ability)
|
||||||
|
->add('charisma', IntegerType::class, $ability)
|
||||||
|
// Proficiencies
|
||||||
|
->add('savingThrowProficiencies', ChoiceType::class, [
|
||||||
|
'choices' => array_combine(Character::ABILITIES, Character::ABILITIES),
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Saving throw proficiencies',
|
||||||
|
])
|
||||||
|
->add('skillProficiencies', ChoiceType::class, [
|
||||||
|
'choices' => array_combine(array_keys(Character::SKILLS), array_keys(Character::SKILLS)),
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Skill proficiencies',
|
||||||
|
])
|
||||||
|
->add('skillExpertise', ChoiceType::class, [
|
||||||
|
'choices' => array_combine(array_keys(Character::SKILLS), array_keys(Character::SKILLS)),
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Skill expertise',
|
||||||
|
'help' => 'Double proficiency bonus (e.g. Deft Explorer, Rogue Expertise)',
|
||||||
|
])
|
||||||
|
// Text blocks
|
||||||
|
->add('otherProficienciesLanguages', TextareaType::class, $block + ['label' => 'Other proficiencies & languages'])
|
||||||
|
->add('equipment', TextareaType::class, $block)
|
||||||
|
->add('featuresTraits', TextareaType::class, $block + ['label' => 'Features & traits'])
|
||||||
|
->add('spellcasting', TextareaType::class, $block)
|
||||||
|
->add('personalityTraits', TextareaType::class, $note + ['label' => 'Personality traits'])
|
||||||
|
->add('ideals', TextareaType::class, $note)
|
||||||
|
->add('bonds', TextareaType::class, $note)
|
||||||
|
->add('flaws', TextareaType::class, $note)
|
||||||
|
->add('appearance', TextareaType::class, $block + ['help' => 'Age, height, weight, eyes, skin, hair, description'])
|
||||||
|
->add('backstory', TextareaType::class, $block)
|
||||||
|
->add('alliesOrganizations', TextareaType::class, $block + ['label' => 'Allies & organizations'])
|
||||||
|
->add('treasure', TextareaType::class, $block);
|
||||||
|
// attacks and gameNotes are not part of this form.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => Character::class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Puzzle;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs/verifies the token that lets a client reset a shared puzzle room.
|
||||||
|
* Used by both {@see \App\Controller\PuzzleController} (issues it to logged-in
|
||||||
|
* users) and {@see \App\Command\PuzzleRelayCommand} (a public WebSocket server
|
||||||
|
* outside Symfony's session/security system, so it must check the signature
|
||||||
|
* itself rather than trusting the client).
|
||||||
|
*/
|
||||||
|
final class PuzzleResetTokenSigner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'APP_SECRET')]
|
||||||
|
private readonly string $secret,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sign(string $room): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $room, $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $room, string $token): bool
|
||||||
|
{
|
||||||
|
return hash_equals($this->sign($room), $token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Artwork;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Artwork>
|
||||||
|
*/
|
||||||
|
class ArtworkRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Artwork::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gallery order: manual position, oldest upload first as a tiebreak. */
|
||||||
|
public function findAllOrdered(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['position' => 'ASC', 'uploadedAt' => 'ASC']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a newly uploaded piece belongs: after everything already in the gallery. */
|
||||||
|
public function nextPosition(): int
|
||||||
|
{
|
||||||
|
$max = $this->createQueryBuilder('a')
|
||||||
|
->select('MAX(a.position)')
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
|
||||||
|
return ((int) $max) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ChapterNote;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ChapterNote>
|
||||||
|
*/
|
||||||
|
class ChapterNoteRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ChapterNote::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note row for a chapter, or a fresh unsaved one. The caller persists
|
||||||
|
* it only if something is actually written.
|
||||||
|
*/
|
||||||
|
public function findOrCreate(string $slug): ChapterNote
|
||||||
|
{
|
||||||
|
return $this->findOneBy(['slug' => $slug]) ?? new ChapterNote($slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Character>
|
||||||
|
*/
|
||||||
|
class CharacterRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Character::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Character> */
|
||||||
|
public function findAllOrdered(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['name' => 'ASC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Combatant;
|
||||||
|
use App\Entity\Encounter;
|
||||||
|
use App\Entity\Enemy;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Combatant>
|
||||||
|
*/
|
||||||
|
class CombatantRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Combatant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextPosition(Encounter $encounter): int
|
||||||
|
{
|
||||||
|
return 1 + (int) $this->createQueryBuilder('c')
|
||||||
|
->select('COALESCE(MAX(c.position), 0)')
|
||||||
|
->andWhere('c.encounter = :e')->setParameter('e', $encounter)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many combatants in this encounter already come from this enemy (for numbering). */
|
||||||
|
public function countFromEnemy(Encounter $encounter, Enemy $enemy): int
|
||||||
|
{
|
||||||
|
return (int) $this->count(['encounter' => $encounter, 'enemy' => $enemy]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Encounter;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Encounter>
|
||||||
|
*/
|
||||||
|
class EncounterRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Encounter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Encounter> most-recent first */
|
||||||
|
public function recent(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['updatedAt' => 'DESC', 'id' => 'DESC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Storyline;
|
||||||
|
|
||||||
|
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a rendered chapter into an outline: finds each numbered section
|
||||||
|
* heading (<h2 class="storyline-section"><span>21</span> Title</h2>), gives it
|
||||||
|
* an anchor id, and reports it so the sidebar can link straight to it.
|
||||||
|
*/
|
||||||
|
final class ChapterOutline
|
||||||
|
{
|
||||||
|
private const HEADING = '~<h2 class="storyline-section"([^>]*)>\s*<span>(.*?)</span>\s*(.*?)</h2>~s';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{html: string, sections: list<array{id: string, num: string, title: string}>}
|
||||||
|
*/
|
||||||
|
public function process(string $html): array
|
||||||
|
{
|
||||||
|
$slugger = new AsciiSlugger();
|
||||||
|
$sections = [];
|
||||||
|
$n = 0;
|
||||||
|
|
||||||
|
$html = preg_replace_callback(self::HEADING, function (array $m) use (&$sections, &$n, $slugger): string {
|
||||||
|
++$n;
|
||||||
|
$num = trim(html_entity_decode(strip_tags($m[2]), \ENT_QUOTES | \ENT_HTML5));
|
||||||
|
$title = trim(html_entity_decode(strip_tags($m[3]), \ENT_QUOTES | \ENT_HTML5));
|
||||||
|
$id = sprintf('section-%d-%s', $n, strtolower($slugger->slug($title)->toString()));
|
||||||
|
$sections[] = ['id' => $id, 'num' => $num, 'title' => $title];
|
||||||
|
|
||||||
|
return sprintf('<h2 class="storyline-section" id="%s"%s><span>%s</span> %s</h2>', $id, $m[1], $m[2], $m[3]);
|
||||||
|
}, $html) ?? $html;
|
||||||
|
|
||||||
|
return ['html' => $html, 'sections' => $sections];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Art · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h1 class="h3 mb-0">Art</h1>
|
||||||
|
<a href="{{ path('app_art_upload') }}" class="btn btn-primary btn-sm">Upload art</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if artworks is empty %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center text-body-secondary py-5">
|
||||||
|
Nothing uploaded yet. Use <strong>Upload art</strong> to add maps, handouts or character pieces.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-body-secondary small mb-2">Drag the <span class="art-drag-handle d-inline-flex">✥</span> handle to reorder.</p>
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3 art-grid"
|
||||||
|
data-controller="art-reorder"
|
||||||
|
data-art-reorder-url-value="{{ path('app_art_reorder') }}"
|
||||||
|
data-art-reorder-token-value="{{ csrf_token('reorder-art') }}"
|
||||||
|
data-action="dragover->art-reorder#dragOver drop->art-reorder#drop">
|
||||||
|
{% for art in artworks %}
|
||||||
|
<div class="col art-tile" data-art-reorder-target="item" data-art-id="{{ art.id }}">
|
||||||
|
<div class="card shadow-sm h-100 position-relative">
|
||||||
|
<button type="button" class="art-drag-handle" draggable="true"
|
||||||
|
data-action="dragstart->art-reorder#dragStart dragend->art-reorder#dragEnd"
|
||||||
|
title="Drag to reorder" aria-label="Drag to reorder {{ art.name }}">✥</button>
|
||||||
|
<a href="{{ path('app_art_show', {id: art.id}) }}" class="text-decoration-none text-body">
|
||||||
|
<div class="art-thumb">
|
||||||
|
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="fw-semibold text-truncate" title="{{ art.name }}">{{ art.name }}</div>
|
||||||
|
<div class="text-body-secondary small">
|
||||||
|
{{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="art-reorder-status" data-art-reorder-target="status" aria-live="polite"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ art.name }} · Art · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<a href="{{ path('app_art') }}" class="text-decoration-none small">← Art</a>
|
||||||
|
<h1 class="h3 mb-0">{{ art.name }}</h1>
|
||||||
|
<span class="text-body-secondary small">
|
||||||
|
{{ art.uploadedAt|date('Y-m-d H:i') }} · {{ (art.size / 1024)|round }} KB
|
||||||
|
{%- if art.originalName %} · {{ art.originalName }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-controller="clipboard"
|
||||||
|
data-clipboard-url-value="{{ path('app_art_file', {id: art.id}) }}"
|
||||||
|
data-action="clipboard#copy">Copy image</button>
|
||||||
|
|
||||||
|
<a href="{{ path('app_art_file', {id: art.id}) }}" target="_blank" rel="noopener"
|
||||||
|
class="btn btn-outline-secondary btn-sm">Open full size</a>
|
||||||
|
|
||||||
|
<form method="post" action="{{ path('app_art_delete', {id: art.id}) }}"
|
||||||
|
onsubmit="return confirm('Delete “{{ art.name|e('js') }}”?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete-art-' ~ art.id) }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center art-full">
|
||||||
|
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Upload art · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{{ path('app_art') }}" class="text-decoration-none small">← Art</a>
|
||||||
|
<h1 class="h3 mb-0">Upload art</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm" style="max-width: 32rem;">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_start(form, {attr: {enctype: 'multipart/form-data'}}) }}
|
||||||
|
{{ form_row(form.name) }}
|
||||||
|
{{ form_row(form.file) }}
|
||||||
|
<div class="mt-3 d-flex gap-2">
|
||||||
|
<button class="btn btn-primary">Upload</button>
|
||||||
|
<a href="{{ path('app_art') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column">
|
<body class="d-flex flex-column {% block body_class %}{% endblock %}">
|
||||||
{% block navbar %}
|
{% block navbar %}
|
||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
{ route: 'app_characters', label: 'Characters' },
|
{ route: 'app_characters', label: 'Characters' },
|
||||||
{ route: 'app_npcs', label: "NPC's" },
|
{ route: 'app_npcs', label: "NPC's" },
|
||||||
{ route: 'app_enemies', label: 'Enemies' },
|
{ route: 'app_enemies', label: 'Enemies' },
|
||||||
|
{ route: 'app_tracker', label: 'Tracker' },
|
||||||
|
{ route: 'app_art', label: 'Art' },
|
||||||
{ route: 'app_storyline', label: 'Storyline' },
|
{ route: 'app_storyline', label: 'Storyline' },
|
||||||
] %}
|
] %}
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav me-auto">
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
{#
|
||||||
|
# Read-only character sheet. Table-shaped where the paper sheet itself is
|
||||||
|
# (ability scores, skills, attacks); the prose helpers (_line, _section, mod)
|
||||||
|
# are borrowed from the enemy stat block, another 5e sheet.
|
||||||
|
# {% import 'characters/_sheet.html.twig' as cs %}
|
||||||
|
# {{ cs.block(character) }}
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro block(c) %}
|
||||||
|
{% import 'enemy/_statblock.html.twig' as sb %}
|
||||||
|
|
||||||
|
<dl class="char-meta">
|
||||||
|
<div><dt>Race</dt><dd>{{ c.race ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Class & level</dt><dd>{{ c.characterClass ? c.characterClass ~ ' ' ~ c.level : '—' }}</dd></div>
|
||||||
|
<div><dt>Background</dt><dd>{{ c.background ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Alignment</dt><dd>{{ c.alignment ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Player</dt><dd>{{ c.playerName ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Experience</dt><dd>{{ c.experiencePoints is not null ? c.experiencePoints ~ ' XP' : '—' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="char-meta">
|
||||||
|
<div><dt>Armor Class</dt><dd>{{ c.armorClass ?? '—' }}</dd></div>
|
||||||
|
<div><dt>Initiative</dt><dd>{{ c.initiative is not null ? sb.mod(c.initiative) : '—' }}</dd></div>
|
||||||
|
<div><dt>Speed</dt><dd>{{ c.speed ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Hit Points</dt>
|
||||||
|
<dd>
|
||||||
|
{%- if c.currentHitPoints is not null or c.hitPointMaximum is not null -%}
|
||||||
|
{{ c.currentHitPoints ?? '?' }}{{ c.hitPointMaximum ? ' / ' ~ c.hitPointMaximum }}{{ c.temporaryHitPoints ? ' (+' ~ c.temporaryHitPoints ~ ' temp)' }}
|
||||||
|
{%- else -%}—{%- endif -%}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Hit Dice</dt><dd>{{ c.hitDice ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Proficiency Bonus</dt><dd>{{ sb.mod(c.proficiencyBonus) }}</dd></div>
|
||||||
|
<div><dt>Passive Perception</dt><dd>{{ c.passivePerception }}</dd></div>
|
||||||
|
<div><dt>Inspiration</dt><dd>{{ c.inspiration ? 'Yes' : '—' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm char-ability-table">
|
||||||
|
<thead><tr><th>Ability</th><th>Score</th><th>Mod</th><th>Save</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ability in constant('App\\Entity\\Character::ABILITIES') %}
|
||||||
|
{% set score = c.abilityScore(ability) %}
|
||||||
|
<tr class="{{ c.isProficientInSave(ability) ? 'is-proficient' }}">
|
||||||
|
<td>{{ ability }}</td>
|
||||||
|
<td>{{ score }}</td>
|
||||||
|
<td>{{ sb.mod(c.abilityModifier(score)) }}</td>
|
||||||
|
<td>{{ sb.mod(c.saveModifier(ability)) }}{{ c.isProficientInSave(ability) ? ' ●' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm char-skill-table">
|
||||||
|
<thead><tr><th>Skill</th><th>Abl.</th><th>Mod</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for skill, ability in constant('App\\Entity\\Character::SKILLS') %}
|
||||||
|
{% set proficient = c.isProficientInSkill(skill) or c.hasExpertiseInSkill(skill) %}
|
||||||
|
<tr class="{{ proficient ? 'is-proficient' }}">
|
||||||
|
<td>{{ skill }}{{ c.hasExpertiseInSkill(skill) ? ' ●●' : (proficient ? ' ●') }}</td>
|
||||||
|
<td>{{ ability|slice(0, 3) }}</td>
|
||||||
|
<td>{{ sb.mod(c.skillModifier(skill)) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if c.attacks is not empty %}
|
||||||
|
<h3 class="h6 border-bottom pb-1 mt-3">Attacks & Spellcasting</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm char-attack-table">
|
||||||
|
<thead><tr><th>Name</th><th>Atk Bonus</th><th>Damage/Type</th><th>Range</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for atk in c.attacks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ atk.name }}</td>
|
||||||
|
<td>{{ atk.bonus }}</td>
|
||||||
|
<td>{{ atk.damage }}</td>
|
||||||
|
<td>{{ atk.range }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ sb._section('Spellcasting', c.spellcasting) }}
|
||||||
|
{{ sb._section('Features & Traits', c.featuresTraits) }}
|
||||||
|
{{ sb._section('Other Proficiencies & Languages', c.otherProficienciesLanguages) }}
|
||||||
|
{{ sb._section('Equipment', c.equipment) }}
|
||||||
|
|
||||||
|
{% if c.personalityTraits or c.ideals or c.bonds or c.flaws %}
|
||||||
|
<h3 class="h6 border-bottom pb-1 mt-3">Personality</h3>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-2">
|
||||||
|
<div>{{ sb._line('Traits', c.personalityTraits) }}</div>
|
||||||
|
<div>{{ sb._line('Ideals', c.ideals) }}</div>
|
||||||
|
<div>{{ sb._line('Bonds', c.bonds) }}</div>
|
||||||
|
<div>{{ sb._line('Flaws', c.flaws) }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ sb._section('Appearance', c.appearance) }}
|
||||||
|
{{ sb._section('Backstory', c.backstory) }}
|
||||||
|
{{ sb._section('Allies & Organizations', c.alliesOrganizations) }}
|
||||||
|
{{ sb._section('Treasure', c.treasure) }}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ is_new ? 'New character' : 'Edit ' ~ character.name }} · Characters · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{{ is_new ? path('app_characters') : path('app_characters_show', {id: character.id}) }}"
|
||||||
|
class="text-decoration-none small">← {{ is_new ? 'All characters' : 'Back to ' ~ character.name }}</a>
|
||||||
|
<h1 class="h3 mb-0">{{ is_new ? 'New character' : 'Edit ' ~ character.name }}</h1>
|
||||||
|
<span class="text-body-secondary">Character sheet — game notes live on the character page.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_start(form) }}
|
||||||
|
<div class="row g-4" style="max-width: 70rem;">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Identity</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.name) }}
|
||||||
|
{{ form_row(form.playerName) }}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-8">{{ form_row(form.race) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.level) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">{{ form_row(form.characterClass) }}</div>
|
||||||
|
<div class="col-6">{{ form_row(form.background) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">{{ form_row(form.alignment) }}</div>
|
||||||
|
<div class="col-6">{{ form_row(form.experiencePoints) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Combat</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{ form_row(form.armorClass) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.initiative) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.speed) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{ form_row(form.hitPointMaximum) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.currentHitPoints) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.temporaryHitPoints) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-4">{{ form_row(form.hitDice) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.proficiencyBonus) }}</div>
|
||||||
|
<div class="col-4 pt-4">{{ form_row(form.inspiration) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Ability scores & proficiencies</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row row-cols-3 row-cols-md-6 g-2 mb-2">
|
||||||
|
<div>{{ form_row(form.strength) }}</div>
|
||||||
|
<div>{{ form_row(form.dexterity) }}</div>
|
||||||
|
<div>{{ form_row(form.constitution) }}</div>
|
||||||
|
<div>{{ form_row(form.intelligence) }}</div>
|
||||||
|
<div>{{ form_row(form.wisdom) }}</div>
|
||||||
|
<div>{{ form_row(form.charisma) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">{{ form_row(form.savingThrowProficiencies) }}</div>
|
||||||
|
<div class="col-md-4">{{ form_row(form.skillProficiencies) }}</div>
|
||||||
|
<div class="col-md-4">{{ form_row(form.skillExpertise) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Attacks & spellcasting</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-2">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Atk bonus</th><th>Damage/type</th><th>Range</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for i in 0..5 %}
|
||||||
|
{% set atk = character.attacks[i] ?? null %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][name]" value="{{ atk.name ?? '' }}" placeholder="Weapon or spell"></td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][bonus]" value="{{ atk.bonus ?? '' }}" placeholder="+5" style="max-width: 6rem"></td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][damage]" value="{{ atk.damage ?? '' }}" placeholder="1d4+5 piercing"></td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][range]" value="{{ atk.range ?? '' }}" placeholder="20/60 ft."></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mb-3">Blank rows are dropped when you save.</div>
|
||||||
|
{{ form_row(form.spellcasting) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Traits & equipment</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.featuresTraits) }}
|
||||||
|
{{ form_row(form.otherProficienciesLanguages) }}
|
||||||
|
{{ form_row(form.equipment) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Personality</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.personalityTraits) }}
|
||||||
|
{{ form_row(form.ideals) }}
|
||||||
|
{{ form_row(form.bonds) }}
|
||||||
|
{{ form_row(form.flaws) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Appearance & backstory</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.appearance) }}
|
||||||
|
{{ form_row(form.backstory) }}
|
||||||
|
{{ form_row(form.alliesOrganizations) }}
|
||||||
|
{{ form_row(form.treasure) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 d-flex gap-2">
|
||||||
|
<button class="btn btn-primary">{{ is_new ? 'Add character' : 'Save' }}</button>
|
||||||
|
<a href="{{ is_new ? path('app_characters') : path('app_characters_show', {id: character.id}) }}"
|
||||||
|
class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -3,9 +3,35 @@
|
|||||||
{% block title %}Characters · dmtools{% endblock %}
|
{% block title %}Characters · dmtools{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{{ include('_partials/page_placeholder.html.twig', {
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
title: 'Characters',
|
<h1 class="h3 mb-0">Characters</h1>
|
||||||
icon: '🧝',
|
<a href="{{ path('app_characters_new') }}" class="btn btn-primary btn-sm">Add character</a>
|
||||||
blurb: 'Player characters will live here — list, sheet details and party overview. Data model to be designed.',
|
</div>
|
||||||
}) }}
|
|
||||||
|
{% if characters is empty %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center text-body-secondary py-5">
|
||||||
|
No party members yet. Use <strong>Add character</strong> to bring in a sheet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-3">
|
||||||
|
{% for c in characters %}
|
||||||
|
<div class="col">
|
||||||
|
<a href="{{ path('app_characters_show', {id: c.id}) }}" class="card shadow-sm h-100 text-decoration-none text-body">
|
||||||
|
<div class="char-portrait char-portrait--card">
|
||||||
|
<img src="{{ asset(c.portraitPath) }}" alt="{{ c.name }}" loading="lazy" onerror="this.parentElement.remove()">
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="fw-semibold text-truncate" title="{{ c.name }}">{{ c.name }}</div>
|
||||||
|
<div class="text-body-secondary small">{{ c.metaLine ?: '—' }}</div>
|
||||||
|
{% if c.playerName %}
|
||||||
|
<div class="text-body-secondary small">Played by {{ c.playerName }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ character.name }} · Characters · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% import 'characters/_sheet.html.twig' as cs %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<a href="{{ path('app_characters') }}" class="text-decoration-none small">← All characters</a>
|
||||||
|
<h1 class="h3 mb-0">{{ character.name }}</h1>
|
||||||
|
<span class="text-body-secondary fst-italic">{{ character.metaLine ?: '—' }}</span>
|
||||||
|
{% if character.playerName %}
|
||||||
|
<div class="text-body-secondary small">Played by {{ character.playerName }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ path('app_characters_edit', {id: character.id}) }}" class="btn btn-outline-secondary btn-sm">Edit sheet</a>
|
||||||
|
<form method="post" action="{{ path('app_characters_delete', {id: character.id}) }}"
|
||||||
|
onsubmit="return confirm('Delete “{{ character.name|e('js') }}” from the character list?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete-character-' ~ character.id) }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="char-portrait char-portrait--hero">
|
||||||
|
<img src="{{ asset(character.portraitPath) }}" alt="{{ character.name }}" onerror="this.parentElement.remove()">
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ cs.block(character) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Game notes</span>
|
||||||
|
{% if character.updatedAt %}
|
||||||
|
<span class="text-body-secondary small fw-normal">saved {{ character.updatedAt|date('Y-m-d H:i') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_start(form) }}
|
||||||
|
{{ form_row(form.gameNotes) }}
|
||||||
|
<button class="btn btn-primary mt-2">Save notes</button>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{#
|
||||||
|
# Shared 5e stat-block rendering. Used on the enemy detail page and, folded
|
||||||
|
# up, inside each tracker combatant; its prose helpers (mod, _line, _section)
|
||||||
|
# are also borrowed by the character sheet (characters/_sheet.html.twig).
|
||||||
|
# {% import 'enemy/_statblock.html.twig' as sb %}
|
||||||
|
# {{ sb.block(enemy) }}
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro mod(n) %}{{ n >= 0 ? '+' ~ n : n }}{% endmacro %}
|
||||||
|
|
||||||
|
{% macro _stat(label, score, m) %}
|
||||||
|
<div class="col text-center">
|
||||||
|
<div class="text-uppercase small text-body-secondary">{{ label }}</div>
|
||||||
|
<div class="fw-semibold">{{ score }} ({{ m }})</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro _line(label, value) %}
|
||||||
|
{% if value %}
|
||||||
|
<div><span class="fw-semibold">{{ label }}.</span> {{ value }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro _section(title, body) %}
|
||||||
|
{% if body %}
|
||||||
|
<h3 class="h6 border-bottom pb-1 mt-3">{{ title }}</h3>
|
||||||
|
<div>{{ body|nl2br }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro block(e) %}
|
||||||
|
{% import _self as sb %}
|
||||||
|
{{ sb._line('Armor Class', e.armorClass ~ (e.armorClassNote ? ' (' ~ e.armorClassNote ~ ')' : '')) }}
|
||||||
|
{{ sb._line('Hit Points', e.hitPoints ~ (e.hitDice ? ' (' ~ e.hitDice ~ ')' : '')) }}
|
||||||
|
{{ sb._line('Speed', e.speed) }}
|
||||||
|
|
||||||
|
<div class="row row-cols-6 my-3 g-1">
|
||||||
|
{{ sb._stat('Str', e.strength, sb.mod(e.abilityModifier(e.strength))) }}
|
||||||
|
{{ sb._stat('Dex', e.dexterity, sb.mod(e.abilityModifier(e.dexterity))) }}
|
||||||
|
{{ sb._stat('Con', e.constitution, sb.mod(e.abilityModifier(e.constitution))) }}
|
||||||
|
{{ sb._stat('Int', e.intelligence, sb.mod(e.abilityModifier(e.intelligence))) }}
|
||||||
|
{{ sb._stat('Wis', e.wisdom, sb.mod(e.abilityModifier(e.wisdom))) }}
|
||||||
|
{{ sb._stat('Cha', e.charisma, sb.mod(e.abilityModifier(e.charisma))) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ sb._line('Saving Throws', e.savingThrows) }}
|
||||||
|
{{ sb._line('Skills', e.skills) }}
|
||||||
|
{{ sb._line('Damage Vulnerabilities', e.damageVulnerabilities) }}
|
||||||
|
{{ sb._line('Damage Resistances', e.damageResistances) }}
|
||||||
|
{{ sb._line('Damage Immunities', e.damageImmunities) }}
|
||||||
|
{{ sb._line('Condition Immunities', e.conditionImmunities) }}
|
||||||
|
{{ sb._line('Senses', e.senses) }}
|
||||||
|
{{ sb._line('Languages', e.languages ?? '—') }}
|
||||||
|
{{ sb._line('Challenge', e.challengeRating ? e.challengeRating ~ (e.experiencePoints ? ' (' ~ e.experiencePoints ~ ' XP)' : '') : null) }}
|
||||||
|
{{ sb._line('Proficiency Bonus', e.proficiencyBonus ? sb.mod(e.proficiencyBonus) : null) }}
|
||||||
|
|
||||||
|
{{ sb._section('Traits', e.traits) }}
|
||||||
|
{{ sb._section('Actions', e.actions) }}
|
||||||
|
{{ sb._section('Bonus Actions', e.bonusActions) }}
|
||||||
|
{{ sb._section('Reactions', e.reactions) }}
|
||||||
|
{{ sb._section('Legendary Actions', e.legendaryActions) }}
|
||||||
|
{{ sb._section('Description', e.description) }}
|
||||||
|
|
||||||
|
{% if e.source %}
|
||||||
|
<p class="text-body-secondary small mt-3 mb-0">Source: {{ e.source }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -3,10 +3,24 @@
|
|||||||
{% block title %}Enemies · dmtools{% endblock %}
|
{% block title %}Enemies · dmtools{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
|
||||||
<h1 class="h3 mb-0">Enemies</h1>
|
<h1 class="h3 mb-0">Enemies</h1>
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap"
|
||||||
|
data-controller="tracker-picker"
|
||||||
|
data-tracker-picker-url-value="{{ path('app_tracker_add') }}"
|
||||||
|
data-tracker-picker-token-value="{{ csrf_token('track-add') }}">
|
||||||
|
<select class="form-select form-select-sm encounter-select" style="width: auto;" aria-label="Encounter">
|
||||||
|
<option value="new"{{ selected_encounter is null ? ' selected' }}>— New encounter —</option>
|
||||||
|
{% for e in encounters %}
|
||||||
|
<option value="{{ e.id }}"{{ selected_encounter == e.id ? ' selected' }}>{{ e.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-action="tracker-picker#submit">
|
||||||
|
Add to tracker
|
||||||
|
</button>
|
||||||
<a href="{{ path('app_enemies_new') }}" class="btn btn-primary btn-sm">Add enemy</a>
|
<a href="{{ path('app_enemies_new') }}" class="btn btn-primary btn-sm">Add enemy</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -21,9 +35,8 @@
|
|||||||
<th data-data="type">Type</th>
|
<th data-data="type">Type</th>
|
||||||
<th data-data="challengeRating">CR</th>
|
<th data-data="challengeRating">CR</th>
|
||||||
<th data-data="armorClass">AC</th>
|
<th data-data="armorClass">AC</th>
|
||||||
<th data-data="hitPoints">HP</th>
|
|
||||||
<th data-data="gameNotes" data-format="truncate" data-orderable="false">Game notes</th>
|
<th data-data="gameNotes" data-format="truncate" data-orderable="false">Game notes</th>
|
||||||
<th data-data="updatedAt">Updated</th>
|
<th data-data="id" data-format="pick" data-orderable="false">Track</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
|
|||||||
@@ -2,29 +2,7 @@
|
|||||||
|
|
||||||
{% block title %}{{ enemy.name }} · Enemies · dmtools{% endblock %}
|
{% block title %}{{ enemy.name }} · Enemies · dmtools{% endblock %}
|
||||||
|
|
||||||
{% macro mod(n) %}{{ n >= 0 ? '+' ~ n : n }}{% endmacro %}
|
{% import 'enemy/_statblock.html.twig' as sb %}
|
||||||
|
|
||||||
{% macro stat(label, score, m) %}
|
|
||||||
<div class="col text-center">
|
|
||||||
<div class="text-uppercase small text-body-secondary">{{ label }}</div>
|
|
||||||
<div class="fw-semibold">{{ score }} ({{ m }})</div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro line(label, value) %}
|
|
||||||
{% if value %}
|
|
||||||
<div><span class="fw-semibold">{{ label }}.</span> {{ value }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro section(title, body) %}
|
|
||||||
{% if body %}
|
|
||||||
<h2 class="h6 border-bottom pb-1 mt-3">{{ title }}</h2>
|
|
||||||
<div>{{ body|nl2br }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% import _self as sb %}
|
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3 gap-3">
|
<div class="d-flex justify-content-between align-items-start mb-3 gap-3">
|
||||||
@@ -33,47 +11,21 @@
|
|||||||
<h1 class="h3 mb-0">{{ enemy.name }}</h1>
|
<h1 class="h3 mb-0">{{ enemy.name }}</h1>
|
||||||
<span class="text-body-secondary fst-italic">{{ enemy.metaLine }}</span>
|
<span class="text-body-secondary fst-italic">{{ enemy.metaLine }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<a href="{{ path('app_enemies_edit', {id: enemy.id}) }}" class="btn btn-outline-secondary btn-sm">Edit stats</a>
|
<a href="{{ path('app_enemies_edit', {id: enemy.id}) }}" class="btn btn-outline-secondary btn-sm">Edit stats</a>
|
||||||
|
<form method="post" action="{{ path('app_enemies_delete', {id: enemy.id}) }}"
|
||||||
|
onsubmit="return confirm('Delete “{{ enemy.name|e('js') }}” from the enemy list?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete-enemy-' ~ enemy.id) }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{ sb.line('Armor Class', enemy.armorClass ~ (enemy.armorClassNote ? ' (' ~ enemy.armorClassNote ~ ')' : '')) }}
|
{{ sb.block(enemy) }}
|
||||||
{{ sb.line('Hit Points', enemy.hitPoints ~ (enemy.hitDice ? ' (' ~ enemy.hitDice ~ ')' : '')) }}
|
|
||||||
{{ sb.line('Speed', enemy.speed) }}
|
|
||||||
|
|
||||||
<div class="row row-cols-6 my-3 g-1">
|
|
||||||
{{ sb.stat('Str', enemy.strength, sb.mod(enemy.abilityModifier(enemy.strength))) }}
|
|
||||||
{{ sb.stat('Dex', enemy.dexterity, sb.mod(enemy.abilityModifier(enemy.dexterity))) }}
|
|
||||||
{{ sb.stat('Con', enemy.constitution, sb.mod(enemy.abilityModifier(enemy.constitution))) }}
|
|
||||||
{{ sb.stat('Int', enemy.intelligence, sb.mod(enemy.abilityModifier(enemy.intelligence))) }}
|
|
||||||
{{ sb.stat('Wis', enemy.wisdom, sb.mod(enemy.abilityModifier(enemy.wisdom))) }}
|
|
||||||
{{ sb.stat('Cha', enemy.charisma, sb.mod(enemy.abilityModifier(enemy.charisma))) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ sb.line('Saving Throws', enemy.savingThrows) }}
|
|
||||||
{{ sb.line('Skills', enemy.skills) }}
|
|
||||||
{{ sb.line('Damage Vulnerabilities', enemy.damageVulnerabilities) }}
|
|
||||||
{{ sb.line('Damage Resistances', enemy.damageResistances) }}
|
|
||||||
{{ sb.line('Damage Immunities', enemy.damageImmunities) }}
|
|
||||||
{{ sb.line('Condition Immunities', enemy.conditionImmunities) }}
|
|
||||||
{{ sb.line('Senses', enemy.senses) }}
|
|
||||||
{{ sb.line('Languages', enemy.languages ?? '—') }}
|
|
||||||
{{ sb.line('Challenge', enemy.challengeRating ? enemy.challengeRating ~ (enemy.experiencePoints ? ' (' ~ enemy.experiencePoints ~ ' XP)' : '') : null) }}
|
|
||||||
{{ sb.line('Proficiency Bonus', enemy.proficiencyBonus ? sb.mod(enemy.proficiencyBonus) : null) }}
|
|
||||||
|
|
||||||
{{ sb.section('Traits', enemy.traits) }}
|
|
||||||
{{ sb.section('Actions', enemy.actions) }}
|
|
||||||
{{ sb.section('Bonus Actions', enemy.bonusActions) }}
|
|
||||||
{{ sb.section('Reactions', enemy.reactions) }}
|
|
||||||
{{ sb.section('Legendary Actions', enemy.legendaryActions) }}
|
|
||||||
{{ sb.section('Description', enemy.description) }}
|
|
||||||
|
|
||||||
{% if enemy.source %}
|
|
||||||
<p class="text-body-secondary small mt-3 mb-0">Source: {{ enemy.source }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,37 +3,31 @@
|
|||||||
{% block title %}Overview · dmtools{% endblock %}
|
{% block title %}Overview · dmtools{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<h1 class="h3 mb-3">Overview</h1>
|
||||||
<h1 class="h3 mb-0">Overview</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-body-secondary">
|
{% set tiles = [
|
||||||
Starting point for the Monday-evening campaign tools. The table below is a
|
{ route: 'app_characters', icon: '🧝', label: 'Characters', blurb: 'The player characters' },
|
||||||
placeholder that shows the Bootstrap + DataTables layout wiring works
|
{ route: 'app_npcs', icon: '🧑🌾', label: "NPC's", blurb: 'Villagers and other non-player characters' },
|
||||||
(search, sort, paging).
|
{ route: 'app_enemies', icon: '👹', label: 'Enemies', blurb: 'Monster stat blocks' },
|
||||||
</p>
|
{ route: 'app_tracker', icon: '⚔️', label: 'Tracker', blurb: 'Run encounters and track hit points' },
|
||||||
|
{ route: 'app_art', icon: '🖼️', label: 'Art', blurb: 'Maps, handouts and character pieces' },
|
||||||
|
{ route: 'app_storyline', icon: '📜', label: 'Storyline', blurb: 'Campaign notes and plot threads' },
|
||||||
|
] %}
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-3">
|
||||||
<div class="card-body">
|
{% for t in tiles %}
|
||||||
<table class="table table-striped table-hover align-middle datatable"
|
<div class="col">
|
||||||
data-datatable-options='{"order": [[0, "asc"]]}'>
|
<a href="{{ path(t.route) }}"
|
||||||
<thead>
|
class="card shadow-sm h-100 text-decoration-none text-body home-tile">
|
||||||
<tr>
|
<div class="card-body d-flex align-items-start gap-3">
|
||||||
<th scope="col">Name</th>
|
<div class="fs-1 lh-1">{{ t.icon }}</div>
|
||||||
<th scope="col">Type</th>
|
<div>
|
||||||
<th scope="col">Location</th>
|
<div class="h5 mb-1">{{ t.label }}</div>
|
||||||
<th scope="col">Notes</th>
|
<div class="text-body-secondary small">{{ t.blurb }}</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Seraphina Vane</td><td>NPC</td><td>Blackreach</td><td>Owes the party a favour</td></tr>
|
|
||||||
<tr><td>Goblin ambush</td><td>Encounter</td><td>Old Kings Road</td><td>CR 2, 6 goblins</td></tr>
|
|
||||||
<tr><td>The Sunken Vault</td><td>Location</td><td>Lake Nydra</td><td>Water-breathing required</td></tr>
|
|
||||||
<tr><td>Rusted Key</td><td>Item</td><td>Party inventory</td><td>Opens the vault antechamber</td></tr>
|
|
||||||
<tr><td>Captain Dorae</td><td>NPC</td><td>Saltmarsh docks</td><td>Will smuggle cargo for coin</td></tr>
|
|
||||||
<tr><td>Owlbear den</td><td>Encounter</td><td>Whisperwood</td><td>CR 3, mother + 2 cubs</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Puzzle: {{ room }} · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block navbar %}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
<style>
|
||||||
|
.puzzle-join {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 4rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-fullbleed {
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: -50vw;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-board {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1200 / 485;
|
||||||
|
background: #1b1f24;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-tray {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 39.4%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, #2a2f37, #1b1f24);
|
||||||
|
border-right: 3px solid rgba(255, 255, 255, .08);
|
||||||
|
box-shadow: inset 0 0 30px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-tray-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.3rem;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-wall {
|
||||||
|
position: absolute;
|
||||||
|
left: 39.4%;
|
||||||
|
top: 0;
|
||||||
|
width: 60.6%;
|
||||||
|
aspect-ratio: 1536 / 1024;
|
||||||
|
background: url('{{ asset('image/puzzle/wall.png') }}') center / cover no-repeat;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 0, 0, .4), 0 10px 30px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, .5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece.is-dragging img {
|
||||||
|
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, .65));
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-cursor {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-cursor-dot {
|
||||||
|
display: block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-cursor-label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: .7rem;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-status {
|
||||||
|
opacity: .7;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div data-controller="puzzle-room"
|
||||||
|
data-puzzle-room-room-value="{{ room }}"
|
||||||
|
data-puzzle-room-relay-url-value="{{ relayPublicUrl }}"
|
||||||
|
data-puzzle-room-reset-token-value="{{ resetToken }}">
|
||||||
|
|
||||||
|
<form class="puzzle-join card p-4" data-puzzle-room-target="joinForm" data-action="submit->puzzle-room#join">
|
||||||
|
<h1 class="h4 mb-3">Join the puzzle</h1>
|
||||||
|
<label class="form-label" for="puzzle-name">Your name</label>
|
||||||
|
<input class="form-control mb-3" id="puzzle-name" type="text" maxlength="40" required
|
||||||
|
data-puzzle-room-target="nameInput" placeholder="e.g. Frank" autofocus>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Join</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="puzzle-fullbleed">
|
||||||
|
<div class="mb-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="h5 mb-0">Puzzle room: {{ room }}</h1>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="puzzle-status" data-puzzle-room-target="status">Connecting…</span>
|
||||||
|
{% if resetToken %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="puzzle-room#reset">Reset puzzle</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="puzzle-board" hidden data-puzzle-room-target="board">
|
||||||
|
<div class="puzzle-tray">
|
||||||
|
<span class="puzzle-tray-label">Spare gears</span>
|
||||||
|
</div>
|
||||||
|
<div class="puzzle-wall"></div>
|
||||||
|
|
||||||
|
{# Gear sizes are % of board width. The bronze pegboard panel in wall.png
|
||||||
|
is ~49.5% of the wall image's own width, and the wall image itself is
|
||||||
|
60.6% of the board width (the rest is the 3-column tray on the left) -
|
||||||
|
so the biggest gear (F) is a third of the panel's actual on-screen
|
||||||
|
size, times 1.25 (bumped up 25% on request). Packed tightly (minimal
|
||||||
|
gaps/padding) so the tray stays as narrow and short as possible,
|
||||||
|
leaving the wall panel as big as it can be while still fitting one
|
||||||
|
screen height. #}
|
||||||
|
{% for piece in [
|
||||||
|
{ id: 'f', size: 12.49, x: 0.0657, y: 0.2582 },
|
||||||
|
{ id: 'e', size: 9.78, x: 0.197, y: 0.2582 },
|
||||||
|
{ id: 'd', size: 8.53, x: 0.3283, y: 0.2582 },
|
||||||
|
{ id: 'i', size: 7.38, x: 0.0657, y: 0.5393 },
|
||||||
|
{ id: 'c', size: 6.84, x: 0.197, y: 0.5393 },
|
||||||
|
{ id: 'h', size: 6.66, x: 0.3283, y: 0.5393 },
|
||||||
|
{ id: 'b', size: 5.31, x: 0.0657, y: 0.7339 },
|
||||||
|
{ id: 'g', size: 5.25, x: 0.197, y: 0.7339 },
|
||||||
|
{ id: 'a', size: 4.31, x: 0.3283, y: 0.7339 },
|
||||||
|
] %}
|
||||||
|
<div class="puzzle-piece" data-puzzle-room-target="piece" data-piece-id="{{ piece.id }}"
|
||||||
|
data-action="pointerdown->puzzle-room#startDrag"
|
||||||
|
style="left: {{ (piece.x * 100) }}%; top: {{ (piece.y * 100) }}%; width: {{ piece.size }}%;">
|
||||||
|
<img src="{{ asset('image/puzzle/gear-' ~ piece.id ~ '.png') }}" alt="Gear {{ piece.id|upper }}" draggable="false">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ chapter.title }} · Storyline · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body_class %}storyline-chapter-page{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="storyline-split" data-controller="chapter-notes">
|
||||||
|
<div class="storyline-split-main">
|
||||||
|
<div class="row g-4 storyline-layout">
|
||||||
|
<nav class="col-lg-3 order-lg-2">
|
||||||
|
<div class="storyline-toc">
|
||||||
|
<a href="{{ path('app_storyline') }}" class="text-decoration-none small d-inline-block mb-2">← All chapters</a>
|
||||||
|
<div class="text-uppercase small text-body-secondary fw-semibold mb-2" style="letter-spacing:.08em">Chapters</div>
|
||||||
|
<ol class="list-unstyled mb-0" data-controller="storyline-toc">
|
||||||
|
{% for s, c in chapters %}
|
||||||
|
{% set open = s == slug %}
|
||||||
|
<li class="storyline-toc-item{{ open ? ' is-open' }}">
|
||||||
|
<div class="storyline-toc-row">
|
||||||
|
{% if sections[s] is not empty %}
|
||||||
|
<button type="button" class="storyline-toc-fold" data-action="storyline-toc#fold"
|
||||||
|
aria-expanded="{{ open ? 'true' : 'false' }}"
|
||||||
|
aria-label="Show sections of {{ c.title }}">▸</button>
|
||||||
|
{% else %}<span class="storyline-toc-fold"></span>{% endif %}
|
||||||
|
<a href="{{ path('app_storyline_chapter', {slug: s}) }}"
|
||||||
|
class="storyline-toc-link{{ s == slug ? ' is-active' }}">
|
||||||
|
<span class="storyline-toc-num">{{ c.tag }}</span>{{ c.title }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if sections[s] is not empty %}
|
||||||
|
<ol class="storyline-toc-sections list-unstyled">
|
||||||
|
{% for sec in sections[s] %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ s == slug ? '#' ~ sec.id : path('app_storyline_chapter', {slug: s}) ~ '#' ~ sec.id }}"
|
||||||
|
class="storyline-toc-sub" {% if s == slug %}data-storyline-toc-target="link"{% endif %}>
|
||||||
|
<span class="storyline-toc-num">{{ sec.num }}</span>{{ sec.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="col-lg-9 order-lg-1">
|
||||||
|
<article class="storyline-doc">
|
||||||
|
<header class="storyline-doc-head">
|
||||||
|
<div class="storyline-eyebrow">Storyline · {{ chapter.eyebrow }}</div>
|
||||||
|
<h1>{{ chapter.title }}</h1>
|
||||||
|
<p class="storyline-standfirst">{{ chapter.summary }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{ body|raw }}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<nav class="storyline-pager">
|
||||||
|
{% if prev %}
|
||||||
|
<a href="{{ path('app_storyline_chapter', {slug: prev}) }}" class="storyline-pager-link">
|
||||||
|
<span class="storyline-pager-dir">← Previous</span>
|
||||||
|
<span class="storyline-pager-title">{{ chapters[prev].title }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}<span></span>{% endif %}
|
||||||
|
{% if next %}
|
||||||
|
<a href="{{ path('app_storyline_chapter', {slug: next}) }}" class="storyline-pager-link text-lg-end">
|
||||||
|
<span class="storyline-pager-dir">Next →</span>
|
||||||
|
<span class="storyline-pager-title">{{ chapters[next].title }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}<span></span>{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="storyline-notes-resize" data-chapter-notes-target="handle"
|
||||||
|
data-action="pointerdown->chapter-notes#startResize" role="separator"
|
||||||
|
aria-label="Resize notes panel" title="Drag to resize"></div>
|
||||||
|
|
||||||
|
<section class="storyline-notes-dock" data-chapter-notes-target="dock">
|
||||||
|
<div class="storyline-notes-bar">
|
||||||
|
<button type="button" class="storyline-notes-toggle" data-action="chapter-notes#toggle"
|
||||||
|
data-chapter-notes-target="toggle" aria-expanded="true">
|
||||||
|
<span class="storyline-notes-caret">▾</span> Chapter notes
|
||||||
|
</button>
|
||||||
|
{% if note.updatedAt %}
|
||||||
|
<span class="text-body-secondary small">saved {{ note.updatedAt|date('Y-m-d H:i') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="storyline-notes-body">
|
||||||
|
{{ form_start(form, {attr: {class: 'storyline-notes-form'}}) }}
|
||||||
|
{{ form_errors(form.notes) }}
|
||||||
|
{{ form_widget(form.notes, {attr: {class: 'form-control storyline-notes-text'}}) }}
|
||||||
|
<div class="storyline-notes-actions">
|
||||||
|
<button class="btn btn-primary btn-sm">Save notes</button>
|
||||||
|
<span class="text-body-secondary small">This chapter only. Saved to the server.</span>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
{# Campaign Overview — hand-formatted from the campaign bible text. #}
|
||||||
|
|
||||||
|
<section class="storyline-ref">
|
||||||
|
<div class="storyline-ref-panel">
|
||||||
|
<h3>By the numbers</h3>
|
||||||
|
<dl>
|
||||||
|
<div><dt>~400 years</dt><dd>since the five heroes sealed Kyuss</dd></div>
|
||||||
|
<div><dt>~175</dt><dd>villagers in Brackenford</dd></div>
|
||||||
|
<div><dt>~133</dt><dd>eligible blood donors</dd></div>
|
||||||
|
<div><dt>~15 litres</dt><dd>of blood collected every month</dd></div>
|
||||||
|
<div><dt>~2 litres</dt><dd>per month is all Aldren needs for himself</dd></div>
|
||||||
|
<div><dt>~1 hour</dt><dd>village to castle (the party travels in for ~2)</dd></div>
|
||||||
|
<div><dt>Age 15</dt><dd>when village-born children are told the secret</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="storyline-ref-panel">
|
||||||
|
<h3>The cast</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Sir Aldren</strong> — one of the original five heroes; cursed with vampirism by Kyuss; for four centuries the prison's unseen Warden.</li>
|
||||||
|
<li><strong>Kyuss</strong> — the ancient evil (or a fragment of it) sealed beneath the castle, now leaking out as the seal fails.</li>
|
||||||
|
<li><strong>Eamon Vale</strong> — 41, a Brackenford farmer; inherits; meets Aldren; misunderstands him; is hidden away by the council.</li>
|
||||||
|
<li><strong>Garrick Voss</strong> — 61, a village elder; not evil, but afraid; the party's fiercest opposition.</li>
|
||||||
|
<li><strong>Bastian Cooper</strong> — drives the blood cart to the castle each month.</li>
|
||||||
|
<li><strong>The halfling family</strong> — the player characters: hired to deliver the inheritance and come back with Eamon's signed, magically authenticated receipt.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>01</span> The Truth, Four Hundred Years Ago</h2>
|
||||||
|
<p class="storyline-lead">Roughly four centuries ago, something connected to <strong>Kyuss</strong> began spreading through the region that now surrounds Brackenford.</p>
|
||||||
|
<p>Whether it was Kyuss himself, an aspect of him, or a fragment of his power can stay deliberately uncertain. What matters is that it <strong>could not simply be killed</strong>. Its influence corrupted living creatures, animated the dead, and gradually transformed the land.</p>
|
||||||
|
<p><strong>Five adventurers</strong> confronted it. Among them was <strong>Sir Aldren</strong>, then a mortal warrior and accomplished fighter.</p>
|
||||||
|
<p>They eventually discovered that destroying the entity was impossible. Instead they devised a prison: an ancient magical seal capable of containing the fragment and keeping its influence from the world. They succeeded — but not without cost.</p>
|
||||||
|
<p>During the final confrontation, <strong>Kyuss cursed Aldren with vampirism</strong>. It was meant as a last act of cruelty: one of the heroes who had fought undeath would himself become an undead monster.</p>
|
||||||
|
<p>Kyuss made one critical mistake. <u>Aldren did not leave.</u> Rather than surrender to his curse, he became the guardian of the very prison he had helped create. The other four heroes eventually died. <strong>Aldren remained. For four hundred years.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>02</span> The Blood</h2>
|
||||||
|
<p>The prison was never completely self-sustaining. Maintaining the seal requires <strong>life energy</strong>, and blood is an effective conduit for it.</p>
|
||||||
|
<p>Aldren also requires blood to remain sane — but the amount he personally consumes is <strong>surprisingly small</strong>, roughly <strong>two litres per month</strong>. The remainder goes toward maintaining the prison.</p>
|
||||||
|
<p>Over the centuries the details were lost. The people of Brackenford remember only the simplified version:</p>
|
||||||
|
<blockquote class="storyline-quote">There is a vampire in the castle. Give him blood, and he leaves Brackenford alone.</blockquote>
|
||||||
|
<p><strong>Nobody alive remembers that Aldren is protecting them.</strong> Nobody remembers Kyuss. Nobody remembers what the blood actually does. Even the village council doesn't know the full truth. They aren't maintaining a sinister conspiracy — they're maintaining <strong>a tradition they are terrified to stop</strong>.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>03</span> Modern Brackenford</h2>
|
||||||
|
<p class="storyline-lead">Brackenford is an isolated but prosperous, largely self-sufficient village in the hills, with roughly <strong>175 inhabitants</strong>.</p>
|
||||||
|
<p>It farms its own food, raises livestock, makes clothing, and keeps the craftsmen, hunters, woodcutters, mill, temple, tavern and inn a village needs. It isn't completely cut off: several other villages lie about a day's journey apart, and apprentices move between settlements — bringing new skills, marriages and “new blood.”</p>
|
||||||
|
<p>Outsiders who settle permanently eventually undergo a ceremony in which they are told the village's great secret. Children born in Brackenford learn it during a ceremony in the year they turn <strong>fifteen</strong>. The secret is simple: <strong>Sir Aldren is a vampire, and once a month Brackenford sends him blood.</strong></p>
|
||||||
|
<p>There are currently around <strong>133 eligible donors</strong>. About <strong>15 litres</strong> of blood are collected each month, spread across enough villagers that individual donors get substantial recovery time. The villagers believe this is <strong>tribute</strong>. The blood is loaded onto a specially maintained cart and driven by <strong>Bastian Cooper</strong> to Aldren's castle, roughly an hour away. For generations it has worked. <strong>Nobody asks why.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>04</span> But the System Is Failing</h2>
|
||||||
|
<p>Aldren has known for some time that something is wrong. The seal is deteriorating. At first he compensated himself. Then maintaining it began requiring more blood. Then more. The normal fifteen litres eventually became <strong>insufficient</strong>.</p>
|
||||||
|
<p>Unlike the previous centuries, the deterioration has now reached a <strong>tipping point</strong>, and the failure is <strong>accelerating</strong>. Aldren is exhausted. He has been doing this for four hundred years and has lost everyone he once knew. The purpose of many parts of the original mechanism is becoming difficult even for him to remember, and parts of the prison have physically decayed.</p>
|
||||||
|
<p>And Aldren himself is <strong>starving</strong>. Of the blood sent each month, he takes only what he needs to retain control of himself. He refuses to take more simply to make himself stronger when that blood is desperately needed by the seal.</p>
|
||||||
|
<aside class="storyline-callout" data-label="Ticking clock">
|
||||||
|
He knows something the people of Brackenford don't: <strong>the prison will fail soon.</strong> Not in another century. Not in ten years. Possibly <strong>within weeks</strong>.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>05</span> The Heir</h2>
|
||||||
|
<p>Meanwhile, somewhere completely unrelated to this ancient problem, someone dies. The deceased leaves an inheritance to <strong>Eamon Vale</strong>, a 41-year-old farmer living in Brackenford.</p>
|
||||||
|
<p>A group of halflings — a family trying to earn enough money for their farm — accepts what sounds like an unusually lucrative but very easy job: <strong>deliver the inheritance to Eamon Vale.</strong> They receive half their payment in advance. To collect the rest they must return with a <strong>magically authenticated receipt signed by Eamon himself</strong>. No Eamon, no signature. No signature, no money.</p>
|
||||||
|
<aside class="storyline-callout" data-label="Why the players stay">
|
||||||
|
They aren't travelling to Brackenford to save the world. <strong>They're trying to get paid.</strong>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>06</span> The Night Before Their Arrival</h2>
|
||||||
|
<p>Shortly before the characters reach Brackenford, Eamon is outside the village in the early evening. He encounters <strong>Sir Aldren</strong>, who is desperate enough to break centuries of distance between himself and the villagers.</p>
|
||||||
|
<p>Exactly what Aldren says should be carefully chosen. Something along the lines of:</p>
|
||||||
|
<blockquote class="storyline-quote">Tell them the blood is no longer enough.</blockquote>
|
||||||
|
<p><strong>Aldren means:</strong> the seal is failing. <strong>Eamon hears:</strong> the vampire wants more.</p>
|
||||||
|
<p>He runs back to Brackenford terrified, telling people that Aldren approached him, that the vampire says the blood isn't enough, and that he wants more. Panic begins to spread. The council intervenes: Eamon is taken somewhere secure, supposedly for his own protection and to let him calm down, because they don't want rumours running through the village before they understand what happened.</p>
|
||||||
|
<p>Then five complete outsiders arrive and ask:</p>
|
||||||
|
<blockquote class="storyline-quote">Where can we find Eamon Vale?</blockquote>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>07</span> The Beginning of the Mystery</h2>
|
||||||
|
<p>The characters reach Brackenford after roughly two hours' travel. Eamon is nowhere to be found. At the tavern or inn they ask after him, and the reaction is strange — people exchange glances. Someone tells them:</p>
|
||||||
|
<blockquote class="storyline-quote">We'll take the inheritance for him.</blockquote>
|
||||||
|
<p>Unfortunately that isn't possible: they specifically need Eamon's magically authenticated signature. At first nobody wants to explain where he is.</p>
|
||||||
|
<p>Now the players have exactly the incentive we wanted. They don't investigate because a mysterious village sounds interesting. <strong>Someone in this village is preventing them from finishing their job and getting their money.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>08</span> What the Players Begin to Discover</h2>
|
||||||
|
<p>Their search for Eamon gradually exposes cracks in Brackenford's pleasant exterior. People become evasive when the castle is mentioned. Certain subjects immediately end conversations. The council insists Eamon is safe. The villagers have peculiar customs surrounding blood. Some are frightened of outsiders asking too many questions.</p>
|
||||||
|
<p>Eventually the party learns about the <strong>blood cart</strong>. Once every month, blood leaves Brackenford, travels to the castle, and in that castle lives <strong>Sir Aldren — a vampire</strong>.</p>
|
||||||
|
<p>Suddenly everything makes sense. Eamon encountered the vampire. The vampire demanded more blood. The council silenced Eamon. The village secretly feeds the vampire. The elders don't want outsiders investigating. There is a monster in a castle overlooking Brackenford.</p>
|
||||||
|
<p class="storyline-turn">The players have solved the mystery. <u>Except they haven't.</u></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>09</span> Garrick Voss</h2>
|
||||||
|
<p><strong>Garrick Voss</strong>, a 61-year-old village elder, becomes one of the strongest sources of resistance. Garrick isn't evil. He is <strong>afraid</strong>. His family has survived in Brackenford because generations before him followed the rules: blood goes into the cart, the cart goes to Aldren, Aldren stays away, nobody dies.</p>
|
||||||
|
<p>As far as Garrick is concerned, that is an extraordinarily successful arrangement — and then five outsiders arrive and immediately start questioning something that has kept his family alive for centuries. Eventually his frustration can produce the line:</p>
|
||||||
|
<blockquote class="storyline-quote">You are outside blood.</blockquote>
|
||||||
|
<p>They have no right, in his eyes, to risk Brackenford's children because they're curious. And if they insist on interfering with the arrangement:</p>
|
||||||
|
<blockquote class="storyline-quote">Then perhaps you can pay with your blood.</blockquote>
|
||||||
|
<p>This strongly reinforces the apparent truth: <strong>the elders are protecting the vampire.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>10</span> Finding Eamon</h2>
|
||||||
|
<p>Importantly, the characters should <strong>not</strong> find Eamon immediately. By the time they reach him, they should already know enough about the blood cart that his story matters.</p>
|
||||||
|
<p>Eamon tells them what happened. He was outside. He encountered Aldren. And Aldren told him: <strong>the blood is no longer enough.</strong></p>
|
||||||
|
<aside class="storyline-callout" data-label="First ticking clock">
|
||||||
|
This becomes the campaign's first major deadline: the next blood cart is coming, and the vampire is apparently expecting more.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>11</span> Sir Aldren, the Monster in the Castle</h2>
|
||||||
|
<p>Everything should push the players toward one obvious conclusion: <strong>Aldren is the campaign's villain.</strong></p>
|
||||||
|
<p>He is a four-hundred-year-old vampire. Villagers send him blood. He lives alone in an old castle. He recently approached a villager and demanded more. The council is terrified of angering him. Strange things are beginning to happen around Brackenford. And the worse the seal becomes, the more undead phenomena appear — which, from the players' perspective, seems to <strong>prove that Aldren is becoming more powerful.</strong></p>
|
||||||
|
<aside class="storyline-callout" data-label="The central red herring">
|
||||||
|
The players are correctly interpreting almost every individual clue. They are simply drawing <strong>the wrong connection</strong> between them.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>12</span> The World Begins to Turn Against Them</h2>
|
||||||
|
<p>The manifestations start small. Animals behave strangely. Plants rot unexpectedly. People hear things at night. Dead animals don't stay where they were buried. Worms — or similar imagery associated with <strong>Kyuss</strong> — begin appearing.</p>
|
||||||
|
<p>Then things escalate. Undead appear. The countryside becomes increasingly hostile. Old places around Brackenford begin reacting to something. Ancient symbols are uncovered. Parts of the forgotten history of the region resurface.</p>
|
||||||
|
<p>The party learns that roughly four hundred years ago, <strong>five heroes fought some terrible evil here</strong>. This is a local legend in Brackenford; a village or two away it's merely an old story; further away almost nobody has heard of it, and much of the tale has been distorted.</p>
|
||||||
|
<p>Eventually the party discovers something deeply inconvenient: <strong>Sir Aldren was one of the five heroes.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>13</span> The Five and the Five</h2>
|
||||||
|
<p>There is a deliberate parallel. Four hundred years ago, five adventurers arrived to confront the evil. Now, five adventurers stand in Brackenford.</p>
|
||||||
|
<aside class="storyline-callout" data-label="Design intent">
|
||||||
|
This shouldn't become a prophecy or reincarnation story. The modern party isn't destined to repeat history; the parallel is <strong>thematic</strong>. But Aldren sees it, and it can affect him profoundly. For four centuries everyone he fought beside has been dead — and then five people walk into his castle together. Perhaps, for just a moment, Aldren sees his old companions standing there.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>14</span> Meeting Aldren</h2>
|
||||||
|
<p>The eventual meeting with Aldren should <strong>overturn the campaign</strong>. The party expects a vampire lord. Instead they find <strong>a tired soldier</strong> — still dangerous, still frightening, still unmistakably a vampire, but exhausted.</p>
|
||||||
|
<p>Aldren isn't surrounded by victims. There is no court of vampire spawn. There are no dungeons filled with villagers. There is a man who has spent four centuries guarding something. Perhaps they find him physically <strong>beside part of the seal</strong>.</p>
|
||||||
|
<p>And when they confront him about demanding more blood, they finally learn the truth:</p>
|
||||||
|
<blockquote class="storyline-quote">You think the blood is for me?</blockquote>
|
||||||
|
<p>Only a fraction is. The rest keeps <strong>something else</strong> imprisoned.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>15</span> Kyuss</h2>
|
||||||
|
<p>Now the campaign changes. The question is no longer <em>how do we kill Aldren?</em> It becomes <strong>what has Aldren been keeping underneath this place for four hundred years?</strong></p>
|
||||||
|
<p>The ancient evil associated with <strong>Kyuss</strong> has been contained beneath or around the castle. Aldren isn't its master — he is its <strong>jailer</strong>. His vampirism isn't evidence that he serves the ancient evil; it is <strong>the scar Kyuss left on him</strong> when Aldren helped imprison it.</p>
|
||||||
|
<p>And now the prison is failing. The strange events around Brackenford aren't Aldren's influence spreading. <u>They're Kyuss leaking out.</u></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>16</span> The Dangerous Possibility</h2>
|
||||||
|
<aside class="storyline-callout storyline-callout--warn" data-label="Warning">
|
||||||
|
<p>The players genuinely <strong>can</strong> attack Aldren before discovering the truth. If they defeat or severely weaken him too early, they haven't solved the problem — they've just removed one of the things keeping the prison stable. The prison opens further and the campaign suddenly becomes substantially worse.</p>
|
||||||
|
<p>That makes the twist <strong>meaningful rather than cosmetic</strong>. The players weren't merely mistaken about Aldren; acting on that mistake has consequences. But it shouldn't automatically end the campaign — it simply forces them to solve a much more desperate version of the same crisis.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>17</span> The Blood Cart</h2>
|
||||||
|
<p>Eventually the monthly collection occurs. This should be the <strong>only</strong> actual blood-cart departure the characters witness during the campaign. By then the cart isn't abstract anymore: they know these people. They've eaten with them. They've spoken to their children. They know <strong>Bastian Cooper</strong>. They know the families whose blood is being collected.</p>
|
||||||
|
<p>Around <strong>15 litres</strong> are prepared — and now the characters know something most of those villagers don't: <strong>it isn't enough.</strong></p>
|
||||||
|
<p>The village has around <strong>133 eligible donors</strong>. There is physically enough blood available to <strong>temporarily reinforce the seal</strong>. But convincing Brackenford to provide dramatically more than the traditional amount means asking an entire community to overturn <strong>four centuries of fear</strong>.</p>
|
||||||
|
<p>The villagers believe additional blood means <strong>feeding the monster</strong>. The characters know it means <strong>buying time</strong>. And <strong>Garrick Voss</strong> — who earlier told them to pay with their own “outside blood” — now has to confront the possibility that the tradition he fiercely protected was based on a misunderstanding.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>18</span> Solving the Problem</h2>
|
||||||
|
<p>More blood cannot be the permanent solution. The extra blood should only stabilise the prison <strong>temporarily</strong>. It buys the characters enough time to actually repair what is broken.</p>
|
||||||
|
<p>The final portion of the campaign therefore becomes about <strong>reconstructing knowledge lost over four centuries</strong>. The five original adventurers knew how the prison worked. That knowledge is now fragmented: some is in Aldren's memory, some survives in local legends, and some may exist in ruins, inscriptions, old family records or forgotten structures around Brackenford.</p>
|
||||||
|
<aside class="storyline-callout" data-label="The puzzle">
|
||||||
|
This is where the English-language puzzle can become important. Rather than a random dungeon puzzle, it can be part of the original five heroes' mechanism — a safeguard designed so that someone who came after them could restore the prison.
|
||||||
|
</aside>
|
||||||
|
<p>The players must understand what their predecessors did before they can repair it. <strong>The campaign's investigation finally becomes its solution. Everything they've learned matters.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>19</span> The Final Crisis</h2>
|
||||||
|
<p>As the seal approaches failure, Kyuss's influence becomes undeniable. The campaign moves through four stages:</p>
|
||||||
|
<ol class="storyline-ladder">
|
||||||
|
<li>“Something is strange about this village.”</li>
|
||||||
|
<li>“There is a vampire in that castle.”</li>
|
||||||
|
<li>“The vampire isn't the monster.”</li>
|
||||||
|
<li>“The world itself is turning against us.”</li>
|
||||||
|
</ol>
|
||||||
|
<p>The characters have only a short period in which to act. They need <strong>blood</strong> to stabilise the mechanism. They need <strong>Aldren's knowledge</strong>. They need <strong>information left behind by the original five</strong>. They need the <strong>cooperation of Brackenford</strong>. And they need to <strong>survive</strong> whatever manifestations of Kyuss emerge as the prison begins opening.</p>
|
||||||
|
<p>Nobody else can realistically solve it in time. Not because they're chosen. <strong>Because they're here.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>20</span> The Ending</h2>
|
||||||
|
<p>If they succeed, <strong>Kyuss is contained again. Brackenford survives.</strong> But things cannot simply return to normal.</p>
|
||||||
|
<p>The villagers now know — or at least some of them know — that their ancestors misunderstood the blood ritual. Their supposed monster was their protector. Their council maintained a lie without even knowing it was a lie. Aldren has spent four centuries protecting people who feared and hated him. And <strong>the prison still exists.</strong></p>
|
||||||
|
<p>The characters haven't destroyed Kyuss. <strong>They've bought Brackenford another future.</strong></p>
|
||||||
|
<p>Aldren's fate can depend on the choices made during the campaign. He might remain the Warden. He might finally be freed from his duty at enormous cost. He might die ensuring the repaired prison closes. This can stay undecided until the mechanics of the seal and the five original heroes are finalised. But the village is safe. For now.</p>
|
||||||
|
<p>The halflings can finally get Eamon's signature. They can return with proof that the inheritance was delivered, and collect the other half of their payment. They leave Brackenford considerably less innocent than when they entered it.</p>
|
||||||
|
<blockquote class="storyline-quote storyline-quote--close">We should have done more for those people. But right now, they're safe.</blockquote>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
{# Prologue — The Long Road to Brackenford. Hand-formatted from the session outline. #}
|
||||||
|
|
||||||
|
<dl class="storyline-meta">
|
||||||
|
<div><dt>Starting level</dt><dd>1</dd></div>
|
||||||
|
<div><dt>Ending level</dt><dd>2 <span class="text-body-secondary">(milestone)</span></dd></div>
|
||||||
|
<div><dt>Expected duration</dt><dd>~2.5 hours</dd></div>
|
||||||
|
<div><dt>Locations</dt><dd>Bellhaven → the road → The Crooked Goose</dd></div>
|
||||||
|
<div><dt>Campaign hook</dt><dd>Eamon Vale's inheritance</dd></div>
|
||||||
|
<div><dt>Featured adventure</dt><dd>The House That Won't Let Anyone Sleep</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<aside class="storyline-callout" data-label="Tone">
|
||||||
|
The session should feel almost <strong>disappointingly mundane</strong>. Nothing about the inheritance is secretly connected to Aldren or Kyuss. The characters have simply taken a courier job.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>01</span> Opening — Bellhaven</h2>
|
||||||
|
<p>Bellhaven is a prosperous regional city on an important crossroads — large enough for guilds, courts, warehouses, temples, professional couriers and estate administrators.</p>
|
||||||
|
<p>Brackenford is <strong>twenty days east by road</strong>, the final days taking travellers steadily further from the major trade routes.</p>
|
||||||
|
<p>The five family members have already answered an advertisement:</p>
|
||||||
|
<div class="storyline-handout">
|
||||||
|
<strong>Couriers Required</strong>
|
||||||
|
Delivery of estate property.<br>
|
||||||
|
Destination: Brackenford.<br>
|
||||||
|
Approximately twenty days east by road.<br>
|
||||||
|
Discretion required. Compensation generous.<br>
|
||||||
|
<br>
|
||||||
|
Enquire at the Office of Helena Vey,<br>
|
||||||
|
Licensed Executor of Estates.
|
||||||
|
</div>
|
||||||
|
<p>Start with character introductions as the five family members arrive at Helena's office.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>02</span> Magistrate Helena Vey</h2>
|
||||||
|
<p>Helena Vey is in her late forties or early fifties. <em>Licensed Executor</em> is her actual profession, though people informally call her <strong>Magistrate Vey</strong> from her years working for Bellhaven's civil court.</p>
|
||||||
|
<p>She is efficient, intelligent and extremely busy. Most importantly, <strong>Helena knows nothing about Brackenford's secret</strong>. To her, this is paperwork.</p>
|
||||||
|
<p>Her office holds shelves of legal volumes, bundles of documents tied with coloured string, locked cabinets, a large desk, wax seals, a clerk, and five chairs — someone clearly had to go and find the fifth. Against one wall sits <strong>Eamon's inheritance</strong>.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>03</span> Why Adventurers?</h2>
|
||||||
|
<p>Helena contacted three professional courier companies. The first refused the job. The second offered a ridiculous price. The third offered an even more ridiculous price.</p>
|
||||||
|
<p>Not because Brackenford is dangerous — because it is <strong>twenty days away</strong>. A courier travelling there and back spends roughly forty days on a single delivery, plus food, accommodation, protection and possibly replacement horses, and there is little commercial traffic toward Brackenford to combine with the trip.</p>
|
||||||
|
<blockquote class="storyline-quote">The contents aren't particularly valuable. At least, not compared with what three courier companies wanted to charge me to deliver them.</blockquote>
|
||||||
|
<blockquote class="storyline-quote">You people travel anyway.</blockquote>
|
||||||
|
<p>If the party calls themselves adventurers:</p>
|
||||||
|
<blockquote class="storyline-quote">Precisely. You're already accepting the inconvenience of roads, rain, questionable beds and occasionally being attacked by wildlife. Professional couriers charge extra for all four.</blockquote>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>04</span> The Deceased — Edric Vale</h2>
|
||||||
|
<p><strong>Edric Vale</strong> was a 76-year-old human, originally from Brackenford. He left as a young man and settled in Bellhaven, working as a cabinetmaker and furniture restorer.</p>
|
||||||
|
<p>He never married and had no children. His closest surviving relative is his nephew, <strong>Eamon Vale of Brackenford</strong>, whom he had not seen in many years.</p>
|
||||||
|
<p>There is no conspiracy here. <strong>Edric simply died of old age.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>05</span> The Inheritance</h2>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>The wooden chest</dt><dd>A sturdy travelling chest, roughly 25 kg, containing <strong>230 gp</strong> in assorted coinage. Locked and sealed with Helena's wax seals; Eamon receives the key separately, in a sealed envelope.</dd></div>
|
||||||
|
<div><dt>A wooden box</dt><dd>Beautifully handmade by Edric. Inside: several letters, old drawings, family documents, a carved wooden toy, a small silver pendant and an old portrait. <strong>No magical secrets or sinister clues.</strong></dd></div>
|
||||||
|
<div><dt>A wrapped object</dt><dd>An old mantel clock. Eamon admired it when he visited Edric as a child, and Edric remembered.</dd></div>
|
||||||
|
<div><dt>A bundle of tools</dt><dd>Edric's high-quality woodworking tools.</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>06</span> The Delivery Contract</h2>
|
||||||
|
<p>The <strong>Proof of Receipt</strong> identifies <strong>Eamon Vale of Brackenford</strong> as the sole authorised recipient. Eamon must personally sign the magically prepared signature field. When he does, the black ink briefly becomes <strong>gold</strong> before settling into a dark metallic colour, and Helena's seal changes to match.</p>
|
||||||
|
<p>Someone else <em>can</em> physically accept the inheritance — but the party then fails the contract and does not receive the remainder of the payment.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>07</span> Payment</h2>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>Total</dt><dd><strong>200 gp</strong></dd></div>
|
||||||
|
<div><dt>Upfront</dt><dd>100 gp</dd></div>
|
||||||
|
<div><dt>On return</dt><dd>100 gp, against the authenticated receipt</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>The professional courier quotes were considerably higher. The adventurers genuinely are the cheaper option.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>08</span> Questions for Helena</h2>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>Where is Brackenford?</dt><dd>Twenty days east, depending on weather.</dd></div>
|
||||||
|
<div><dt>What's it like?</dt><dd>A small agricultural village in the hills.</dd></div>
|
||||||
|
<div><dt>Is it dangerous?</dt><dd>Not that Helena is aware of.</dd></div>
|
||||||
|
<div><dt>Who is Eamon?</dt><dd>Edric's nephew, apparently a farmer.</dd></div>
|
||||||
|
<div><dt>Why hasn't Eamon collected it himself?</dt><dd>He does not know Edric has died.</dd></div>
|
||||||
|
<div><dt>Why not send a letter?</dt><dd>A notice was sent ahead, but Helena received no reply — not especially unusual for such a remote settlement.</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>09</span> Shopping in Bellhaven</h2>
|
||||||
|
<p>Give the characters 15–20 minutes of freedom before departure.</p>
|
||||||
|
<aside class="storyline-callout" data-label="Ask the players">
|
||||||
|
“Before you leave Bellhaven, what does the family want to buy?”
|
||||||
|
</aside>
|
||||||
|
<p>Possible concerns: food, tents, rope, ammunition, healing supplies, transport, cookware, blankets, and whatever else they think they need. Use this mainly to establish the family dynamic.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>10</span> Leaving Bellhaven</h2>
|
||||||
|
<p>Ahead is twenty days of road. <strong>No dungeon. No dragon. No ancient curse.</strong></p>
|
||||||
|
<p>They have one job: deliver several boxes to a farmer, get his signature, come back. Easy money.</p>
|
||||||
|
<aside class="storyline-callout" data-label="Ask the players">
|
||||||
|
“Who's carrying the paperwork?” — “Who's responsible for the inheritance?” Make the players decide.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>11</span> The First Fifteen Days</h2>
|
||||||
|
<p>Montage the journey rather than playing every day. These are roleplaying prompts, not skill checks — spend roughly 10–15 minutes.</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Day 2</span><span>It rains continuously. “Which one of you discovers that something they packed isn't nearly as waterproof as they thought?”</span></li>
|
||||||
|
<li><span class="k">Day 5</span><span>Travelling merchants. “Which member of the family is most likely to start talking to strangers?”</span></li>
|
||||||
|
<li><span class="k">Day 8</span><span>Around the campfire. “Who normally cooks?”</span></li>
|
||||||
|
<li><span class="k">Day 11</span><span>“Which family habit has started getting on someone else's nerves?”</span></li>
|
||||||
|
<li><span class="k">Day 14</span><span>“Who seems most homesick?”</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>12</span> Day Fifteen</h2>
|
||||||
|
<p>Rain begins during the afternoon. The road becomes muddy and darkness approaches. Then they see light.</p>
|
||||||
|
<p>A wooden sign beside the road depicts a goose whose neck bends at a ridiculous angle:</p>
|
||||||
|
<p class="storyline-beat">The Crooked Goose</p>
|
||||||
|
<p>Warm yellow windows promise food, ale, fire and beds.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>13</span> The Crooked Goose</h2>
|
||||||
|
<p>Make the inn genuinely wonderful: a warm common room, roasting food, laughter, local cider, perhaps a bad musician, and an enormous fireplace.</p>
|
||||||
|
<p>Then the party asks for rooms. <strong>Every room is occupied.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>14</span> The Innkeeper — Maud Tallow</h2>
|
||||||
|
<p><strong>Maud Tallow</strong> is a friendly, practical 52-year-old human who is absolutely convinced the guesthouse is haunted. Tonight the inn is full of travelling merchants, two drovers, a family of four, two labourers and an old travelling scholar.</p>
|
||||||
|
<p>Eventually Maud says:</p>
|
||||||
|
<blockquote class="storyline-quote">Well… there is somewhere.</blockquote>
|
||||||
|
<p>Her husband: “Maud.” She ignores him.</p>
|
||||||
|
<blockquote class="storyline-quote">The guest cottage.</blockquote>
|
||||||
|
<blockquote class="storyline-quote">You wouldn't have to pay.</blockquote>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>15</span> The Problem</h2>
|
||||||
|
<p>The guest cottage stands about 100 feet behind the inn. It is small and comfortable — two rooms, a little loft/storage area, a fireplace, and enough beds for the party.</p>
|
||||||
|
<p><strong>Nobody has successfully slept there for three nights.</strong> The first guest heard scratching. The second claimed furniture moved. The third ran into the inn around 2 a.m. wearing only a nightshirt and refuses to return.</p>
|
||||||
|
<blockquote class="storyline-quote">You lot are adventurers. Figure out what's wrong with the cottage, and you've got beds, dinner and breakfast for nothing.</blockquote>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>16</span> What Actually Happened</h2>
|
||||||
|
<aside class="storyline-callout storyline-callout--warn" data-label="GM only">
|
||||||
|
<p>The cottage belonged to <strong>Maela Tallow</strong>, Maud's mother-in-law, who died about six months ago. Unknown to everyone, a <strong>Hearthburrow</strong> lives beneath it. <span class="text-body-secondary">(Its stat block is in <a href="{{ path('app_enemies') }}">Enemies</a>.)</span></p>
|
||||||
|
<p>For years Maela performed what she considered an old household superstition. Before bed she placed bread beside the hearth, left a little milk, knocked three times on the hearthstone, and sometimes said, “Warm hearth, shared home, good night.”</p>
|
||||||
|
<p><strong>Maela thought it was tradition. The Hearthburrow thought it was a contract.</strong></p>
|
||||||
|
<p>Then Maela died. No bread. No milk. No knocking. Then strangers began sleeping in its house. <strong>It is furious.</strong></p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>17</span> Investigation</h2>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">DC 10 Investigation</span><span>Tiny scratches around the bottom of the hearth. They do not resemble rat scratches.</span></li>
|
||||||
|
<li><span class="k">DC 12 Perception</span><span>Small objects are missing from strange places. One drawer contains mismatched buttons.</span></li>
|
||||||
|
<li><span class="k">DC 12 Nature</span><span>The tracks are too strange for a normal rodent.</span></li>
|
||||||
|
<li><span class="k">DC 13 Arcana</span><span>Minor residual fey magic surrounds the hearth.</span></li>
|
||||||
|
<li><span class="k">Under a bed</span><span>A tiny silver spoon belonging to the inn.</span></li>
|
||||||
|
<li><span class="k">The hearth</span><span>A tiny hollow behind one stone holding crumbs, buttons, a coin, thread and a thimble.</span></li>
|
||||||
|
</ul>
|
||||||
|
<p class="storyline-turn">Something is living here.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>18</span> Talking to People</h2>
|
||||||
|
<p>Maud remembers Maela always leaving milk out. Maud's husband remembers: “She used to knock on that bloody fireplace every night” — and demonstrates. <strong>Knock. Knock. Knock.</strong></p>
|
||||||
|
<p>The old scholar, if given the clues, suggests <strong>household fey</strong> but does not know the exact species.</p>
|
||||||
|
<aside class="storyline-callout" data-label="GM note">
|
||||||
|
Do not let one Arcana check solve the entire mystery.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>19</span> Nightfall</h2>
|
||||||
|
<p>Eventually someone must remain in the cottage. Nothing happens. Five minutes. Ten. Twenty.</p>
|
||||||
|
<p class="storyline-beat">Scratch. Scratch. Scratch.</p>
|
||||||
|
<p>Inside the wall. Then, from behind one character, comes another PC's voice — “[PC's name]?” — except that character did not speak.</p>
|
||||||
|
<p class="storyline-turn">The Hearthburrow has started playing.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>20</span> The Cottage Encounter</h2>
|
||||||
|
<p><strong>Do not immediately roll initiative.</strong></p>
|
||||||
|
<div class="storyline-stages">
|
||||||
|
<div class="storyline-stage">
|
||||||
|
<h4>Stage 1 — Mischief</h4>
|
||||||
|
<p>A boot disappears. A cupboard opens. A backpack moves. A candle goes out.</p>
|
||||||
|
</div>
|
||||||
|
<div class="storyline-stage">
|
||||||
|
<h4>Stage 2 — Warning</h4>
|
||||||
|
<p>Shutters slam. A chair slides across the floor. Soot drops down the chimney. Something whispers: “Mine.”</p>
|
||||||
|
</div>
|
||||||
|
<div class="storyline-stage">
|
||||||
|
<h4>Stage 3 — Confrontation</h4>
|
||||||
|
<p>If the party tears the house apart or attacks, roll initiative. The Hearthburrow uses abilities such as Pulled Rug, Flying Crockery, Chimney Soot, Snuffed Flame, Wall Scurry and Mimic Voice.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>The encounter should be <strong>chaotic and funny rather than deadly</strong>.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>21</span> Finding the Hearthburrow</h2>
|
||||||
|
<p>The creature is tiny, scruffy, huge-eared, dressed in stolen scraps, and furious. If someone speaks Sylvan they can communicate; otherwise use pantomime.</p>
|
||||||
|
<p>It points at the party, the food, itself and the hearth, gesturing angrily. If somebody eats in front of it, it becomes deeply offended.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>22</span> Solving the Ritual</h2>
|
||||||
|
<p>If the players reconstruct Maela's routine:</p>
|
||||||
|
<ol class="storyline-ladder">
|
||||||
|
<li>Put bread beside the hearth.</li>
|
||||||
|
<li>Pour milk.</li>
|
||||||
|
<li>Knock three times.</li>
|
||||||
|
<li>Perhaps say: “Warm hearth, shared home, good night.”</li>
|
||||||
|
</ol>
|
||||||
|
<p>The words are not a magical password; <strong>the hospitality matters</strong>. The cottage falls silent. A tiny hand takes the bread. Another takes the milk. The party's missing possessions begin sliding back out. Finally the Hearthburrow cautiously emerges, no longer hostile. <strong>Problem solved.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>23</span> Alternative Solutions</h2>
|
||||||
|
<p><strong>Do not require the ritual.</strong> The party might negotiate, offer another home, promise future food, capture the creature, or kill it. Creative solutions should work.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>24</span> Hearthburrow's Favour</h2>
|
||||||
|
<p>If resolved peacefully, the Hearthburrow gives one character an <strong>old brass button</strong>. Once, while carrying it, the owner may reroll a failed Dexterity (Stealth), Dexterity (Sleight of Hand) or Wisdom (Perception) check and must use the new result. Afterward, the button inexplicably disappears.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>25</span> Level Up</h2>
|
||||||
|
<p>After sleeping properly, the party advances to <strong>Level 2</strong> using milestone advancement.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>26</span> Morning at the Crooked Goose</h2>
|
||||||
|
<p>Maud provides breakfast, food for the road, free accommodation, and perhaps a bottle of cider. Then she asks:</p>
|
||||||
|
<blockquote class="storyline-quote">So what was it?</blockquote>
|
||||||
|
<aside class="storyline-callout storyline-callout--warn" data-label="GM — do this">
|
||||||
|
Write down the party's <strong>exact</strong> answer. When they eventually return from Brackenford, their own account will have become distorted into a local legend.
|
||||||
|
</aside>
|
||||||
|
<ul class="storyline-legend">
|
||||||
|
<li><span class="from">“There was a little fey creature living under the hearth.”</span><span class="arrow">→</span><span class="to">“Five halfling heroes battled the Fey Lord Beneath the Hearth.”</span></li>
|
||||||
|
<li><span class="from">“The house was haunted.”</span><span class="arrow">→</span><span class="to">“Five adventurers exorcised the screaming ghost of Maela Tallow.”</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>If the party exaggerates the story themselves, make the eventual legend even more absurd.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>27</span> Back on the Road</h2>
|
||||||
|
<p>Approximately five days remain. The countryside becomes hillier, less travelled, more agricultural and more isolated. Road signs grow older and settlements smaller.</p>
|
||||||
|
<p>Nothing supernatural happens. No worms. No undead. No ominous ravens. No visions. No vampire clues.</p>
|
||||||
|
<p class="storyline-turn">Not yet.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>28</span> End of the Prologue</h2>
|
||||||
|
<p>After nearly twenty days of travelling, the party reaches a ridge. A weathered milestone reads:</p>
|
||||||
|
<p class="storyline-beat">Brackenford — 7 miles</p>
|
||||||
|
<p>After nineteen days of rain, campfires, hard roads, one supposedly haunted cottage and far too much trail food, the easiest job they have ever accepted is almost finished. Deliver the inheritance. Get Eamon Vale's signature. Go home. Get paid.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>+</span> Appendix — Timing of Eamon's Encounter with Aldren</h2>
|
||||||
|
<p>Eamon should encounter Aldren while the characters are already on the <strong>final two-hour stretch</strong> toward Brackenford.</p>
|
||||||
|
<p>While the party thinks their job is nearly over, Eamon is outside the village and sees a pale man he immediately recognises from centuries of village stories: <strong>Sir Aldren</strong>. Aldren tells him:</p>
|
||||||
|
<blockquote class="storyline-quote">Tell them the blood is no longer enough.</blockquote>
|
||||||
|
<p>Eamon runs back to Brackenford. The council scrambles. Garrick Voss gets involved. Eamon is hidden away. Roughly two hours later, five halflings walk into Brackenford carrying his inheritance.</p>
|
||||||
|
<p class="storyline-turn">They are not the chosen five. They are simply the five people who happened to be there when Brackenford finally needed someone.</p>
|
||||||
|
<p class="text-body-secondary small">Continues in <a href="{{ path('app_storyline_chapter', {slug: 'overview'}) }}">the Campaign Overview</a> — see “The Five and the Five”.</p>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
{# Session 1 — Outside Blood. Hand-formatted from the session outline. #}
|
||||||
|
|
||||||
|
<dl class="storyline-meta">
|
||||||
|
<div><dt>Starting level</dt><dd>2</dd></div>
|
||||||
|
<div><dt>Expected duration</dt><dd>~2.5 hours</dd></div>
|
||||||
|
<div><dt>Locations</dt><dd>Road into Brackenford → Thorn Apiary → Vale Farm → village centre</dd></div>
|
||||||
|
<div><dt>Primary objective</dt><dd>Deliver Eamon Vale's inheritance</dd></div>
|
||||||
|
<div><dt>Actual objective</dt><dd>Establish Brackenford, and make the players realise Eamon's absence is not normal</dd></div>
|
||||||
|
<div><dt>Major reveal</dt><dd>Something involving <strong>blood</strong> that the village doesn't want outsiders asking about</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<aside class="storyline-callout storyline-callout--warn" data-label="Do not reveal yet">
|
||||||
|
Aldren. The purpose of the blood. Kyuss. Where Eamon is.
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside class="storyline-callout" data-label="Tone">
|
||||||
|
<p><strong>Brackenford isn't creepy when they arrive. It's a village.</strong></p>
|
||||||
|
<p>Children play. Someone is repairing a fence. Smoke rises from chimneys. People are working. Dogs bark at the handcart. The weirdness begins only when they ask the wrong questions.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>01</span> Two Hours from Brackenford</h2>
|
||||||
|
<p>Begin exactly where the prologue ended. Nearly twenty days on the road. The handcart is covered by the tarp they bought in Bellhaven; inside are Eamon's possessions and the sealed coin chest. Dunnchadh has the contract and Eamon's magical receipt stored safely between the toolboxes.</p>
|
||||||
|
<p>The road narrows. Cultivated fields appear. Stone walls divide grazing land. Now and then they pass an isolated farm.</p>
|
||||||
|
<div class="storyline-handout">
|
||||||
|
The road crests another low hill.<br>
|
||||||
|
Beyond it, nestled between rolling green slopes, is a village.<br>
|
||||||
|
Not a town. Not a fortified settlement.<br>
|
||||||
|
Just a few dozen clusters of stone-and-timber buildings surrounded by fields, orchards and grazing land.<br>
|
||||||
|
Thin trails of chimney smoke rise into the afternoon air.<br>
|
||||||
|
Somewhere in the distance, you hear a church bell.<br>
|
||||||
|
<br>
|
||||||
|
After almost twenty days on the road… <strong>Brackenford.</strong>
|
||||||
|
</div>
|
||||||
|
<p>Let them react. They've made it. Their easy delivery job is nearly finished.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>02</span> What the Players Don't See</h2>
|
||||||
|
<aside class="storyline-callout storyline-callout--warn" data-label="GM only">
|
||||||
|
<p>Eamon Vale's crisis has already happened. About two hours earlier, Eamon encountered Aldren, who told him:</p>
|
||||||
|
<blockquote class="storyline-quote">Tell them the blood is no longer enough.</blockquote>
|
||||||
|
<p>Eamon came back to Brackenford terrified. People saw him, and heard enough of what he shouted to know something had happened. The council stepped in and took him somewhere secure.</p>
|
||||||
|
<p>The adults who know about Aldren are frightened. The children mostly know only that <em>something happened with Mr. Vale</em>. And before anyone has had time to decide what to do… five unfamiliar halflings arrive pulling a cart.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>03</span> Entering Brackenford</h2>
|
||||||
|
<p><strong>Don't make the villagers hostile. Make them curious.</strong> Outsiders don't arrive every day.</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Adults</span><span>A farmer stops working for a moment. Someone carrying a basket looks twice. A woman pauses mid-conversation.</span></li>
|
||||||
|
<li><span class="k">A dog</span><span>Runs alongside the handcart for ten seconds before being called back.</span></li>
|
||||||
|
<li><span class="k">Children</span><span>Notice them immediately. They watch from a distance. One whispers. Another runs toward a house. A little boy points; his mother tells him not to stare.</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>The feeling should be <em>“What did the cat drag in?”</em> — not <em>“Outsiders! Get them!”</em> Brackenford trades with other villages, people travel through, apprentices arrive. Five halflings with a loaded handcart are unusual, not frightening.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>04</span> The Brave Child</h2>
|
||||||
|
<p>Eventually one child gets brave enough: <strong>Bryony Thorn</strong>, age 8. She has grown up in the only halfling household in Brackenford, so five halflings together is fascinating. She approaches, cautiously at first.</p>
|
||||||
|
<blockquote class="storyline-quote">Are you Thorns?</blockquote>
|
||||||
|
<p>Pause. If they don't understand:</p>
|
||||||
|
<blockquote class="storyline-quote">Like… family of ours?</blockquote>
|
||||||
|
<p>If they explain they're not, Bryony looks slightly disappointed — then notices something about the <strong>beekeeper</strong> (Darragh, going by the pet bees on the sheet): a bee smoker strapped to the cart, a tool, the smell of beeswax, or the character simply mentions it.</p>
|
||||||
|
<blockquote class="storyline-quote">You keep bees?</blockquote>
|
||||||
|
<p class="storyline-turn">And that changes everything.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>05</span> The Thorn Family</h2>
|
||||||
|
<p>Bryony runs home, possibly without saying goodbye. A few minutes later she comes running back with her older sister, <strong>Clover Thorn</strong>, an 18-year-old halfling beekeeper apprentice. Clover is considerably less shy, introduces herself properly, and apologises for Bryony interrogating strangers.</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Clover</span><span>“Sorry. We don't get many halflings here who aren't related to us.”</span></li>
|
||||||
|
<li><span class="k">Bryony</span><span>“I wasn't interrogating them.”</span></li>
|
||||||
|
<li><span class="k">Clover</span><span>“You asked if they were family before asking their names.”</span></li>
|
||||||
|
<li><span class="k">Bryony</span><span>“That's important.”</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>A light opening before the mystery begins.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>06</span> The Thorn House and Apiary</h2>
|
||||||
|
<p>If the party shows even moderate interest, Clover invites them to the Thorn farmstead on the edge of Brackenford. <span class="text-body-secondary">(<a href="{{ path('app_npc_family', {family: 'Thorn'}) }}">Thorn family tree</a>)</span></p>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>Bramble Thorn, 45</dt><dd>Beekeeper, father.</dd></div>
|
||||||
|
<div><dt>Poppy Thorn, 42</dt><dd>Beekeeper, mother.</dd></div>
|
||||||
|
<div><dt>Wren Thorn, 19</dt><dd>Beekeeper apprentice, eldest son.</dd></div>
|
||||||
|
<div><dt>Clover Thorn, 18</dt><dd>Beekeeper apprentice, daughter.</dd></div>
|
||||||
|
<div><dt>Bryony Thorn, 8</dt><dd>Professional interrogator of strangers.</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>Make this one of the most welcoming places in Brackenford. Rows of hives behind the house. Beeswax candles drying beneath a covered work area. Jars everywhere. The whole place smells faintly of honey and smoke.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>07</span> Let the Beekeeper Have Their Moment</h2>
|
||||||
|
<p><strong>Don't rush this.</strong> A player chose beekeeping before this connection existed — exactly the sort of coincidence worth exploiting.</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Bramble</span><span>“What sort of hives do you use?”</span></li>
|
||||||
|
<li><span class="k">Clover</span><span>“What flowers grow around your farm?”</span></li>
|
||||||
|
<li><span class="k">Poppy</span><span>Wants to compare honey.</span></li>
|
||||||
|
<li><span class="k">Wren</span><span>Has Opinions™ about hive placement.</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>Let them disagree about techniques. No rolls unless the player makes specific claims or examines the Thorn hives.</p>
|
||||||
|
<aside class="storyline-callout" data-label="Why it matters">
|
||||||
|
Your character's background matters — and <strong>the party will know people in Brackenford before they're asked to save it.</strong>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>08</span> Why Are You Here?</h2>
|
||||||
|
<p>Eventually, naturally:</p>
|
||||||
|
<blockquote class="storyline-quote">So what brings five of you all the way out here?</blockquote>
|
||||||
|
<p>The players explain: Eamon Vale. Watch the atmosphere change — not dramatically. Bramble doesn't drop his mug. Poppy doesn't scream. But there's a pause. Clover looks toward her father.</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Bryony</span><span>“Oh! Mr. Vale was—”</span></li>
|
||||||
|
<li><span class="k">Poppy</span><span>“Bryony.” <span class="text-body-secondary">Not angrily. Just immediately.</span></span></li>
|
||||||
|
</ul>
|
||||||
|
<p class="storyline-turn">The first tiny alarm bell.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>09</span> What the Thorns Know</h2>
|
||||||
|
<aside class="storyline-callout storyline-callout--warn" data-label="GM only — knowledge layers">
|
||||||
|
<p><strong>All adult Thorns</strong> know about Aldren and the blood tradition.</p>
|
||||||
|
<p><strong>Bryony</strong> (8) does not. She knows something strange happened today because children notice everything — she may have seen Eamon running through the village, heard shouting, or noticed adults suddenly sending children home. She doesn't know what it means.</p>
|
||||||
|
<p><strong>Clover</strong> (18) does know about Aldren and the blood cart. She's friendly toward the party, but the instant the conversation nears today's incident she becomes guarded. For the first time, their new friend is hiding something.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>10</span> Asking About Eamon</h2>
|
||||||
|
<p>Bramble gives the straightforward answer:</p>
|
||||||
|
<blockquote class="storyline-quote">Vale farm's on the western side. Follow the road past the square, then take the path beside the orchard.</blockquote>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>Is Eamon home?</dt><dd>“Couldn't tell you.” <span class="text-body-secondary">Technically true.</span></dd></div>
|
||||||
|
<div><dt>If pressed</dt><dd>“Best ask his family.”</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>Bramble won't lie unnecessarily — but he's certainly not telling five strangers that Eamon ran into their four-hundred-year-old vampire earlier today.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>11</span> Vale Farm</h2>
|
||||||
|
<p>This is where the easy job officially stops being easy. <span class="text-body-secondary">(<a href="{{ path('app_npc_family', {family: 'Vale'}) }}">Vale family tree</a>)</span></p>
|
||||||
|
<p>They knock. <strong>Maris Vale</strong>, Eamon's 39-year-old partner, answers. She looks tired — not grieving, not terrified, but clearly she's had a bad day. Behind her might be <strong>Olda Vale</strong> (73, Eamon's mother) and, depending on the time of day, <strong>Tilda</strong> (17), <strong>Rory</strong> (12) and <strong>Wick</strong> (6).</p>
|
||||||
|
<blockquote class="storyline-quote">We're looking for Eamon Vale.</blockquote>
|
||||||
|
<p>Maris pauses.</p>
|
||||||
|
<blockquote class="storyline-quote">He's not here.</blockquote>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>12</span> The Inheritance</h2>
|
||||||
|
<p>The prologue preparation pays off. They explain Edric, show the inheritance, and perhaps explain that Edric has died. That gives Maris a genuine emotional reaction — she knows who Edric was, and Olda certainly does: Edric was her relative.</p>
|
||||||
|
<p>For a moment the ancient vampire conspiracy disappears and this becomes <strong>a family receiving news of a death</strong>. Let that breathe.</p>
|
||||||
|
<blockquote class="storyline-quote">Edric's dead?</blockquote>
|
||||||
|
<p>If told he died peacefully of old age:</p>
|
||||||
|
<blockquote class="storyline-quote">Seventy-six… <span class="text-body-secondary">(she nods)</span> Stubborn bastard made it further than I expected.</blockquote>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>13</span> “We Can Take It”</h2>
|
||||||
|
<blockquote class="storyline-quote">You can leave his things here. I'll make sure he gets them.</blockquote>
|
||||||
|
<p>Now Dunnchadh's paperwork matters. They can leave the inheritance — but <strong>Eamon has to sign</strong>. If they explain that, Maris becomes uncomfortable.</p>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>Eamon must sign</dt><dd>“He can't do that right now.”</dd></div>
|
||||||
|
<div><dt>Why?</dt><dd>“He's… dealing with something.”</dd></div>
|
||||||
|
<div><dt>Where?</dt><dd>“I don't know when he'll be back.”</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="storyline-turn">That answer should sound wrong — because she almost said something else.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>14</span> If They Push Maris</h2>
|
||||||
|
<p>Don't make her stonewall like an NPC quest gate. She's under pressure. She loves Eamon. She knows he is physically safe, but she's frightened by what he said. If they push gently:</p>
|
||||||
|
<blockquote class="storyline-quote">Something happened this afternoon.</blockquote>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>Is he hurt?</dt><dd>“No.”</dd></div>
|
||||||
|
<div><dt>Kidnapped?</dt><dd>“No.”</dd></div>
|
||||||
|
<div><dt>In danger?</dt><dd><em>Long pause.</em> “I don't know.”</dd></div>
|
||||||
|
<div><dt>Where is he?</dt><dd>“The council is looking after him.”</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>A major clue: <strong>the village council has Eamon</strong>. Don't give them his location.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>15</span> The First Contradiction</h2>
|
||||||
|
<p>Something concrete to notice: the Thorns implied they didn't know where Eamon was. Maris says the council has him. Not necessarily sinister — but strange.</p>
|
||||||
|
<p>And because the party needs Eamon's signature, they can't simply shrug and leave. <strong>They're trapped without being railroaded:</strong> they can leave whenever they want, they just won't get their remaining 100 gp.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>16</span> The Village Square</h2>
|
||||||
|
<p>At some point they'll head toward the centre. Let them see the village properly — this is where the roster of <a href="{{ path('app_npcs') }}">NPCs</a> starts becoming useful.</p>
|
||||||
|
<p><strong>Don't introduce fifteen named characters. Use faces.</strong> The mill. The temple. The smithy. Children. Someone selling vegetables. A carpenter carrying timber. A dwarf arguing about a horseshoe. People who clearly know one another.</p>
|
||||||
|
<p class="storyline-turn">This isn't a spooky cult village. It's a community — and that matters enormously later.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>17</span> The First Blood Clue</h2>
|
||||||
|
<p>Start the blood-cart mystery before they find Eamon, but make the first clue <strong>mundane</strong>. Near the Redbrook household, or a small collection room near the village centre, they see <strong>Bastian Cooper's cart</strong>: just a sturdy covered cart. Bastian is inspecting one of its wheels. Nearby are several carefully cleaned containers.</p>
|
||||||
|
<p>If the party doesn't care, that's fine — don't force it. But if they ask:</p>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>What's the cart?</dt><dd>“Collection cart.”</dd></div>
|
||||||
|
<div><dt>Collection of what?</dt><dd><em>Bastian hesitates.</em> “Village business.”</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>That's it, the first time.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>18</span> The Children Make a Mistake</h2>
|
||||||
|
<p>Rory Vale, or another child, is nearby.</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Child</span><span>“Is that the cart for next—”</span></li>
|
||||||
|
<li><span class="k">Adult</span><span>“Inside.”</span></li>
|
||||||
|
<li><span class="k">Child</span><span>“But—”</span></li>
|
||||||
|
<li><span class="k">Adult</span><span>“Now.”</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>Much more suspicious than Bastian refusing to answer. The players don't know what the cart collects — only that <strong>adults don't want children discussing it with outsiders</strong>.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>19</span> Garrick Voss</h2>
|
||||||
|
<aside class="storyline-callout" data-label="Hold back">
|
||||||
|
Don't use Garrick's “outside blood” line yet. It hits much harder once the party knows blood is literally involved.
|
||||||
|
</aside>
|
||||||
|
<p>This is their first encounter with <strong>Garrick Voss</strong>: polite, measured, an elder.</p>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>About Eamon?</dt><dd>“Eamon is safe.”</dd></div>
|
||||||
|
<div><dt>Where?</dt><dd>“Somewhere he can rest.”</dd></div>
|
||||||
|
<div><dt>Why can't we see him?</dt><dd>“Because he has had a difficult day.”</dd></div>
|
||||||
|
<div><dt>We need his signature.</dt><dd>“Then I'm afraid you'll need to wait.”</dd></div>
|
||||||
|
<div><dt>How long?</dt><dd>“I couldn't say.”</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>He should sound <strong>reasonable enough to be infuriating</strong>.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>20</span> Garrick's Position</h2>
|
||||||
|
<p>Garrick genuinely doesn't care about their courier contract — not out of cruelty. From his perspective, Eamon encountered Aldren, Aldren says the blood isn't enough, the village may be facing its greatest crisis in generations… and five strangers are complaining about paperwork.</p>
|
||||||
|
<p>If Dunnchadh says “We're owed a hundred gold”:</p>
|
||||||
|
<blockquote class="storyline-quote">Then I suggest you enjoy our hospitality while you wait.</blockquote>
|
||||||
|
<p>Not hostility — priorities. The party will probably hate him for it. <strong>Perfect.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>21</span> Where Can They Stay?</h2>
|
||||||
|
<p>After nearly twenty days of travel, give them somewhere to establish themselves. The <a href="{{ path('app_npc_family', {family: 'Brewster'}) }}">Brewsters</a> become their first regular social hub:</p>
|
||||||
|
<p class="storyline-beat">The Hearth & Hound</p>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>Hettie</dt><dd>Runs the tavern.</dd></div>
|
||||||
|
<div><dt>Oswin</dt><dd>Brews the ale.</dd></div>
|
||||||
|
<div><dt>Ralf</dt><dd>Handles much of the brewing work.</dd></div>
|
||||||
|
<div><dt>Mara</dt><dd>Works the tavern.</dd></div>
|
||||||
|
<div><dt>Pip & Nib</dt><dd>Frequently underfoot.</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-body-secondary small">The name having “Hearth” in it despite the separate Hearth family is fine — if anything, a minor local joke.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>22</span> Hettie Brewster</h2>
|
||||||
|
<p>Friendly, not bubbly. A woman who has served almost everyone in Brackenford at some point. She knows who drinks what, who owes money, whose marriage is struggling, whose child broke a window, and exactly how long someone has been sitting alone with the same ale.</p>
|
||||||
|
<p>She sees five outsiders walk in and immediately knows: <strong>news</strong>. She doesn't interrogate them. She lets them talk.</p>
|
||||||
|
<p class="storyline-turn">That's much more dangerous.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>23</span> Tavern Conversations</h2>
|
||||||
|
<p>Give the party chances to talk to villagers. If they mention Eamon, different people react differently:</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">One</span><span>“He'll turn up.”</span></li>
|
||||||
|
<li><span class="k">Another</span><span>“Council's handling it.”</span></li>
|
||||||
|
<li><span class="k">Someone else</span><span>“Best leave Vale alone tonight.”</span></li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Nobody gives exactly the same answer.</strong> This isn't a rehearsed conspiracy; it's a village where everyone knows part of what happened and nobody knows what they're supposed to tell outsiders. That produces inconsistencies naturally.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>24</span> The Word “Blood”</h2>
|
||||||
|
<p>Aim for the first explicit reference to blood <strong>late in the session</strong>. Not Aldren. Not vampire. Just <em>blood</em>.</p>
|
||||||
|
<p>The players overhear <strong>Hester Redbrook</strong> quietly speaking with <strong>Bastian Cooper</strong>:</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Hester</span><span>“We can't change the list now.”</span></li>
|
||||||
|
<li><span class="k">Bastian</span><span>“I'm telling you what I heard.”</span></li>
|
||||||
|
<li><span class="k">Hester</span><span>“And I'm telling you fifteen is what we prepare.”</span></li>
|
||||||
|
<li><span class="k">Bastian</span><span>“What if fifteen isn't enough?”</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>Hettie notices the party listening. The conversation stops.</p>
|
||||||
|
<p>Fifteen <em>what</em>? The players may assume litres, people, crates, gold, food… <strong>Don't explain.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>25</span> If They Confront Bastian</h2>
|
||||||
|
<p>Bastian isn't good at conspiracy. He's a cart driver.</p>
|
||||||
|
<dl class="storyline-qa">
|
||||||
|
<div><dt>What does fifteen mean?</dt><dd>“Nothing you need concern yourself with.”</dd></div>
|
||||||
|
<div><dt>That's obviously suspicious.</dt><dd>“Aye.”</dd></div>
|
||||||
|
</dl>
|
||||||
|
<blockquote class="storyline-quote">You've been here half a day. There are things you don't understand yet.</blockquote>
|
||||||
|
<blockquote class="storyline-quote">If you're still here long enough, maybe you will.</blockquote>
|
||||||
|
<p>This reinforces the line between <strong>villager</strong> and <strong>outsider</strong>.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>26</span> Don't Give Them the Vampire Yet</h2>
|
||||||
|
<p>Resist the temptation. By the end of Session 1 the players should know:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Eamon is missing / unavailable;</li>
|
||||||
|
<li>something happened to him shortly before they arrived;</li>
|
||||||
|
<li>the council knows where he is;</li>
|
||||||
|
<li>the villagers are frightened;</li>
|
||||||
|
<li>there's a strange collection cart;</li>
|
||||||
|
<li>something involving <em>fifteen</em> is apparently no longer enough;</li>
|
||||||
|
<li>blood is involved somehow.</li>
|
||||||
|
</ul>
|
||||||
|
<p>They should <strong>not</strong> yet know why. If someone asks directly, “Are you collecting blood?”, let the NPC's reaction answer before the words do: silence, a glance, then…</p>
|
||||||
|
<blockquote class="storyline-quote">Who told you that?</blockquote>
|
||||||
|
<p>Effectively confirmation, without exposition.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>27</span> Optional — Bryony Returns</h2>
|
||||||
|
<aside class="storyline-callout" data-label="Emergency lever">
|
||||||
|
Use this if the session needs a push, or the party hasn't found the blood thread themselves.
|
||||||
|
</aside>
|
||||||
|
<p>Children hear things they don't understand, and adults forget they're listening. Bryony finds the party later — perhaps she particularly likes the beekeeper now.</p>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">Bryony</span><span>“Did you find Mr. Vale?”</span></li>
|
||||||
|
<li><span class="k">Party</span><span>No.</span></li>
|
||||||
|
<li><span class="k">Bryony</span><span>“He was shouting before.”</span></li>
|
||||||
|
<li><span class="k">Party</span><span>What was he shouting?</span></li>
|
||||||
|
<li><span class="k">Bryony</span><span><em>Thinks.</em> “Something about there not being enough.”</span></li>
|
||||||
|
<li><span class="k">Party</span><span>Enough what?</span></li>
|
||||||
|
<li><span class="k">Bryony</span><span><em>Shrugs.</em> “Blood, I think.”</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>Before they can get more:</p>
|
||||||
|
<p class="storyline-beat">“BRYONY!”</p>
|
||||||
|
<p>Clover is standing some distance away. Not angry. <strong>Scared.</strong></p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>28</span> Session Ending</h2>
|
||||||
|
<p>End with a very small event, not an attack.</p>
|
||||||
|
<p>The party leaves the tavern. Brackenford is dark now. Windows glow. The village is quiet. Somewhere a dog barks. They pass the covered collection cart. Bastian has gone — for the first time, nobody is around it.</p>
|
||||||
|
<p>One character notices something beneath the cart. A tiny dark droplet. Then another. Something has leaked from one of the containers. If touched: dark red, still slightly tacky.</p>
|
||||||
|
<p class="storyline-beat">Blood.</p>
|
||||||
|
<p>Don't say anything else. Let the players react. Then:</p>
|
||||||
|
<div class="storyline-handout">
|
||||||
|
Twenty days ago, you accepted a simple courier job.<br>
|
||||||
|
This morning, all you needed was Eamon Vale's signature.<br>
|
||||||
|
Now Eamon is being hidden by the village council…<br>
|
||||||
|
and someone in Brackenford is collecting blood.
|
||||||
|
</div>
|
||||||
|
<p class="storyline-turn">End Session 1.</p>
|
||||||
|
|
||||||
|
<h2 class="storyline-section"><span>+</span> Appendix — What Actually Happened During Session 1</h2>
|
||||||
|
<ul class="storyline-rows">
|
||||||
|
<li><span class="k">~2 h before</span><span>Eamon encounters Aldren.</span></li>
|
||||||
|
<li><span class="k">~1 h 45 m before</span><span>Eamon runs into Brackenford shouting that the blood isn't enough.</span></li>
|
||||||
|
<li><span class="k">~1 h 30 m before</span><span>Garrick and other council members intervene.</span></li>
|
||||||
|
<li><span class="k">~1 h before</span><span>Eamon is moved somewhere secure.</span></li>
|
||||||
|
<li><span class="k">Party arrives</span><span>Everyone is still processing what happened.</span></li>
|
||||||
|
</ul>
|
||||||
|
<p>That last part is crucial. <strong>The villagers aren't performing a perfectly organised cover-up. They're improvising.</strong> That's why people contradict each other, why children almost say things they shouldn't, why Bastian and Hester are arguing, and why Maris nearly tells the party too much.</p>
|
||||||
|
<p class="storyline-turn">The PCs haven't entered a conspiracy prepared for them. They've walked into a village two hours after a crisis began.</p>
|
||||||
|
<p class="text-body-secondary small">Follows <a href="{{ path('app_storyline_chapter', {slug: 'prologue-the-long-road'}) }}">the Prologue</a> — see “Appendix — Timing of Eamon's Encounter with Aldren”.</p>
|
||||||
@@ -3,9 +3,31 @@
|
|||||||
{% block title %}Storyline · dmtools{% endblock %}
|
{% block title %}Storyline · dmtools{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{{ include('_partials/page_placeholder.html.twig', {
|
<div class="storyline-index">
|
||||||
title: 'Storyline',
|
<header class="mb-4">
|
||||||
icon: '📜',
|
<div class="text-uppercase small text-body-secondary fw-semibold" style="letter-spacing:.08em">Storyline</div>
|
||||||
blurb: 'The campaign storyline will live here. Shape still to be decided — session log, plot threads, or a wiki-style document.',
|
<h1 class="display-6 mb-2">Brackenford</h1>
|
||||||
}) }}
|
<p class="text-body-secondary mb-0" style="max-width:60ch">
|
||||||
|
The campaign bible, split into chapters. The overview lays out the whole arc;
|
||||||
|
the prologue is the first session. More chapters — sessions, locations and the
|
||||||
|
people of the village — will follow.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol class="storyline-chapter-list list-unstyled">
|
||||||
|
{% for slug, chapter in chapters %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ path('app_storyline_chapter', {slug: slug}) }}" class="storyline-chapter-card">
|
||||||
|
<span class="storyline-chapter-num">{{ chapter.tag }}</span>
|
||||||
|
<span class="storyline-chapter-body">
|
||||||
|
<span class="storyline-chapter-kicker">{{ chapter.eyebrow }}</span>
|
||||||
|
<span class="storyline-chapter-title">{{ chapter.title }}</span>
|
||||||
|
<span class="storyline-chapter-summary">{{ chapter.summary }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="storyline-chapter-go" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Tracker · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
|
||||||
|
<h1 class="h3 mb-0">Encounters</h1>
|
||||||
|
<form method="post" action="{{ path('app_tracker_new') }}" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('tracker-new') }}">
|
||||||
|
<input type="text" name="name" class="form-control form-control-sm" style="width: 14rem;"
|
||||||
|
placeholder="New encounter name" aria-label="New encounter name">
|
||||||
|
<button class="btn btn-primary btn-sm text-nowrap">New encounter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not encounters %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center text-body-secondary py-5">
|
||||||
|
No encounters yet. Name one above, or go to <strong>Enemies</strong>, tick the combatants
|
||||||
|
you want and add them to a new encounter.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group shadow-sm">
|
||||||
|
{% for e in encounters %}
|
||||||
|
<a href="{{ path('app_tracker_show', {id: e.id}) }}"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
<span class="fw-semibold">{{ e.name }}</span>
|
||||||
|
<span class="text-body-secondary small">
|
||||||
|
· {{ e.combatants|length }} combatant{{ e.combatants|length == 1 ? '' : 's' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-body-secondary small">updated {{ e.updatedAt|date('Y-m-d H:i') }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||