dndkit 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dndkit/__init__.py +10 -0
- dndkit/__main__.py +6 -0
- dndkit/activity.py +68 -0
- dndkit/app.py +105 -0
- dndkit/config.py +104 -0
- dndkit/errors.py +48 -0
- dndkit/game/__init__.py +1 -0
- dndkit/game/areas.py +181 -0
- dndkit/game/battlefield.py +154 -0
- dndkit/game/bestiary.py +155 -0
- dndkit/game/characters.py +227 -0
- dndkit/game/combat.py +209 -0
- dndkit/game/combatants.py +188 -0
- dndkit/game/concentration.py +43 -0
- dndkit/game/dice.py +186 -0
- dndkit/game/effects.py +183 -0
- dndkit/game/grid.py +88 -0
- dndkit/game/journal.py +47 -0
- dndkit/game/movement.py +264 -0
- dndkit/game/pathing.py +153 -0
- dndkit/game/proficiencies.py +104 -0
- dndkit/game/progression.py +77 -0
- dndkit/game/readiness.py +214 -0
- dndkit/game/reference.py +123 -0
- dndkit/game/registry.py +172 -0
- dndkit/game/resources.py +96 -0
- dndkit/game/rests.py +98 -0
- dndkit/game/rules.py +196 -0
- dndkit/game/seating.py +71 -0
- dndkit/game/sight.py +142 -0
- dndkit/game/spellbook.py +97 -0
- dndkit/game/tables.py +216 -0
- dndkit/game/world.py +125 -0
- dndkit/models/__init__.py +69 -0
- dndkit/models/base.py +26 -0
- dndkit/models/battle.py +40 -0
- dndkit/models/bestiary.py +56 -0
- dndkit/models/campaign.py +209 -0
- dndkit/models/character.py +149 -0
- dndkit/models/world.py +62 -0
- dndkit/skills/__init__.py +14 -0
- dndkit/skills/catalog.py +113 -0
- dndkit/skills/dnd-battle-map/SKILL.md +78 -0
- dndkit/skills/dnd-game-master/SKILL.md +125 -0
- dndkit/skills/dnd-monsters/SKILL.md +85 -0
- dndkit/skills/extension.py +93 -0
- dndkit/storage/__init__.py +6 -0
- dndkit/storage/catalog.py +72 -0
- dndkit/storage/confirmations.py +54 -0
- dndkit/storage/files.py +55 -0
- dndkit/storage/sharing.py +67 -0
- dndkit/storage/store.py +278 -0
- dndkit/text.py +13 -0
- dndkit/tools/__init__.py +47 -0
- dndkit/tools/apps.py +47 -0
- dndkit/tools/battle.py +117 -0
- dndkit/tools/bestiary.py +121 -0
- dndkit/tools/campaign.py +208 -0
- dndkit/tools/characters.py +189 -0
- dndkit/tools/combat.py +169 -0
- dndkit/tools/deps.py +42 -0
- dndkit/tools/journal.py +102 -0
- dndkit/tools/locations.py +98 -0
- dndkit/tools/npcs.py +101 -0
- dndkit/tools/play.py +118 -0
- dndkit/tools/players.py +44 -0
- dndkit/tools/progression.py +41 -0
- dndkit/tools/quests.py +95 -0
- dndkit/tools/recorder.py +63 -0
- dndkit/tools/render.py +281 -0
- dndkit/tools/render_combat.py +121 -0
- dndkit/tools/render_map.py +141 -0
- dndkit/tools/resources.py +104 -0
- dndkit/tools/schema.py +47 -0
- dndkit/tools/screen.py +47 -0
- dndkit/tools/spells.py +67 -0
- dndkit/tools/utility.py +42 -0
- dndkit/ui/__init__.py +52 -0
- dndkit/ui/backstage.js +41 -0
- dndkit/ui/battlemap.js +70 -0
- dndkit/ui/character.html +23 -0
- dndkit/ui/combat.html +18 -0
- dndkit/ui/components.css +283 -0
- dndkit/ui/components.js +261 -0
- dndkit/ui/desk.js +91 -0
- dndkit/ui/dice.html +42 -0
- dndkit/ui/dicetray.js +99 -0
- dndkit/ui/gm.html +59 -0
- dndkit/ui/party.html +18 -0
- dndkit/ui/player.html +61 -0
- dndkit/ui/seating.html +18 -0
- dndkit/ui/shared.css +195 -0
- dndkit/ui/shared.js +242 -0
- dndkit/ui/sheet.js +75 -0
- dndkit/ui/table.html +41 -0
- dndkit/ui/words.js +120 -0
- dndkit/ui/world.js +56 -0
- dndkit/views/__init__.py +7 -0
- dndkit/views/backstage.py +49 -0
- dndkit/views/battle.py +92 -0
- dndkit/views/desk.py +76 -0
- dndkit/views/everyone.py +194 -0
- dndkit/views/public.py +89 -0
- dndkit/views/widgets.py +58 -0
- dndkit/views/world.py +70 -0
- dndkit/web/__init__.py +10 -0
- dndkit/web/access.py +136 -0
- dndkit/web/audiences.py +111 -0
- dndkit/web/changes.py +84 -0
- dndkit/web/feed.py +112 -0
- dndkit/web/routes.py +134 -0
- dndkit/web/server.py +131 -0
- dndkit-0.4.0.dist-info/METADATA +567 -0
- dndkit-0.4.0.dist-info/RECORD +118 -0
- dndkit-0.4.0.dist-info/WHEEL +5 -0
- dndkit-0.4.0.dist-info/entry_points.txt +2 -0
- dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
- dndkit-0.4.0.dist-info/top_level.txt +1 -0
dndkit/ui/shared.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/* dndkit pages: how they get their data, and shared DOM helpers.
|
|
2
|
+
*
|
|
3
|
+
* Two transports, same pages. As an MCP App in roomkit-ui, a page gets only
|
|
4
|
+
* the tool's text result, sometimes in the widget it replaces, so it reads
|
|
5
|
+
* its data through tools/call get_screen_data and polls it. The host answers
|
|
6
|
+
* request ids as strings, may not listen yet when the page loads
|
|
7
|
+
* (ui/initialize is retried), and shows any console.error under the widget,
|
|
8
|
+
* so errors are rendered, never logged.
|
|
9
|
+
* As a web page (window.GM_WEB, set by the server), a page opens one event
|
|
10
|
+
* stream for all its views and the server pushes each change.
|
|
11
|
+
*/
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
import { I18N } from "./words.js";
|
|
15
|
+
|
|
16
|
+
export const GM = (() => {
|
|
17
|
+
const WEB = window.GM_WEB || null;
|
|
18
|
+
const t = I18N.t;
|
|
19
|
+
const pending = new Map();
|
|
20
|
+
const inputWaiters = [];
|
|
21
|
+
const RETRY_AFTER_REFUSAL_MS = 10000;
|
|
22
|
+
const resultHandlers = [];
|
|
23
|
+
let nextId = 1;
|
|
24
|
+
let ready = false;
|
|
25
|
+
let input = null;
|
|
26
|
+
let lastHeight = 0;
|
|
27
|
+
|
|
28
|
+
const post = (message) => window.parent.postMessage({ jsonrpc: "2.0", ...message }, "*");
|
|
29
|
+
const notify = (method, params) => post({ method, params });
|
|
30
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
|
|
32
|
+
function request(method, params, timeoutMs) {
|
|
33
|
+
const id = nextId++;
|
|
34
|
+
post({ id, method, params });
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
pending.set(String(id), { resolve, reject });
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
if (pending.delete(String(id))) reject(new Error("timeout"));
|
|
39
|
+
}, timeoutMs);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
window.addEventListener("message", (event) => {
|
|
44
|
+
const msg = event.data;
|
|
45
|
+
if (!msg || msg.jsonrpc !== "2.0") return;
|
|
46
|
+
if (msg.id !== undefined && msg.method === undefined) {
|
|
47
|
+
const waiter = pending.get(String(msg.id));
|
|
48
|
+
if (!waiter) return;
|
|
49
|
+
pending.delete(String(msg.id));
|
|
50
|
+
if (msg.error) waiter.reject(new Error(msg.error.message || "error"));
|
|
51
|
+
else waiter.resolve(msg.result);
|
|
52
|
+
} else if (msg.method === "ui/notifications/tool-input") {
|
|
53
|
+
input = (msg.params && msg.params.arguments) || {};
|
|
54
|
+
inputWaiters.splice(0).forEach((resolve) => resolve(input));
|
|
55
|
+
} else if (msg.method === "ui/notifications/tool-result") {
|
|
56
|
+
resultHandlers.forEach((handler) => handler(msg.params || {}));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function connect() {
|
|
61
|
+
for (let attempt = 0; attempt < 10 && !ready; attempt++) {
|
|
62
|
+
try {
|
|
63
|
+
await request(
|
|
64
|
+
"ui/initialize",
|
|
65
|
+
{
|
|
66
|
+
protocolVersion: "2026-01-26",
|
|
67
|
+
appInfo: { name: "dndkit", version: "1" },
|
|
68
|
+
appCapabilities: {},
|
|
69
|
+
},
|
|
70
|
+
800,
|
|
71
|
+
);
|
|
72
|
+
ready = true;
|
|
73
|
+
} catch (_) {
|
|
74
|
+
/* the host was not listening yet: ask again */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
notify("ui/notifications/initialized", {});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The arguments the model called the tool with ({} if none arrive). */
|
|
81
|
+
function toolInput(timeoutMs = 3000) {
|
|
82
|
+
if (input) return Promise.resolve(input);
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
inputWaiters.push(resolve);
|
|
85
|
+
setTimeout(() => resolve(input || {}), timeoutMs);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function callTool(name, args) {
|
|
90
|
+
const result = await request("tools/call", { name, arguments: args || {} }, 15000);
|
|
91
|
+
const text = ((result && result.content) || [])
|
|
92
|
+
.filter((block) => block.type === "text")
|
|
93
|
+
.map((block) => block.text)
|
|
94
|
+
.join("\n");
|
|
95
|
+
if (result && result.isError) throw new Error(text || "error");
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const data = async (view, extra) =>
|
|
100
|
+
JSON.parse(await callTool("get_screen_data", { view, ...(extra || {}) }));
|
|
101
|
+
|
|
102
|
+
/** Render *view* now and at every change: pushed on the web, polled in a widget. */
|
|
103
|
+
function live(view, extra, render, intervalMs) {
|
|
104
|
+
if (WEB) return feed.add(view, render);
|
|
105
|
+
let previous = "";
|
|
106
|
+
let failures = 0;
|
|
107
|
+
const tick = async () => {
|
|
108
|
+
try {
|
|
109
|
+
const text = await callTool("get_screen_data", { view, ...(extra || {}) });
|
|
110
|
+
failures = 0;
|
|
111
|
+
if (text !== previous) {
|
|
112
|
+
previous = text;
|
|
113
|
+
render(JSON.parse(text));
|
|
114
|
+
status("");
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
failures += 1;
|
|
118
|
+
status(error.message);
|
|
119
|
+
}
|
|
120
|
+
if (failures < 5) setTimeout(tick, intervalMs);
|
|
121
|
+
};
|
|
122
|
+
tick();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The web pages' one event stream: every view a page shows, sent at each change. */
|
|
126
|
+
const feed = {
|
|
127
|
+
renders: new Map(),
|
|
128
|
+
errors: new Map(), // view -> its last error, until it renders again
|
|
129
|
+
source: null,
|
|
130
|
+
add(view, render) {
|
|
131
|
+
if (!this.renders.has(view)) this.renders.set(view, []);
|
|
132
|
+
this.renders.get(view).push(render);
|
|
133
|
+
if (!this.source) this.source = Promise.resolve().then(() => this.open());
|
|
134
|
+
},
|
|
135
|
+
show() {
|
|
136
|
+
status([...new Set(this.errors.values())].join(" · "));
|
|
137
|
+
},
|
|
138
|
+
open() {
|
|
139
|
+
const views = [...this.renders.keys()].join(",");
|
|
140
|
+
const source = new EventSource(`${WEB.feed}?views=${encodeURIComponent(views)}`);
|
|
141
|
+
for (const [view, renders] of this.renders) {
|
|
142
|
+
source.addEventListener(view, (event) => {
|
|
143
|
+
const data = JSON.parse(event.data);
|
|
144
|
+
if (data.error) this.errors.set(view, t(data.error));
|
|
145
|
+
else {
|
|
146
|
+
this.errors.delete(view);
|
|
147
|
+
renders.forEach((render) => render(data));
|
|
148
|
+
}
|
|
149
|
+
this.show();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// The browser reconnects by itself, and the server then sends every view
|
|
153
|
+
// again; after a refusal (a link that no longer matches) it gives up, so
|
|
154
|
+
// the page tries again a little later.
|
|
155
|
+
source.onerror = () => {
|
|
156
|
+
status(t("offline"));
|
|
157
|
+
if (source.readyState !== EventSource.CLOSED) return;
|
|
158
|
+
setTimeout(() => this.open(), RETRY_AFTER_REFUSAL_MS);
|
|
159
|
+
};
|
|
160
|
+
source.onopen = () => this.show();
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function fit() {
|
|
165
|
+
if (WEB) return;
|
|
166
|
+
const height = Math.ceil(document.body.getBoundingClientRect().height) + 2;
|
|
167
|
+
if (Math.abs(height - lastHeight) < 4) return;
|
|
168
|
+
lastHeight = height;
|
|
169
|
+
notify("ui/notifications/size-changed", { width: document.body.scrollWidth, height });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function status(message) {
|
|
173
|
+
const box = document.getElementById("status");
|
|
174
|
+
if (box) box.textContent = message || "";
|
|
175
|
+
fit();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Children to append: nested lists flattened, empty values dropped
|
|
179
|
+
* (DOM methods would print null as the text "null"). */
|
|
180
|
+
const nodes = (children) =>
|
|
181
|
+
children
|
|
182
|
+
.flat(Infinity)
|
|
183
|
+
.filter((child) => child !== null && child !== undefined && child !== false && child !== "")
|
|
184
|
+
.map((child) => (child instanceof Node ? child : document.createTextNode(String(child))));
|
|
185
|
+
|
|
186
|
+
/** Replace an element's content. */
|
|
187
|
+
const fill = (el, ...children) => el.replaceChildren(...nodes(children));
|
|
188
|
+
|
|
189
|
+
/** Build DOM without innerHTML: names come from players and the model. */
|
|
190
|
+
function h(tag, attrs, ...children) {
|
|
191
|
+
const el = document.createElement(tag);
|
|
192
|
+
for (const [key, value] of Object.entries(attrs || {})) {
|
|
193
|
+
if (value === null || value === undefined || value === false) continue;
|
|
194
|
+
if (key === "class") el.className = value;
|
|
195
|
+
else if (key === "style") el.style.cssText = value;
|
|
196
|
+
else el.setAttribute(key, value === true ? "" : value);
|
|
197
|
+
}
|
|
198
|
+
el.append(...nodes(children));
|
|
199
|
+
return el;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hpBar(hp, max, temp) {
|
|
203
|
+
if (hp === null || hp === undefined || !max) return h("div", { class: "muted small" }, t("hp") + " ?");
|
|
204
|
+
const ratio = Math.max(0, Math.min(1, hp / max));
|
|
205
|
+
const tone = ratio > 0.5 ? "" : ratio > 0.25 ? " mid" : " low";
|
|
206
|
+
const label = `${hp}/${max}` + (temp ? ` (+${temp} ${t("temp")})` : "");
|
|
207
|
+
return h(
|
|
208
|
+
"div",
|
|
209
|
+
{ class: "hp" },
|
|
210
|
+
h("div", { class: "hpbar" + tone }, h("i", { style: `width:${ratio * 100}%` })),
|
|
211
|
+
h("span", { class: "hplabel" }, label),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A creature's conditions (turns left on a timed one), exhaustion and spell held. */
|
|
216
|
+
const conditions = ({ conditions: names, rounds, exhaustion, concentration }) =>
|
|
217
|
+
h(
|
|
218
|
+
"div",
|
|
219
|
+
{ class: "chips" },
|
|
220
|
+
concentration ? h("span", { class: "chip conc", title: t("concentration") }, `◎ ${concentration}`) : null,
|
|
221
|
+
(names || []).map((name) => h("span", { class: "chip cond" },
|
|
222
|
+
rounds && rounds[name] ? `${t(name)} · ${t("turnsLeft", { n: rounds[name] })}` : t(name))),
|
|
223
|
+
exhaustion ? h("span", { class: "chip warn" }, `${t("exhaustion")} ${exhaustion}`) : null,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
function pips(filled, total, kind) {
|
|
227
|
+
return h(
|
|
228
|
+
"span",
|
|
229
|
+
{ class: "pips " + (kind || "") },
|
|
230
|
+
Array.from({ length: total }, (_, i) => h("i", { class: i < filled ? "on" : "" })),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Connect to the host (a widget) or not (a web page), then run the page's main function. */
|
|
235
|
+
function start(main) {
|
|
236
|
+
if (!WEB) new ResizeObserver(fit).observe(document.body);
|
|
237
|
+
(WEB ? Promise.resolve() : connect()).then(main).catch((error) => status(error.message));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { web: Boolean(WEB), start, toolInput, data, live, callTool, h, fill, t, hpBar, conditions, pips,
|
|
241
|
+
status, fit, sleep, onResult: (handler) => resultHandlers.push(handler) };
|
|
242
|
+
})();
|
dndkit/ui/sheet.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* A character sheet as its player reads it: who they are, their numbers, how
|
|
2
|
+
* they cast, their spells (each opening on its description), their gear,
|
|
3
|
+
* proficiencies and story. */
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
import { I18N } from "./words.js";
|
|
7
|
+
import { GM } from "./shared.js";
|
|
8
|
+
|
|
9
|
+
export function CharacterSheet() {
|
|
10
|
+
const { h, fill, t } = GM;
|
|
11
|
+
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
|
12
|
+
const section = (title, ...content) => h("div", { class: "section" }, h("h4", {}, title), ...content);
|
|
13
|
+
const chips = (items) => h("div", { class: "list" }, items.map((item) => h("span", { class: "chip" }, item)));
|
|
14
|
+
const bonus = (name, value) => (value === null || value === undefined ? name : `${name} ${signed(value)}`);
|
|
15
|
+
const stat = ([label, value]) => h("div", { class: "stat" }, h("b", {}, value), h("span", {}, label));
|
|
16
|
+
const el = h("div", { class: "card sheet" }, h("div", { class: "empty" }, "…"));
|
|
17
|
+
|
|
18
|
+
const spell = (s) => h("details", { class: "spell" + (s.prepared ? " prepared" : "") },
|
|
19
|
+
h("summary", {}, h("b", {}, s.name), " ",
|
|
20
|
+
h("span", { class: "muted small" }, s.level === 0 ? t("cantrip") : `${t("level")} ${s.level}`),
|
|
21
|
+
s.prepared ? h("span", { class: "chip warn" }, t("prepared")) : null),
|
|
22
|
+
s.details ? h("div", { class: "muted small" }, s.details) : null,
|
|
23
|
+
s.description ? h("p", { class: "small" }, s.description) : null);
|
|
24
|
+
|
|
25
|
+
const item = (i) => h("div", { class: "item" },
|
|
26
|
+
i.slot ? h("span", { class: "chip" }, i.slot) : null, " ",
|
|
27
|
+
h("b", {}, i.qty > 1 ? `${i.name} ×${i.qty}` : i.name),
|
|
28
|
+
i.details ? h("span", { class: "muted small" }, ` (${i.details})`) : null,
|
|
29
|
+
i.description ? h("span", { class: "muted small" }, ` — ${i.description}`) : null);
|
|
30
|
+
|
|
31
|
+
function render(c) {
|
|
32
|
+
const stats = [[t("ac"), c.ac], [t("speed"), I18N.distance(c.speed)], [t("initiative"), signed(c.initiative)],
|
|
33
|
+
[t("proficiency"), signed(c.proficiency)], [t("xp"), c.xp]];
|
|
34
|
+
const casting = c.casting ? [[t("castsWith"), t(c.casting.ability.slice(0, 3))], [t("spellAttack"), signed(c.casting.attack)],
|
|
35
|
+
[t("spellDc"), c.casting.dc]] : [];
|
|
36
|
+
const xpRatio = c.next_level_xp ? Math.min(1, c.xp / c.next_level_xp) : 1;
|
|
37
|
+
const identity = [c.background, c.alignment].filter(Boolean).join(" · ");
|
|
38
|
+
const deathSaves = c.hp !== 0 ? null : c.stable ? h("div", { class: "small" }, t("stable")) :
|
|
39
|
+
h("div", { class: "small" }, `${t("deathSaves")} `,
|
|
40
|
+
GM.pips(c.death_saves[0], 3, "good"), " ", GM.pips(c.death_saves[1], 3, "bad"));
|
|
41
|
+
fill(el,
|
|
42
|
+
h("div", { class: "head" }, h("span", { class: "title" }, c.inspiration ? `${c.name} ★` : c.name),
|
|
43
|
+
h("span", { class: "muted small" }, c.summary + (c.player ? ` — ${c.player}` : ""))),
|
|
44
|
+
identity ? h("div", { class: "muted small identity" }, identity) : null,
|
|
45
|
+
h("div", { class: "vitals" }, GM.hpBar(c.hp, c.hp_max, c.temp)),
|
|
46
|
+
deathSaves,
|
|
47
|
+
h("div", { class: "stats" }, stats.map(stat)),
|
|
48
|
+
casting.length ? h("div", { class: "stats casting" }, casting.map(stat)) : null,
|
|
49
|
+
c.next_level_xp ? h("div", { class: "muted small" }, `${t("nextLevel")}: ${c.next_level_xp} ${t("xp")}`,
|
|
50
|
+
h("div", { class: "xp" }, h("i", { style: `width:${xpRatio * 100}%` }))) : null,
|
|
51
|
+
h("div", { class: "abilities" }, c.abilities.map((a) => h("div", { class: "ability" },
|
|
52
|
+
h("div", { class: "key" }, t(a.key)), h("div", { class: "mod" }, signed(a.mod)),
|
|
53
|
+
h("div", { class: "score" }, a.score)))),
|
|
54
|
+
GM.conditions(c),
|
|
55
|
+
c.slots.length ? section(t("slots"), h("div", { class: "list" }, c.slots.map(([level, left, max]) =>
|
|
56
|
+
h("span", { class: "slot" }, `${t("lvl")} ${level} `, GM.pips(left, max))))) : null,
|
|
57
|
+
c.spells.length ? section(t("spells"), h("div", { class: "spells" }, c.spells.map(spell))) : null,
|
|
58
|
+
c.equipment.length ? section(t("equipped"), h("div", {}, c.equipment.map(item))) : null,
|
|
59
|
+
c.inventory.length ? section(t("inventory"), h("div", {}, c.inventory.map(item))) : null,
|
|
60
|
+
c.race.subrace || c.race.traits.length ? section(c.race.subrace ? `${c.race.name} · ${c.race.subrace}` : c.race.name,
|
|
61
|
+
chips(c.race.traits)) : null,
|
|
62
|
+
c.features.length ? section(t("features"), chips(c.features)) : null,
|
|
63
|
+
c.skills.length || c.saves.length ? section(t("proficiencies"),
|
|
64
|
+
chips([...c.skills.map((s) => bonus(t(s.name), s.bonus)),
|
|
65
|
+
...c.saves.map((s) => bonus(`${t("save")} ${t(s.name)}`, s.bonus))])) : null,
|
|
66
|
+
c.languages.length ? section(t("languages"), chips(c.languages)) : null,
|
|
67
|
+
c.description || c.bio ? section(t("story"),
|
|
68
|
+
c.description ? h("p", { class: "small appearance" }, c.description) : null,
|
|
69
|
+
c.bio ? h("p", { class: "small" }, c.bio) : null) : null,
|
|
70
|
+
h("div", { class: "muted small" }, `${t("hitDice")}: ${c.hit_dice}`),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { el, render };
|
|
75
|
+
}
|
dndkit/ui/table.html
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Table</title>
|
|
7
|
+
<!--gm:shared-->
|
|
8
|
+
</head>
|
|
9
|
+
<body class="web">
|
|
10
|
+
<div id="heading"></div>
|
|
11
|
+
<div class="board">
|
|
12
|
+
<div class="col" id="main"></div>
|
|
13
|
+
<div class="col" id="side"></div>
|
|
14
|
+
</div>
|
|
15
|
+
<div id="status"></div>
|
|
16
|
+
<script>
|
|
17
|
+
// The shared screen: what everyone at the table may see, and the codes
|
|
18
|
+
// that open each character on a phone.
|
|
19
|
+
const combat = Views.combat();
|
|
20
|
+
const heading = Views.campaign();
|
|
21
|
+
document.getElementById("heading").append(heading.el);
|
|
22
|
+
const seating = Views.seating();
|
|
23
|
+
const party = Views.party({ quests: false, campaign: false });
|
|
24
|
+
const dice = Views.dice();
|
|
25
|
+
const journal = Views.journal();
|
|
26
|
+
const join = Views.join();
|
|
27
|
+
document.getElementById("main").append(combat.el, party.el, seating.el);
|
|
28
|
+
document.getElementById("side").append(dice.el, journal.el, join.el);
|
|
29
|
+
|
|
30
|
+
GM.start(() => {
|
|
31
|
+
GM.live("party", null, heading.render);
|
|
32
|
+
GM.live("seating", null, seating.render);
|
|
33
|
+
GM.live("combat", null, (data) => { combat.el.hidden = !data.in_combat; combat.render(data); });
|
|
34
|
+
GM.live("party", null, (data) => { document.title = data.campaign; party.render(data); });
|
|
35
|
+
GM.live("dice", null, dice.render);
|
|
36
|
+
GM.live("journal", null, journal.render);
|
|
37
|
+
GM.live("join", null, join.render);
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
dndkit/ui/words.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* dndkit pages: their words in English and French, picked from the
|
|
2
|
+
* browser's language. t("key") also passes through an unknown key, so a
|
|
3
|
+
* server message without a translation still shows. */
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
export const I18N = (() => {
|
|
7
|
+
const FRENCH = (navigator.language || "").toLowerCase().startsWith("fr");
|
|
8
|
+
const WORDS = {
|
|
9
|
+
round: ["Round", "Round"], turn: ["turn", "tour"], hp: ["HP", "PV"], ac: ["AC", "CA"],
|
|
10
|
+
total: ["Total", "Total"], advantage: ["Advantage", "Avantage"],
|
|
11
|
+
disadvantage: ["Disadvantage", "Désavantage"], waiting: ["Rolling…", "Lancer…"],
|
|
12
|
+
noCombat: ["No combat running.", "Aucun combat en cours."],
|
|
13
|
+
defeated: ["Overcome", "Vaincus"], xp: ["XP", "XP"], session: ["Session", "Session"],
|
|
14
|
+
funds: ["Funds", "Bourse"], quests: ["Active quests", "Quêtes en cours"],
|
|
15
|
+
noQuest: ["No active quest.", "Aucune quête en cours."], inCombat: ["In combat", "En combat"],
|
|
16
|
+
party: ["Party", "Groupe"], noParty: ["No character yet.", "Aucun personnage."],
|
|
17
|
+
slots: ["Spell slots", "Emplacements de sorts"], spells: ["Spells", "Sorts"],
|
|
18
|
+
inventory: ["Inventory", "Inventaire"], cantrip: ["Cantrip", "Tour de magie"],
|
|
19
|
+
level: ["Level", "Niveau"], prepared: ["prepared", "préparé"],
|
|
20
|
+
proficiency: ["Proficiency", "Maîtrise"], inspiration: ["Inspiration", "Inspiration"],
|
|
21
|
+
deathSaves: ["Death saves", "Jets contre la mort"], exhaustion: ["Exhaustion", "Épuisement"],
|
|
22
|
+
temp: ["temp", "temp"], unknown: ["unknown", "inconnu"], nextLevel: ["next level", "niveau suivant"],
|
|
23
|
+
turnOf: ["{name}'s turn", "Tour de {name}"], lvl: ["Lvl", "Niv."],
|
|
24
|
+
str: ["STR", "FOR"], dex: ["DEX", "DEX"], con: ["CON", "CON"], int: ["INT", "INT"],
|
|
25
|
+
wis: ["WIS", "SAG"], cha: ["CHA", "CHA"],
|
|
26
|
+
blinded: ["blinded", "aveuglé"], charmed: ["charmed", "charmé"], deafened: ["deafened", "assourdi"],
|
|
27
|
+
frightened: ["frightened", "effrayé"], grappled: ["grappled", "agrippé"],
|
|
28
|
+
incapacitated: ["incapacitated", "neutralisé"], invisible: ["invisible", "invisible"],
|
|
29
|
+
paralyzed: ["paralyzed", "paralysé"], petrified: ["petrified", "pétrifié"],
|
|
30
|
+
poisoned: ["poisoned", "empoisonné"], prone: ["prone", "à terre"],
|
|
31
|
+
restrained: ["restrained", "entravé"], stunned: ["stunned", "étourdi"],
|
|
32
|
+
unconscious: ["unconscious", "inconscient"],
|
|
33
|
+
// How hurt a monster looks to the players.
|
|
34
|
+
unhurt: ["unhurt", "indemne"], hurt: ["hurt", "blessé"], bloodied: ["bloodied", "en sang"],
|
|
35
|
+
down: ["down", "hors de combat"],
|
|
36
|
+
speed: ["Speed", "Vitesse"], hidden: ["hidden", "caché"], boss: ["Boss", "Boss"],
|
|
37
|
+
initiative: ["Initiative", "Initiative"], castsWith: ["Casts with", "Lance avec"],
|
|
38
|
+
spellAttack: ["Spell attack", "Attaque de sort"], spellDc: ["Spell save DC", "DD des sorts"],
|
|
39
|
+
intelligence: ["Intelligence", "Intelligence"], wisdom: ["Wisdom", "Sagesse"], charisma: ["Charisma", "Charisme"],
|
|
40
|
+
features: ["Features", "Capacités"], proficiencies: ["Proficiencies", "Maîtrises"], save: ["Save", "JS"],
|
|
41
|
+
languages: ["Languages", "Langues"], story: ["Story", "Histoire"], hitDice: ["Hit dice", "Dés de vie"],
|
|
42
|
+
legendaryActions: ["Legendary actions", "Actions légendaires"],
|
|
43
|
+
legendaryResistances: ["Legendary resistances", "Résistances légendaires"],
|
|
44
|
+
// The table.
|
|
45
|
+
theTable: ["🪑 The table", "🪑 La table"], noCharacter: ["no character yet", "pas encore de personnage"],
|
|
46
|
+
nobodySeated: ["Nobody seated yet: the game master asks who is playing.",
|
|
47
|
+
"Personne n'est encore installé : le maître du jeu demande qui joue."],
|
|
48
|
+
absentees: ["Absent", "Absents"], knownPlayers: ["Known players", "Joueurs connus"],
|
|
49
|
+
noPlayer: ["No player", "Sans joueur"], away: ["absent", "absent"], unplayed: ["no player", "sans joueur"],
|
|
50
|
+
// Web pages.
|
|
51
|
+
yourTurn: ["Your turn!", "À toi de jouer !"], journal: ["Adventure log", "Journal"],
|
|
52
|
+
noEvent: ["Nothing logged yet.", "Rien de noté pour l'instant."], from: ["from {name}", "confiée par {name}"],
|
|
53
|
+
players: ["Players", "Joueurs"], showCodes: ["Join codes", "Codes joueurs"], hideCodes: ["Hide", "Masquer"],
|
|
54
|
+
scanToJoin: ["Scan your character's code to follow it on your phone.",
|
|
55
|
+
"Scanne le code de ton personnage pour le suivre sur ton téléphone."],
|
|
56
|
+
unreachable: ["Phones cannot reach this server: start it with DNDKIT_WEB_HOST=0.0.0.0.",
|
|
57
|
+
"Les téléphones ne peuvent pas joindre ce serveur : lance-le avec DNDKIT_WEB_HOST=0.0.0.0."],
|
|
58
|
+
noCampaign: ["No active campaign.", "Aucune campagne active."],
|
|
59
|
+
unknownLink: ["This link no longer matches a character of the active campaign.",
|
|
60
|
+
"Ce lien ne correspond plus à un personnage de la campagne active."],
|
|
61
|
+
offline: ["Connection lost, reconnecting…", "Connexion perdue, reconnexion…"],
|
|
62
|
+
// Skills, by their English names (PHB ch. 7) and the French translation.
|
|
63
|
+
Acrobatics: ["Acrobatics", "Acrobaties"], "Animal Handling": ["Animal Handling", "Dressage"],
|
|
64
|
+
Arcana: ["Arcana", "Arcanes"], Athletics: ["Athletics", "Athlétisme"], Deception: ["Deception", "Tromperie"],
|
|
65
|
+
History: ["History", "Histoire"], Insight: ["Insight", "Perspicacité"], Intimidation: ["Intimidation", "Intimidation"],
|
|
66
|
+
Investigation: ["Investigation", "Investigation"], Medicine: ["Medicine", "Médecine"], Nature: ["Nature", "Nature"],
|
|
67
|
+
Perception: ["Perception", "Perception"], Performance: ["Performance", "Représentation"],
|
|
68
|
+
Persuasion: ["Persuasion", "Persuasion"], Religion: ["Religion", "Religion"],
|
|
69
|
+
"Sleight of Hand": ["Sleight of Hand", "Escamotage"], Stealth: ["Stealth", "Discrétion"], Survival: ["Survival", "Survie"],
|
|
70
|
+
concentration: ["Concentration", "Concentration"],
|
|
71
|
+
stable: ["Stable at 0 HP", "Stabilisé à 0 PV"],
|
|
72
|
+
turnsLeft: ["{n} turns left", "{n} tours restants"],
|
|
73
|
+
reactionUsed: ["Reaction used this round", "Réaction utilisée ce round"],
|
|
74
|
+
secretRoll: ["Secret roll, for the game master", "Jet secret, pour le MJ"],
|
|
75
|
+
viewFailed: ["This part failed: see the server log.", "Cette partie a échoué : voir le journal du serveur."],
|
|
76
|
+
// Adventure log entries.
|
|
77
|
+
combat: ["combat", "combat"], roleplay: ["roleplay", "interprétation"], exploration: ["exploration", "exploration"],
|
|
78
|
+
quest: ["quest", "quête"], character: ["character", "personnage"], world: ["world", "monde"],
|
|
79
|
+
// The world, on the backstage.
|
|
80
|
+
locations: ["Locations", "Lieux"], noLocation: ["No location yet.", "Aucun lieu."],
|
|
81
|
+
partyHere: ["the party is here", "le groupe est ici"], population: ["Population", "Population"],
|
|
82
|
+
government: ["Government", "Gouvernement"], notable: ["Notable", "À remarquer"], leadsTo: ["Leads to", "Mène à"],
|
|
83
|
+
ties: ["Ties", "Liens"], inFight: ["fighting", "en combat"],
|
|
84
|
+
// Sizes ("medium" is shared with the difficulty ratings below).
|
|
85
|
+
tiny: ["tiny", "très petite"], small: ["small", "petite"],
|
|
86
|
+
large: ["large", "grande"], huge: ["huge", "très grande"], gargantuan: ["gargantuan", "gigantesque"],
|
|
87
|
+
// The desk, on the backstage.
|
|
88
|
+
activity: ["Model activity", "Activité du MJ"], noCall: ["No call yet.", "Aucun appel pour l'instant."],
|
|
89
|
+
failures: ["{n} failed", "{n} en échec"], notebook: ["Notebook", "Carnet"],
|
|
90
|
+
situation: ["Situation", "Situation"], worldNotes: ["World notes", "Notes du monde"],
|
|
91
|
+
setting: ["Setting", "Univers"], show: ["Show", "Afficher"], sessions: ["Past sessions", "Sessions passées"],
|
|
92
|
+
noNotes: ["No notes yet.", "Pas encore de notes."], dm: ["GM", "MJ"], saved: ["saved", "enregistré"],
|
|
93
|
+
skills: ["skills", "skills"],
|
|
94
|
+
lastFight: ["Last fight ({rounds} rounds):", "Dernier combat ({rounds} rounds) :"], treasure: ["Treasure", "Butin"],
|
|
95
|
+
equipped: ["Equipped", "Équipé"], stats: ["Statistics", "Statistiques"], done: ["done", "terminée"],
|
|
96
|
+
// Backstage.
|
|
97
|
+
backstage: ["Backstage", "Régie"], encounters: ["Planned encounters", "Rencontres prévues"],
|
|
98
|
+
noEncounter: ["Nothing planned.", "Rien de prévu."], npcs: ["NPCs", "PNJ"], noNpc: ["No NPC yet.", "Aucun PNJ."],
|
|
99
|
+
allQuests: ["Quests", "Quêtes"], bestiary: ["Bestiary", "Bestiaire"], noMonster: ["Empty.", "Vide."],
|
|
100
|
+
notes: ["Notes", "Notes"], reward: ["Reward", "Récompense"], secret: ["Secret", "Secret"],
|
|
101
|
+
planned: ["planned", "prévue"], running: ["running", "en cours"],
|
|
102
|
+
trivial: ["trivial", "triviale"], easy: ["easy", "facile"], medium: ["medium", "moyenne"],
|
|
103
|
+
hard: ["hard", "difficile"], deadly: ["deadly", "mortelle"],
|
|
104
|
+
friendly: ["friendly", "amical"], neutral: ["neutral", "neutre"], hostile: ["hostile", "hostile"],
|
|
105
|
+
active: ["active", "en cours"], completed: ["completed", "accomplie"], failed: ["failed", "échouée"],
|
|
106
|
+
on_hold: ["on hold", "en pause"],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function t(key, values) {
|
|
110
|
+
const text = (WORDS[key] ? WORDS[key][FRENCH ? 1 : 0] : String(key)).replace(/\{(\w+)\}/g, (_, name) =>
|
|
111
|
+
values && values[name] !== undefined ? values[name] : "");
|
|
112
|
+
// French elision: "Tour de Élodie" -> "Tour d'Élodie".
|
|
113
|
+
return FRENCH ? text.replace(/\bde ([aeiouyàâéèêëîïôûü])/gi, "d'$1") : text;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A distance as the players' books give it: feet, or metres in French (5 ft = 1.5 m). */
|
|
117
|
+
const distance = (feet) => (FRENCH ? `${(feet * 0.3).toLocaleString("fr")} m` : `${feet} ft`);
|
|
118
|
+
|
|
119
|
+
return { t, distance, FRENCH };
|
|
120
|
+
})();
|
dndkit/ui/world.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* The world as the game master knows it, for the backstage: NPCs with their
|
|
2
|
+
* secrets, the places (the party's own marked), and the bestiary's stat
|
|
3
|
+
* blocks. Each entry opens on its details. */
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
import { GM } from "./shared.js";
|
|
7
|
+
|
|
8
|
+
export function World() {
|
|
9
|
+
const { h, fill, t } = GM;
|
|
10
|
+
const empty = (key) => h("div", { class: "empty small" }, t(key));
|
|
11
|
+
const lists = { npcs: h("div"), locations: h("div"), monsters: h("div") };
|
|
12
|
+
const card = (title, list) => h("div", { class: "card world" },
|
|
13
|
+
h("div", { class: "head" }, h("span", { class: "title" }, title)), list);
|
|
14
|
+
const el = h("div", { class: "worldgrid" },
|
|
15
|
+
card(t("npcs"), lists.npcs), card(t("locations"), lists.locations), card(t("bestiary"), lists.monsters));
|
|
16
|
+
const fact = (label, value) => (value || value === 0
|
|
17
|
+
? h("div", { class: "small" }, h("b", {}, `${label} `), Array.isArray(value) ? value.join(", ") : value)
|
|
18
|
+
: null);
|
|
19
|
+
const secret = (label, text) => (text ? h("div", { class: "note small" }, h("b", {}, `${label} `), text) : null);
|
|
20
|
+
const line = (...parts) => parts.filter(Boolean).join(" · ");
|
|
21
|
+
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
|
22
|
+
|
|
23
|
+
const entry = (summary, ...body) => h("details", { class: "entry" }, h("summary", {}, ...summary), ...body);
|
|
24
|
+
|
|
25
|
+
const npc = (n) => entry(
|
|
26
|
+
[h("b", {}, n.name), n.attitude ? h("span", { class: `chip ${n.attitude}` }, t(n.attitude)) : null,
|
|
27
|
+
h("span", { class: "muted small" }, line(n.race, n.occupation, n.location))],
|
|
28
|
+
n.description ? h("p", { class: "small" }, n.description) : null,
|
|
29
|
+
fact(t("ties"), n.ties.length ? n.ties : null), fact(t("stats"), n.stats.length ? n.stats : null),
|
|
30
|
+
secret(`🔒 ${t("secret")}`, n.bio), secret(t("notes"), n.notes));
|
|
31
|
+
|
|
32
|
+
const place = (p) => entry(
|
|
33
|
+
[h("b", {}, p.name), h("span", { class: "muted small" }, p.type),
|
|
34
|
+
p.current ? h("span", { class: "chip here" }, `📍 ${t("partyHere")}`) : null],
|
|
35
|
+
p.description ? h("p", { class: "small" }, p.description) : null,
|
|
36
|
+
fact(t("population"), p.population), fact(t("government"), p.government),
|
|
37
|
+
fact(t("notable"), p.features.length ? p.features : null), fact(t("npcs"), p.npcs.length ? p.npcs : null),
|
|
38
|
+
fact(t("leadsTo"), p.connections.length ? p.connections : null), secret(t("notes"), p.notes));
|
|
39
|
+
|
|
40
|
+
const monster = (m) => entry(
|
|
41
|
+
[m.boss ? h("span", { class: "crown" }, "♛") : null, h("b", {}, m.name),
|
|
42
|
+
h("span", { class: "muted small" }, line(`${t("ac")} ${m.ac}`, `${t("hp")} ${m.hp}`, m.cr ? `CR ${m.cr}` : "")),
|
|
43
|
+
m.fighting ? h("span", { class: "chip warn" }, `${m.fighting} ${t("inFight")}`) : null],
|
|
44
|
+
h("div", { class: "muted small" }, line(t(m.size), m.speed, `${t("initiative")} ${signed(m.initiative)}`, `${m.xp} ${t("xp")}`)),
|
|
45
|
+
m.legendary[0] || m.legendary[1] ? h("div", { class: "small" },
|
|
46
|
+
`${t("legendaryActions")} ${m.legendary[0]} · ${t("legendaryResistances")} ${m.legendary[1]}`) : null,
|
|
47
|
+
m.actions ? h("p", { class: "small actions" }, m.actions) : null, secret(t("notes"), m.notes));
|
|
48
|
+
|
|
49
|
+
function render(data) {
|
|
50
|
+
fill(lists.npcs, data.npcs.length ? data.npcs.map(npc) : empty("noNpc"));
|
|
51
|
+
fill(lists.locations, data.locations.length ? data.locations.map(place) : empty("noLocation"));
|
|
52
|
+
fill(lists.monsters, data.monsters.length ? data.monsters.map(monster) : empty("noMonster"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { el, render };
|
|
56
|
+
}
|
dndkit/views/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""JSON views of the game for screens: the MCP widgets and the web pages.
|
|
2
|
+
|
|
3
|
+
``everyone`` (dice, party, seats, a sheet) and ``public`` (the fight, the
|
|
4
|
+
journal) are what the players may see, field by field; ``widgets`` (the fight
|
|
5
|
+
in full), ``backstage`` (plans), ``world`` (NPCs, places, bestiary) and
|
|
6
|
+
``desk`` (activity, notebook) add what only the game master knows.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The game master's plans: encounters still to fight and quests, notes included."""
|
|
2
|
+
|
|
3
|
+
from dndkit.errors import GameError
|
|
4
|
+
from dndkit.game import bestiary
|
|
5
|
+
from dndkit.models import Campaign, Encounter, Quest
|
|
6
|
+
from dndkit.views.everyone import Json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def backstage(campaign: Campaign) -> Json:
|
|
10
|
+
"""The plans: every encounter (running, planned, then done) and quest, notes included."""
|
|
11
|
+
order = {"running": 0, "planned": 1, "done": 2}
|
|
12
|
+
encounters = sorted(campaign.encounters.values(), key=lambda e: order[e.status])
|
|
13
|
+
return {
|
|
14
|
+
"encounters": [_encounter(campaign, e) for e in encounters],
|
|
15
|
+
"quests": [_quest(q) for q in campaign.quests.values()],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _encounter(campaign: Campaign, encounter: Encounter) -> Json:
|
|
20
|
+
try:
|
|
21
|
+
rated = bestiary.difficulty(campaign, encounter)
|
|
22
|
+
except GameError: # a monster since removed from the bestiary
|
|
23
|
+
rated = None
|
|
24
|
+
return {
|
|
25
|
+
"name": encounter.name,
|
|
26
|
+
"status": encounter.status,
|
|
27
|
+
"location": encounter.location,
|
|
28
|
+
"description": encounter.description,
|
|
29
|
+
"notes": encounter.notes,
|
|
30
|
+
"monsters": [f"{g.count}× {g.monster}" for g in encounter.monsters],
|
|
31
|
+
"difficulty": rated.rating if rated else None,
|
|
32
|
+
"xp": rated.adjusted_xp if rated else None,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _quest(quest: Quest) -> Json:
|
|
37
|
+
return {
|
|
38
|
+
"title": quest.title,
|
|
39
|
+
"status": quest.status,
|
|
40
|
+
"giver": quest.giver,
|
|
41
|
+
"description": quest.description,
|
|
42
|
+
"objectives": [
|
|
43
|
+
{"text": text, "done": text in quest.completed_objectives} for text in quest.objectives
|
|
44
|
+
],
|
|
45
|
+
"done": len(quest.completed_objectives),
|
|
46
|
+
"total": len(quest.objectives),
|
|
47
|
+
"reward": quest.reward,
|
|
48
|
+
"notes": quest.notes,
|
|
49
|
+
}
|