A character sheet pane beside the transcript, fed by the Tomb of Annihilation rules connector's sheet_read and sheet_write results

Your character sheet beside the chat in Claude Code: HP, AC, spell save DCs, spell slots, and a turn menu of your weapons and spells. Type /sheet to open it.
claude in a terminal. It does not work in claude.ai chat, the desktop app's Chat tab or the phone. Please install the Tomb of Annihilation sheet pane for me. Merge these two entries
into my user settings file, ~/.claude/settings.json, without changing anything else
in it (create the file if it does not exist):
"extraKnownMarketplaces": { "toa": { "source": { "source": "github", "repo": "NullDefault/toa-sheet" }, "autoUpdate": true } }
"enabledPlugins": { "toa-sheet@toa": true }
Then tell me to quit Claude completely and open it again.
That's it. You never do this again.
show my sheet. The pane opens with your sheet./sheet in any session, any day. It shows the last sheet Claude read on this computer./sheet saves, /sheet gear, /sheet notes. The tabs are Actions, Saves, Features, Gear and Notes.They arrive by themselves when you open Claude.
/sheet isn't there claude plugin marketplace add NullDefault/toa-sheet
claude plugin install toa-sheet@toa
Plugins like this one run with your permissions, so here is what this one does. It draws the pane, adds /sheet, and keeps your sheet in Claude Code's own plugin storage so the pane works in the next session. It reads only the answers from the rules connector you set up. It never touches your files, starts programs or goes on the internet. Every line is in plugins/toa-sheet/hooks.
With auto-update on, whatever your DM publishes here reaches you the next time you open Claude.
To remove it, ask Claude to delete those two entries from ~/.claude/settings.json, then restart.
hooks/register.ts 779 lines1// toa-sheet: a character sheet pane beside the transcript (handoff 17, step A),
2// and the DM's party view in the same pane (`/party`, handoff 18 step one).
3//
4// It watches the rules connector's own sheet_read / sheet_write results and the
5// vault's Table-log writes and session manifests, and draws what the servers
6// sent. It calls a tool itself only on a press: `e: re-read` on the party view,
7// or `n` or `t` when it starts a new round, to re-read; `f: end` on the party view, to
8// log the fight; and the taps on the sheet view (`d`, `h` and their number
9// field, a slot's digit, `u`), each a `sheet_write` at the pane's version. Each
10// is `$.tool.call` with the press as `consent`, checked for permission like the
11// model's own calls (README, "Re-read and permissions").
12//
13// It also draws the connector's rows in the transcript as receipts (receipt.ts),
14// one line each, and flashes what a write changed in the pane.
15//
16// The party view's keys are the DM's fight (fight.ts): a digit picks a row, a
17// letter acts on it, and each applied op is stored; the fight drawn is their fold.
18//
19// `c: packs` reads the DM's combat packs from Prep/Fights.md, the one file it reads.
20//
21// Mods API calls: $.ui, $.store, $.command, $.clock, $.tool, $.fs.read only. Every call
22// lives in this file; sheet.ts, table.ts, look.ts, receipt.ts and view.ts are pure.
23
24import type { EngineInterface, MatchedHook, On, RenderElement, ToolCallArgs } from 'claude-code'
25import { fold, live, parsePacks, summary, undoTarget, type Event, type Fight, type Op, type Pack } from './fight.ts'
26import { FLASH_MS } from './look.ts'
27import { CONNECTOR, VAULT, receipt } from './receipt.ts'
28import { asApplied, asCheck, asGet, entryAfterWrite, entryFromGet, matchEntry, payloadOf, type Entry } from './sheet.ts'
29import { applyWrite, asManifest, asTableWrite, isSessionId, rosterRows, type TableLog } from './table.ts'
30import { bandTree, detailTree, hpOf, paneTree, receiptTree, RULES_KEY, slotRows, TABS, tabKey, tabOf, type Adding, type Ask, type Reread, type TabId, type Tap } from './view.ts'
31
32const PANE = 'toa-sheet'
33const KEY = 'sheet:' // one $.store key per character, so sessions never overwrite each other's characters
34const LAST = 'last'
35const TABLE = 'table:' // one $.store key per session's log
36const SESSION = 'session'
37const FIGHT = 'party:fight' // the current fight's id; its ops are under FIGHT_LOG + id
38const FIGHT_LOG = 'party:log:' // one DM, one session: the last writer wins
39const VAULT_QUERY = 'mcp__vault__vault_query'
40const FIGHTS = 'Prep/Fights.md' // the DM's combat packs, relative to the session's folder (the vault)
41const DRAFT_WRITE: string = 'mcp__vault__draft_write' // typed wide: the vault is no tool the declarations know
42/** The person's own words for the press that raises the mod's call; the engine strips it before any tool sees it. */
43const consent = (label: string, pane: string) => `The user pressed "${label}" on the ${pane} pane`
44
45// Module state. A reload clears it; session.start refills the sheets from $.store.
46const sheets = new Map<string, Entry>()
47let focus: string | null = null
48let tab: TabId = 'actions'
49// The pane's Buttons from each surface's last drawing, handed back unchanged so
50// their press handles survive a redraw (view.ts, PaneView.memo).
51const memos = new Map<string, Map<string, RenderElement>>()
52let paneOpen = false
53let dismissed = false // the user closed the pane: do not open it unasked again this session
54let autoTried = false
55let toasted = false
56let seenHere = false // a sheet result arrived in this session (the band strip waits for one)
57// The last applied write's paths, drawn inverse in the pane until `until` (never stored).
58let flash: { id: string; paths: string[]; until: number } | null = null
59let view: 'sheet' | 'party' = 'sheet'
60let session: string | null = null // the session the party view follows: `/party S04`, or the Table operation's last write
61const logs = new Map<string, TableLog>()
62let reread: Reread | null = null // the last re-read's outcome, never stored
63let rereading = false
64let fightEvents: Event[] = [] // the current fight's ops and undos, as stored
65let fightId: string | null = null
66// The DM's hand on the party view, never stored: the picked row, the open number
67// field or add form.
68let sel: { row: string; member: number } | null = null
69let ask: Ask | null = null
70let adding: Adding | null = null
71// The note line under the view on show; a view switch clears it.
72let note: string | null = null
73let packs: Pack[] | null = null // the pack list `c` opened, read fresh each time
74// The sheet view's taps, never stored: the open number field, and the last tap
75// written on this machine (the version it wrote over, `from`, and the one it wrote, `to`), for `u`.
76let tap: Tap | null = null
77let lastTap: { id: string; from: number; to: number } | null = null
78
79// The receipt hook for both tool families; the mods loader takes hooks only from the top level.
80const rowHook: MatchedHook<'ui.render', { component: 'ToolUse' }> = ($, e, next) => {
81 const r = receipt(e.props, (id) => sheets.get(id)?.sheet)
82 return r ? receiptTree($.ui.resolve(e), r) : next(e)
83}
84
85const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
86const focused = () => (focus ? sheets.get(focus) ?? null : null)
87const firstLine = (s: unknown) => String(s).split('\n')[0]!.slice(0, 120)
88
89function storedEntry(v: unknown): Entry | null {
90 if (!v || typeof v !== 'object') return null
91 const e = v as Entry
92 if (typeof e.id !== 'string' || typeof e.name !== 'string' || !Number.isInteger(e.version) || !e.sheet || typeof e.sheet !== 'object') return null
93 return { ...e, fromStore: true }
94}
95
96function storedLog(v: unknown): TableLog | null {
97 if (!v || typeof v !== 'object') return null
98 const l = v as TableLog
99 if (typeof l.session !== 'string' || !Array.isArray(l.entries)) return null
100 if (!l.entries.every((t) => t && typeof t.at === 'number' && typeof t.text === 'string')) return null
101 if (l.closedAt !== null && typeof l.closedAt !== 'number') return null
102 if (l.manifest !== null && (!l.manifest || typeof l.manifest !== 'object')) return null
103 return l
104}
105
106const int = (v: unknown) => Number.isInteger(v)
107const intOrNull = (v: unknown) => v === null || Number.isInteger(v)
108const str = (v: unknown) => typeof v === 'string'
109
110function storedOp(o: any): boolean {
111 if (!o || typeof o !== 'object') return false
112 switch (o.op) {
113 case 'add': return str(o.name) && int(o.count) && intOrNull(o.hp) && intOrNull(o.ac) && intOrNull(o.init)
114 case 'pack': return str(o.name) && Array.isArray(o.rows) && o.rows.every((r: any) => r?.op === 'add' && storedOp(r))
115 case 'init': return str(o.row) && str(o.name) && int(o.value)
116 case 'hp': return str(o.row) && int(o.member) && int(o.delta)
117 case 'kill': return str(o.row) && int(o.member)
118 case 'out': case 'point': return str(o.row)
119 case 'down': return str(o.row) && str(o.name) && typeof o.value === 'boolean'
120 case 'next': case 'end': return true
121 default: return false
122 }
123}
124
125/** A stored fight log, or null: anything else (T0055's lines among it) starts the fight empty. */
126function storedEvents(v: unknown): Event[] | null {
127 if (!Array.isArray(v)) return null
128 const ok = v.every((e) => e && typeof e === 'object' && typeof e.at === 'number' && (int(e.undo) || storedOp(e.op)))
129 return ok ? (v as Event[]) : null
130}
131
132async function loadStore($: EngineInterface) {
133 const keys: string[] = (await $.store.keys()) ?? []
134 for (const k of keys) {
135 if (k.startsWith(KEY)) {
136 const e = storedEntry(await $.store.get(k))
137 if (e && !sheets.has(e.id)) sheets.set(e.id, e)
138 } else if (k.startsWith(TABLE)) {
139 const l = storedLog(await $.store.get(k))
140 if (l && !logs.has(l.session)) logs.set(l.session, l)
141 }
142 }
143 const last = await $.store.get(LAST)
144 if (!focus && typeof last === 'string' && sheets.has(last)) focus = last
145 const stored = await $.store.get(SESSION)
146 if (!session && typeof stored === 'string') session = stored
147 const id = await $.store.get(FIGHT)
148 if (!fightId && typeof id === 'string') {
149 const events = storedEvents(await $.store.get(FIGHT_LOG + id)) ?? []
150 // Only a live fight comes back: `u` undoes an end within the sitting, never an old fight's.
151 if (live(fold(events))) {
152 fightId = id
153 fightEvents = events
154 }
155 }
156}
157
158const fight = (): Fight | null => fold(fightEvents)
159const nameOf = (id: string) => live(fight())?.rows.find((r) => r.id === id)?.name ?? sheets.get(id)?.name ?? id
160
161/** Put the keyboard on `key`; an element never drawn (no Input on mobile, the pane closed) answers deny, and the hotkeys still work. */
162async function focusOn($: EngineInterface, key: string) {
163 try {
164 await $.ui.focus({ requestId: PANE, key })
165 } catch {
166 // not drawn here
167 }
168}
169
170/** Park the ring on `next`, a Button, so the hotkeys work again: the way back from a field. */
171const park = ($: EngineInterface) => focusOn($, 'next')
172
173/**
174 * One op pressed (or `undo`): stored and drawn. An op that starts a new fight
175 * starts a new log; the old one stays in the store.
176 */
177async function apply($: EngineInterface, op: Op | 'undo') {
178 const at = await $.clock.now()
179 let event: Event
180 if (op === 'undo') {
181 const target = undoTarget(fightEvents)
182 if (target === null) {
183 note = 'nothing to undo'
184 return $.ui.invalidate('ui.render')
185 }
186 event = { at, undo: target }
187 } else event = { at, op }
188 fightEvents = [...fightEvents, event]
189 const f = fight()
190 if (f && f.id !== fightId) {
191 fightEvents = [event]
192 fightId = f.id
193 await $.store.set(FIGHT, fightId)
194 }
195 if (fightId) await $.store.set(FIGHT_LOG + fightId, fightEvents)
196 const row = sel && live(f)?.rows.find((r) => r.id === sel!.row)
197 sel = row && sel ? { row: row.id, member: Math.max(0, Math.min(sel.member, row.members.length - 1)) } : null
198 note = null
199 ask = null
200 $.ui.invalidate('ui.render')
201 await park($)
202}
203
204/**
205 * `n` or `t`: apply the op, and when it starts a new round re-read the sheets, so HP the
206 * players wrote shows. Awaited, as `e`'s is: the mod's own watcher sees only the calls made
207 * while the press is in flight.
208 */
209async function turn($: EngineInterface, op: Op, label: string) {
210 const was = fight()?.round ?? 0
211 await apply($, op)
212 if ((fight()?.round ?? 0) > was) await refresh($, consent(label, 'Party') + ', which starts a new round and re-reads')
213}
214
215/** The note line under the party's order or the sheet's taps. */
216const say = ($: EngineInterface, text: string) => {
217 note = text
218 $.ui.invalidate('ui.render')
219}
220
221/**
222 * The ended fight `f` (its fold before the end) as one Table note, through
223 * `draft_write`. Run after the end is applied and never awaited, so the press
224 * never waits on a permission dialog; a line not logged says why in the note.
225 */
226async function logFight($: EngineInterface, f: Fight) {
227 const not = (why: string) => say($, 'fight ended; not logged: ' + why)
228 const text = summary(f)
229 if (!text) return not('nothing tracked')
230 const s = session
231 if (!s) return not('no session (/party S04)')
232 let why: string
233 try {
234 if (!(await $.tool.list()).some((t) => t.name === DRAFT_WRITE)) return not('no vault connector')
235 // Top-level arguments, as draft_write takes them; the watcher sees the call and logs the line.
236 const r = await $.tool.call({ tool: DRAFT_WRITE, kind: 'table-note', session: s, text, consent: consent('f: end fight', 'Party') } as ToolCallArgs)
237 if (!('deny' in r) && !r.isError) return
238 why = firstLine('deny' in r ? r.deny : r.text)
239 } catch (err) {
240 why = firstLine(message(err))
241 }
242 $.ui.log('fight: draft_write refused: ' + why)
243 not(why)
244}
245
246/** A key on the party view: a digit picks a row, a letter acts on the picked one. */
247async function act($: EngineInterface, key: string) {
248 ask = null
249 const f = live(fight())
250 const row = sel ? f?.rows.find((r) => r.id === sel!.row) : undefined
251 if (key.startsWith('row-')) {
252 const id = key.slice(4)
253 sel = sel?.row === id && row && row.members.length > 1 ? { row: id, member: (sel.member + 1) % row.members.length } : { row: id, member: 0 }
254 return $.ui.invalidate('ui.render')
255 }
256 if (key === 'act-c') return openPacks($)
257 if (key.startsWith('pack-')) {
258 const p = packs?.[Number(key.slice(5)) - 1]
259 if (!p) return
260 packs = null
261 return apply($, { op: 'pack', name: p.name, rows: p.rows })
262 }
263 if (key === 'act-a') {
264 adding = adding ? null : { name: '', count: '', hp: '', ac: '', init: '' }
265 $.ui.invalidate('ui.render')
266 return adding ? focusOn($, 'add-name') : undefined
267 }
268 if (key === 'act-f') {
269 if (!f) return
270 await apply($, { op: 'end' })
271 return void logFight($, f)
272 }
273 if (key === 'act-s') {
274 if (!sel) return say($, 'pick a row: 1–9')
275 if (!sheets.has(sel.row)) return say($, 'no sheet')
276 focus = sel.row
277 view = 'sheet'
278 note = null
279 $.ui.invalidate('ui.render')
280 return void (await $.ui.open({ id: PANE, title: focused()!.name }))
281 }
282 if (!sel) return say($, 'pick a row: 1–9')
283 const { row: id, member } = sel
284 if (key === 'act-i') {
285 ask = { kind: 'init', row: id, member, text: '' }
286 $.ui.invalidate('ui.render')
287 return focusOn($, 'amount')
288 }
289 if (!f) return
290 const tracked = !!row && row.members.length > 0
291 if (key === 'act-d' || key === 'act-h') {
292 if (!tracked) return say($, 'no HP tracked here')
293 ask = { kind: key === 'act-d' ? 'damage' : 'heal', row: id, member, text: '' }
294 $.ui.invalidate('ui.render')
295 return focusOn($, 'amount')
296 }
297 if (key === 'act-x') return tracked ? apply($, { op: 'kill', row: id, member }) : say($, 'no HP tracked here')
298 if (key === 'act-o') return row ? apply($, { op: 'out', row: id }) : say($, 'not in the fight')
299 if (key === 'act-k') return apply($, { op: 'down', row: id, name: nameOf(id), value: !row?.down })
300 if (key === 'act-t') return row ? turn($, { op: 'point', row: id }, 't: turn') : say($, 'not in the fight')
301}
302
303/** `c`: the packs in Prep/Fights.md, read fresh, as a list a digit loads from; `c` again closes it. */
304async function openPacks($: EngineInterface) {
305 if (packs) {
306 packs = null
307 return $.ui.invalidate('ui.render')
308 }
309 let text: string
310 try {
311 text = await $.fs.read(FIGHTS)
312 } catch {
313 return say($, 'no ' + FIGHTS)
314 }
315 const read = parsePacks(text)
316 packs = read.packs.length ? read.packs : null
317 // A pack that does not read is left out of the list; the note names its line.
318 note = read.errors[0] ?? (packs ? null : 'no packs in ' + FIGHTS)
319 $.ui.invalidate('ui.render')
320}
321
322const ADD_FIELDS = ['name', 'count', 'hp', 'ac', 'init'] as const
323/** A numeric add field: an integer, or null when blank (or not a number). */
324const num = (t: string) => (/^\d+$/.test(t.trim()) ? Number(t.trim()) : null)
325
326/** The number field and the add form: a change is kept for the redraw; a submit applies or moves on. */
327async function input($: EngineInterface, element: string, kind: 'change' | 'submit', value: string) {
328 if (element === 'tap-amount' && tap) {
329 if (kind === 'change') return void (tap = { ...tap, text: value })
330 const t = value.trim()
331 if (!t) {
332 tap = null
333 $.ui.invalidate('ui.render')
334 return focusOn($, 'tap-d')
335 }
336 const n = num(t)
337 tap = { ...tap, text: value }
338 if (n === null) {
339 note = 'a number'
340 return $.ui.invalidate('ui.render')
341 }
342 return hp($, n, value)
343 }
344 if (element === 'amount' && ask) {
345 if (kind === 'change') return void (ask = { ...ask, text: value })
346 const t = value.trim()
347 if (!t) {
348 ask = null
349 $.ui.invalidate('ui.render')
350 return park($)
351 }
352 const n = num(t)
353 if (n === null) {
354 ask = { ...ask, text: value }
355 note = 'a number'
356 return $.ui.invalidate('ui.render')
357 }
358 const { kind: what, row, member } = ask
359 return apply($, what === 'init' ? { op: 'init', row, name: nameOf(row), value: n } : { op: 'hp', row, member, delta: what === 'damage' ? -n : n })
360 }
361 const field = ADD_FIELDS.find((k) => element === 'add-' + k)
362 if (!field || !adding) return
363 adding = { ...adding, [field]: value }
364 if (kind === 'change') return
365 if (field !== 'init') return focusOn($, 'add-' + ADD_FIELDS[ADD_FIELDS.indexOf(field) + 1])
366 const name = adding.name.trim()
367 if (!name) {
368 note = 'a name'
369 $.ui.invalidate('ui.render')
370 return focusOn($, 'add-name')
371 }
372 const op: Op = { op: 'add', name: name.charAt(0).toUpperCase() + name.slice(1), count: Math.max(1, num(adding.count) ?? 1), hp: num(adding.hp), ac: num(adding.ac), init: num(adding.init) }
373 adding = null
374 await apply($, op)
375 const added = fight()?.rows.find((r) => r.added === fightEvents.length - 1)
376 if (added) sel = { row: added.id, member: 0 }
377 $.ui.invalidate('ui.render')
378}
379
380/** A tap key on the sheet view: `d`/`h` open the number field, `u` undoes the last tap, a slot's digit spends one. */
381async function tapKey($: EngineInterface, key: string) {
382 const entry = focused()
383 if (!entry) return
384 if (key === 'tap-d' || key === 'tap-h') {
385 tap = { kind: key === 'tap-d' ? 'damage' : 'heal', text: '' }
386 $.ui.invalidate('ui.render')
387 return focusOn($, 'tap-amount')
388 }
389 if (key === 'tap-u') {
390 if (!lastTap || lastTap.id !== entry.id) return say($, 'nothing to undo')
391 if (entry.version !== lastTap.to) return say($, 'the sheet changed since: undo in the chat')
392 return write($, entry, { action: 'revert', changes: { version: lastTap.from } }, 'sheet pane: undo', consent('u: undo', 'Sheet'))
393 }
394 const slot = slotRows(entry).find((s) => s.key === key)
395 if (!slot) return
396 const pact = key === 'slot-pact'
397 const name = pact ? 'pact' : slot.label
398 if (slot.left <= 0) return say($, `no ${name} slots left`)
399 const spell = entry.sheet.spellcasting ?? {}
400 const used = Number(pact ? spell.pact_slots_used ?? 0 : spell.slots_used?.[String(slot.level)] ?? 0)
401 const changes = pact ? { spellcasting: { pact_slots_used: used + 1 } } : { spellcasting: { slots_used: { [String(slot.level)]: used + 1 } } }
402 return write($, entry, { action: 'update', changes }, `sheet pane: ${name} slot`, consent(`${slot.hotkey}: ${slot.label}`, 'Sheet'))
403}
404
405/** The number field's `n`: damage spends temp HP first, then HP down to 0; healing raises HP up to the max the pane draws. */
406async function hp($: EngineInterface, n: number, typed: string) {
407 const entry = focused()
408 if (!entry || !tap) return
409 const { current, max, temp } = hpOf(entry)
410 const damage = tap.kind === 'damage'
411 if (n === 0 || (damage && current <= 0 && temp <= 0) || (!damage && current >= max)) return say($, 'nothing to change')
412 const t = damage ? Math.min(temp, n) : 0
413 const changes = damage
414 ? { hp: { current: Math.max(0, current - (n - t)), ...(t ? { temp: temp - t } : {}) } }
415 : { hp: { current: Math.min(max, current + n) } }
416 const said = `The user entered "${typed.trim()}" in "${tap.kind} · ${entry.name}" on the Sheet pane and pressed apply`
417 return write($, entry, { action: 'update', changes }, `sheet pane: ${damage ? '−' : '+'}${n} HP`, said)
418}
419
420/**
421 * One tap's `sheet_write`, applied at once at the pane's version: the
422 * connector's `base_version` guard is the stale check. The watcher caches and
423 * flashes the result; a refusal or an error is the note, and the field stays.
424 */
425async function write($: EngineInterface, entry: Entry, body: Record<string, unknown>, reason: string, said: string) {
426 const tool = 'mcp__' + entry.server + '__sheet_write'
427 const from = entry.version
428 try {
429 if (!(await $.tool.list()).some((t) => t.name === tool)) return say($, 'no sheet_write tool in this session')
430 // The name is built at run time, so it is no literal the declarations know.
431 const r = await $.tool.call({ tool, request: { ...body, character: entry.id, base_version: from, reason }, apply: true, consent: said } as ToolCallArgs)
432 if ('deny' in r) {
433 $.ui.log(`tap: ${tool} refused: ` + firstLine(r.deny))
434 return say($, `refused: allow ${tool} (/permissions)`)
435 }
436 if (r.isError) {
437 if (!/sheet changed/i.test(String(r.text))) return say($, firstLine(r.text))
438 // The watcher re-caches the sheet, so the next Enter recomputes at its version.
439 const read = 'mcp__' + entry.server + '__sheet_read'
440 await $.tool.call({ tool: read, request: { action: 'get', character: entry.id }, consent: said } as ToolCallArgs)
441 return say($, 'the sheet changed · try again')
442 }
443 const p = payloadOf(r)
444 const wrote = p && 'data' in p ? asApplied(p.data) : null
445 lastTap = body.action !== 'revert' && wrote ? { id: entry.id, from, to: wrote.version } : null
446 } catch (err) {
447 return say($, firstLine(message(err)))
448 }
449 tap = null
450 note = null
451 $.ui.invalidate('ui.render')
452 await focusOn($, 'tap-d')
453}
454
455async function saveLog($: EngineInterface, log: TableLog) {
456 logs.set(log.session, log)
457 $.ui.invalidate('ui.render')
458 await $.store.set(TABLE + log.session, log)
459}
460
461/** Keep `entry`, redraw, and save it unless another session already saved a newer version. */
462async function update($: EngineInterface, entry: Entry) {
463 sheets.set(entry.id, entry)
464 $.ui.invalidate('ui.render')
465 const stored = storedEntry(await $.store.get(KEY + entry.id))
466 if (!stored || stored.version <= entry.version) await $.store.set(KEY + entry.id, { ...entry, fromStore: false })
467}
468
469/** Keep `entry` and show it: focus it, and open the pane (or toast) the first time. */
470async function remember($: EngineInterface, entry: Entry) {
471 // A re-read refreshes the cache only: the sheet on show stays the one the DM picked, even mid-sweep.
472 const move = !rereading || !focus
473 if (move) focus = entry.id
474 seenHere = true
475 await update($, entry)
476 if (move) await $.store.set(LAST, entry.id)
477 if (!paneOpen && !dismissed && !autoTried) {
478 autoTried = true
479 // Unasked, so Claude Code places it only where it fits (144 columns, 110 once opened).
480 // Esc closes it, as `/sheet`'s does: a player who never typed `/sheet` needs a way out.
481 const placed = await $.ui.open({ id: PANE, title: entry.name, closeOnEscape: true })
482 if (placed && placed.isPlaced) paneOpen = true
483 }
484 if (!paneOpen && !toasted) {
485 toasted = true
486 await $.ui.toast(`${entry.name}'s sheet is ready: /sheet`)
487 }
488}
489
490/**
491 * A watched result: a get replaces the cached sheet; a check sets its counts
492 * on a sheet already known (a check carries no sheet); an applied write's diff
493 * updates it and flashes what it changed.
494 */
495async function watch($: EngineInterface, e: any, result: unknown) {
496 const m = /^mcp__(.+)__sheet_(read|write)$/.exec(String(e.tool))
497 const p = payloadOf(result)
498 if (!m || !p || !('data' in p)) return
499 const now = await $.clock.now()
500 if (m[2] === 'read') {
501 const got = asGet(p.data)
502 if (got) return remember($, entryFromGet(got, m[1] ?? '', now, sheets.get(got.character.id)?.check))
503 const check = asCheck(p.data)
504 const known = check ? sheets.get(check.character.id) : undefined
505 if (!check || !known) return
506 const warnings = check.findings.filter((f) => f.severity === 'warning').length
507 const counts = { at: now, version: check.character.version, warnings, infos: check.findings.length - warnings }
508 return update($, { ...known, check: counts })
509 }
510 const wrote = asApplied(p.data)
511 const old = wrote ? sheets.get(wrote.character) : undefined
512 if (!wrote || !old) return
513 const request = e.request ?? e.input?.request ?? {}
514 flash = { id: old.id, paths: wrote.diff.map((d) => d.path), until: now + FLASH_MS }
515 $.clock.after(FLASH_MS, () => $.ui.invalidate('ui.render'))
516 await remember($, entryAfterWrite(old, wrote, typeof request.reason === 'string' ? request.reason : '', now))
517}
518
519/**
520 * A watched vault result. A manifest is attached to its own session's log and
521 * never moves `session` (Ingest and Query read other sessions); a Table write
522 * is applied to its session's log, and that session is the one followed.
523 */
524async function watchVault($: EngineInterface, e: any, result: unknown) {
525 const p = payloadOf(result)
526 if (!p || !('data' in p)) return
527 if (e.tool === VAULT_QUERY) {
528 const m = (e.request ?? {}).action === 'session' ? asManifest(p.data) : null
529 if (m) await saveLog($, { ...(logs.get(m.session) ?? { session: m.session, entries: [], closedAt: null }), manifest: m })
530 return
531 }
532 const w = asTableWrite(e, p.data)
533 if (!w) return
534 await saveLog($, applyWrite(logs.get(w.session) ?? null, w, await $.clock.now()))
535 session = w.session
536 await $.store.set(SESSION, session)
537}
538
539/**
540 * The re-read, on `e` and on `n` or `t` into a new round, one sweep at a time: the
541 * connector's `list`, then a `get` per id, one at a time (a dialog each in
542 * default mode, never several at once), then the session's manifest. The
543 * watchers cache the answers; this reads only whether each call answered.
544 */
545async function refresh($: EngineInterface, pressed: string) {
546 if (rereading) return
547 rereading = true
548 const call = async (tool: string, request: Record<string, unknown>) => {
549 // The name is found at run time, so it is no literal the declarations know.
550 const r = await $.tool.call({ tool, request, consent: pressed } as ToolCallArgs)
551 if ('deny' in r || r.isError) {
552 $.ui.log(`re-read: ${tool} refused: ` + firstLine('deny' in r ? r.deny : r.text))
553 reread = { refused: tool }
554 return null
555 }
556 return r
557 }
558 try {
559 const names = (await $.tool.list()).map((t) => t.name)
560 if (!names.length) return void (await $.ui.toast('No tools yet: the servers connect after the first turn; press e then'))
561 const known = [...sheets.values()].map((e) => `mcp__${e.server}__sheet_read`).find((n) => names.includes(n))
562 const tool = known ?? names.find((n) => /^mcp__(.+)__sheet_read$/.test(n))
563 if (!tool) return void (await $.ui.toast('No sheet tool in this session: add the rules connector'))
564 const r = await call(tool, { action: 'list' })
565 if (!r) return
566 const p = payloadOf(r)
567 if (!p || !('data' in p) || !Array.isArray(p.data)) return void (reread = { failed: 'list: unexpected answer' })
568 let n = 0
569 for (const c of p.data) {
570 if (!(await call(tool, { action: 'get', character: c?.id }))) return
571 n++
572 }
573 if (session && names.includes(VAULT_QUERY) && !(await call(VAULT_QUERY, { action: 'session', id: session }))) return
574 reread = { at: await $.clock.now(), sheets: n, listed: p.data.length }
575 } catch (err) {
576 reread = { failed: firstLine(message(err)) }
577 } finally {
578 rereading = false
579 $.ui.invalidate('ui.render')
580 }
581}
582
583export function register(on: On) {
584 on('session.start', async ($, e, next) => {
585 await loadStore($)
586 try {
587 await $.command.register({
588 name: 'sheet',
589 description: 'Show a character sheet beside the transcript',
590 argumentHint: '[name] [actions|saves|features|gear|notes]',
591 immediate: true,
592 })
593 } catch (err) {
594 $.ui.log('/sheet was not added: ' + message(err))
595 }
596 try {
597 await $.command.register({
598 name: 'party',
599 description: "The DM's party view: every sheet, the table log, the session",
600 argumentHint: '[S04]',
601 immediate: true,
602 })
603 } catch (err) {
604 $.ui.log('/party was not added: ' + message(err))
605 }
606 return next(e)
607 })
608
609 // The connector's sheet tools under any server name (a claude.ai uuid, or the name it was added with).
610 on('tool.call', { tool: /^mcp__.+__sheet_(read|write)$/ }, async ($, e, next) => {
611 const result = await next(e)
612 try {
613 await watch($, e, result)
614 } catch (err) {
615 $.ui.log('a sheet result was not shown: ' + message(err))
616 }
617 return result
618 })
619
620 // The vault's session manifests and the Table operation's writes, for the party view.
621 on('tool.call', { tool: /^mcp__vault__(vault_query|draft_write)$/ }, async ($, e, next) => {
622 const result = await next(e)
623 try {
624 await watchVault($, e, result)
625 } catch (err) {
626 $.ui.log('a vault result was not shown: ' + message(err))
627 }
628 return result
629 })
630
631 // `/sheet [name] [tab]`: a tab word (`saves`, `spells`, `gear`) opens that tab, the rest names a character.
632 on('command.run', { command: 'sheet' }, async ($, e) => {
633 const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
634 const named = words.map(tabOf).find((t) => t !== null)
635 if (named) tab = named
636 const query = words.filter((w) => tabOf(w) === null).join(' ')
637 if (query) {
638 const hit = matchEntry([...sheets.values()], query)
639 if (!hit) {
640 await $.ui.toast(`No sheet for "${query}" on this machine yet. Ask Claude to read it.`)
641 return {}
642 }
643 focus = hit.id
644 }
645 view = 'sheet'
646 note = null
647 dismissed = false
648 await $.ui.open({ id: PANE, title: focused()?.name ?? 'Sheet', focus: true, closeOnEscape: true })
649 paneOpen = true
650 $.ui.invalidate('ui.render')
651 return {}
652 })
653
654 // `/party [S04]`: the party view, drawn from the cache. It calls no tool: only `e`, or `n` or `t` into a new round, re-reads.
655 on('command.run', { command: 'party' }, async ($, e) => {
656 const arg = String(e.args ?? '').trim().toUpperCase()
657 if (isSessionId(arg)) {
658 session = arg
659 await $.store.set(SESSION, session)
660 }
661 view = 'party'
662 note = null
663 dismissed = false
664 await $.ui.open({ id: PANE, title: 'Party', focus: true, columns: 60 })
665 paneOpen = true
666 $.ui.invalidate('ui.render')
667 return {}
668 })
669
670 on('ui.close', async ($, e, next) => {
671 if (e.id === PANE) {
672 paneOpen = false
673 if (e.origin?.kind === 'person') dismissed = true
674 $.ui.invalidate('ui.render')
675 }
676 return next(e)
677 })
678
679 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
680 if (e.requestId !== PANE) return next(e)
681 paneOpen = true // drawn, so open (a waiting pane appears when the terminal widens)
682 const el = $.ui.resolve(e)
683 const memo = memos.get(e.surface) ?? new Map<string, RenderElement>()
684 memos.set(e.surface, memo)
685 const now = await $.clock.now()
686 const flashing = flash && flash.id === focus && now < flash.until ? flash.paths : null
687 return paneTree(el, {
688 view,
689 entry: focused(),
690 rows: rosterRows([...sheets.values()]),
691 log: session ? logs.get(session) ?? null : null,
692 session,
693 reread,
694 surface: e.surface,
695 columns: e.props?.bodyColumns ?? 40,
696 tab,
697 memo,
698 now,
699 flash: flashing,
700 fight: fight(),
701 sel,
702 ask: ask && { ...ask, name: nameOf(ask.row) },
703 adding,
704 note,
705 packs,
706 tap,
707 })
708 })
709
710 // The party view's number field (`amount`) and add form (`add-*`); the sheet view's (`tap-amount`).
711 on('ui.input', { plugin: 'toa-sheet' }, async ($, e, next) => {
712 await input($, String(e.element), e.kind, e.value)
713 return next(e)
714 })
715
716 // Connector and vault calls fold into one "Called … n times" line. The matcher selects only
717 // the groups holding either call (a pattern against an array holds when some element
718 // matches); the rewrite unfolds them, and each call is then a ToolUse row the receipt hook draws.
719 on('ui.render', { component: 'ToolGroup', props: { calls: { tool: CONNECTOR } } }, ($, e, next) =>
720 next({ ...e, props: { ...e.props, isExpanded: true } }))
721 on('ui.render', { component: 'ToolGroup', props: { calls: { tool: VAULT } } }, ($, e, next) =>
722 next({ ...e, props: { ...e.props, isExpanded: true } }))
723 on('ui.render', { component: 'ToolUse', props: { tool: CONNECTOR } }, rowHook)
724 on('ui.render', { component: 'ToolUse', props: { tool: VAULT } }, rowHook)
725 // The result block is drawn in the ctrl+o transcript only (probe finding 8): the findings
726 // in words, then Claude Code's own block, never hidden.
727 on('ui.render', { component: 'ToolResult', props: { tool: CONNECTOR } }, async ($, e, next) => {
728 const r = receipt({ ...e.props, isRunning: false }, (id) => sheets.get(id)?.sheet)
729 return r && r.detail.length ? detailTree($.ui.resolve(e), r, await next(e)) : next(e)
730 })
731
732 // The pane's presses: a tab, the DM-rules badge (which opens Notes), the party
733 // button, the sheet's taps, the fight's keys (a row, an action, next, undo), the re-read. The
734 // Buttons' own closures do nothing, so a kept Button never acts on stale state. `e`, `n`, `t`
735 // and a pack's digit take their press rather than pass it to core: a sweep may outlive its
736 // Button's drawing (`s` mid-sweep shows a sheet without them), a pack's load retires its own
737 // Button, and core's look-up of a retired handle throws.
738 // Other presses still pass: `f`'s unawaited log write must start while the press is in flight.
739 on('ui.press', { plugin: 'toa-sheet' }, async ($, e, next) => {
740 const id = String(e.element)
741 if (id === 'view-party') {
742 view = 'party'
743 note = null
744 $.ui.invalidate('ui.render')
745 await $.ui.open({ id: PANE, title: 'Party', columns: 60 })
746 } else if (id === 'act-t' || id.startsWith('pack-')) {
747 await act($, id)
748 return { element: id }
749 } else if (id.startsWith('row-') || id.startsWith('act-')) {
750 await act($, id)
751 } else if (id.startsWith('tap-') || id.startsWith('slot-')) {
752 await tapKey($, id)
753 } else if (id === 'next' && live(fight())) {
754 await turn($, { op: 'next' }, 'n: next')
755 return { element: id }
756 } else if (id === 'undo') {
757 // Also after `f`: undoing the end brings the fight back.
758 await apply($, 'undo')
759 } else if (id === 'reread') {
760 await refresh($, consent('e: re-read', 'Party'))
761 return { element: id }
762 }
763 const hit = TABS.find((t) => tabKey(t.id) === e.element)?.id ?? (e.element === RULES_KEY ? 'notes' : null)
764 if (hit && hit !== tab) {
765 tab = hit
766 $.ui.invalidate('ui.render')
767 }
768 return next(e)
769 })
770
771 // The band strip while the pane is closed, kept above other mods' band content.
772 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
773 const entry = focused()
774 if (paneOpen || !seenHere || !entry) return next(e)
775 const el = $.ui.resolve(e)
776 return el.Box({ flexDirection: 'column', children: [bandTree(el, entry, e.surface), await next(e)] })
777 })
778}
779hooks/fight.ts 263 lines1// The DM's fight (handoff 18, step A part 1). The store holds the DM's presses
2// (ops); a Fight is their fold. Pure: no mods API. The only arithmetic is the
3// DM's own HP sums and the round count.
4
5export type Side = 'pc' | 'monster'
6/** `working` is the signed changes in order (`22 − 9 = 13`); `zero` is `0?` pending, `dead` is confirmed by `x`. */
7export type Member = { hp: number; max: number; working: number[]; dead: boolean; zero: boolean }
8/** A row with no members has no HP tracked (a PC's is the sheet's); a single monster has one. `added` is its event index; `downs` the rounds `k` put it down in. */
9export type Row = { id: string; side: Side; name: string; init: number | null; ac: number | null; members: Member[]; out: boolean; down: boolean; downs: number[]; added: number }
10/** An op as `describe` renders it; `working` is an HP op's result (`22 − 9 = 13`). */
11export type LogLine = { at: number; text: string; working?: string; reverted?: true }
12export type Fight = { id: string; startedAt: number; rows: Row[]; pointer: string | null; round: number; ended: number | null; log: LogLine[] }
13export type AddOp = { op: 'add'; name: string; count: number; hp: number | null; ac: number | null; init: number | null }
14/** One press. `name` on `init`/`down` is the PC's roster name: its row is made on first mention. A `pack` is a combat pack's adds as one press. */
15export type Op =
16 | AddOp
17 | { op: 'pack'; name: string; rows: AddOp[] }
18 | { op: 'init'; row: string; name: string; value: number }
19 | { op: 'hp'; row: string; member: number; delta: number }
20 | { op: 'kill'; row: string; member: number }
21 | { op: 'out'; row: string }
22 | { op: 'down'; row: string; name: string; value: boolean }
23 | { op: 'point'; row: string }
24 | { op: 'next' }
25 | { op: 'end' }
26/** `undo` names the index of the event it reverts. */
27export type Event = { at: number; op: Op } | { at: number; undo: number }
28
29export const live = (f: Fight | null) => (f && f.ended === null ? f : null)
30const group = (r: Row | undefined) => !!r && r.members.length > 1
31const allDead = (r: Row) => r.members.length > 0 && r.members.every((m) => m.dead)
32
33const undoneOf = (events: Event[]) => new Set(events.flatMap((e) => ('undo' in e ? [e.undo] : [])))
34
35/** The index of the event the next `undo` reverts, or null. */
36export function undoTarget(events: Event[]): number | null {
37 const undone = undoneOf(events)
38 for (let i = events.length - 1; i >= 0; i--) if (!('undo' in events[i]!) && !undone.has(i)) return i
39 return null
40}
41
42/** Can the pointer land here? Not `out`, not a dead group, not a `?` row. */
43const eligible = (r: Row) => !r.out && r.init !== null && !allDead(r)
44
45/** Initiative order: rows with one (highest first, then as added), then `?` rows as added; out and dead rows sink in each. */
46export function orderRows(f: Fight): Row[] {
47 const sunk = (r: Row) => (r.out || allDead(r) ? 1 : 0)
48 const by = (a: Row, b: Row) => sunk(a) - sunk(b) || (b.init ?? 0) - (a.init ?? 0) || a.added - b.added
49 return [...f.rows.filter((r) => r.init !== null).sort(by), ...f.rows.filter((r) => r.init === null).sort(by)]
50}
51
52/** Where `n` moves the pointer, and whether it wraps to a new round. */
53function nextRow(f: Fight): { row: Row; wraps: boolean } | null {
54 const order = orderRows(f)
55 const at = order.findIndex((r) => r.id === f.pointer)
56 const after = f.pointer === null ? order.find(eligible) : order.slice(at + 1).find(eligible)
57 if (after) return { row: after, wraps: false }
58 const first = order.find(eligible)
59 return first ? { row: first, wraps: true } : null
60}
61
62/** `▶ NUX then DIEGO · ROUND 2`, or `NO POINTER · n, or pick a row and t` before the first. */
63export function statusText(f: Fight): string {
64 const cur = f.rows.find((r) => r.id === f.pointer)
65 if (!cur) return 'NO POINTER · n, or pick a row and t'
66 const next = nextRow(f)
67 const then = next && next.row !== cur ? ` then ${next.row.name.toUpperCase()}` : ''
68 return `▶ ${cur.name.toUpperCase()}${then}` + (f.round ? ` · ROUND ${f.round}` : '')
69}
70
71/** A member's working as the DM writes it: `22 − 9 = 13 − 8 = 5`. */
72export function working(m: Member): string {
73 let hp = m.max
74 let s = String(hp)
75 for (const d of m.working) {
76 hp += d
77 s += ` ${d < 0 ? '−' : '+'} ${Math.abs(d)} = ${hp}`
78 }
79 return s
80}
81
82/** A row and member as the log names them: the member's number only in a group. */
83function named(f: Fight | null, id: string, member?: number): string {
84 const r = f?.rows.find((x) => x.id === id)
85 const name = r?.name ?? id
86 return member !== undefined && group(r) ? `${name} ${member + 1}` : name
87}
88
89/** An op as the log shows it: `Goblin ×2 · hp 7 · ac 15 · init 14`, `Goblin 2 −9`, `▶ Bati`. */
90export function describe(o: Op, f: Fight | null): string {
91 if (o.op === 'add') {
92 const parts = [o.count > 1 ? `${o.name} ×${o.count}` : o.name]
93 if (o.hp !== null) parts.push(`hp ${o.hp}`)
94 if (o.ac !== null) parts.push(`ac ${o.ac}`)
95 if (o.init !== null) parts.push(`init ${o.init}`)
96 return parts.join(' · ')
97 }
98 if (o.op === 'pack') return `pack ${o.name}: ${o.rows.length} group${o.rows.length === 1 ? '' : 's'}`
99 if (o.op === 'init') return `${o.name} · init ${o.value}`
100 if (o.op === 'hp') return `${named(f, o.row, o.member)} ${o.delta < 0 ? '−' : '+'}${Math.abs(o.delta)}`
101 if (o.op === 'kill') return `${named(f, o.row, o.member)} ✕`
102 if (o.op === 'out') return `${named(f, o.row)} out`
103 if (o.op === 'down') return `${o.name} ${o.value ? 'down' : 'up'}`
104 if (o.op === 'point') return `▶ ${named(f, o.row)}`
105 return o.op === 'next' ? 'n' : 'end'
106}
107
108/**
109 * The fight as one Table-note line, for Ingest: what was tracked, in the DM's
110 * words (`out`, never "fled"). Null when no row was tracked. For example
111 * `fight: Smoke mephit ×6, 5 dead, 1 standing · Gout · Slaad out · 9 rounds · Gout down r2, r5`.
112 * Standing counts `0?` members too: only `x` confirms a death.
113 */
114export function summary(f: Fight): string | null {
115 const order = orderRows(f)
116 if (!order.length) return null
117 const rows = order.map((r) => {
118 const n = r.members.length
119 const dead = r.members.filter((m) => m.dead).length
120 const s = (n > 1 ? `${r.name} ×${n}` : r.name) + (dead ? `, ${dead} dead` : '') + (n - dead ? `, ${n - dead} standing` : '')
121 return r.out ? s + ' out' : s
122 })
123 const rounds = f.round ? [f.round === 1 ? '1 round' : `${f.round} rounds`] : []
124 const downs = order.filter((r) => r.downs.length).map((r) => `${r.name} down ${r.downs.map((d) => 'r' + d).join(', ')}`)
125 return 'fight: ' + [...rows, ...rounds, ...downs].join(' · ')
126}
127
128/** An add's row, `id`, from the event at index `i`. */
129function addRow(f: Fight, o: AddOp, id: string, i: number) {
130 const hp = o.hp
131 const members = hp === null ? [] : Array.from({ length: Math.max(1, o.count) }, () => ({ hp, max: hp, working: [], dead: false, zero: false }))
132 f.rows.push({ id, side: 'monster', name: o.name, init: o.init, ac: o.ac, members, out: false, down: false, downs: [], added: i })
133}
134
135/** Apply one op to `f`, the event at index `i`: false when its row or member is not there. Returns the HP working an `hp` op produced. */
136function applyOp(f: Fight, o: Op, i: number, at: number): { working?: string } | false {
137 const r = 'row' in o ? f.rows.find((x) => x.id === o.row) : undefined
138 if (o.op === 'add') addRow(f, o, 'm' + i, i)
139 // A pack's rows share its event index, so one undo takes them all; they order as listed.
140 else if (o.op === 'pack') o.rows.forEach((a, k) => addRow(f, a, `m${i}.${k}`, i))
141 else if (o.op === 'init' || o.op === 'down') {
142 const row = r ?? { id: o.row, side: 'pc' as const, name: o.name, init: null, ac: null, members: [], out: false, down: false, downs: [], added: i }
143 if (!r) f.rows.push(row)
144 if (o.op === 'init') row.init = o.value
145 else {
146 row.down = o.value
147 if (o.value) row.downs.push(f.round)
148 }
149 } else if (o.op === 'next') {
150 const n = nextRow(f)
151 if (n) {
152 f.round = f.pointer === null ? Math.max(f.round, 1) : n.wraps ? f.round + 1 : f.round
153 f.pointer = n.row.id
154 }
155 } else if (o.op === 'end') f.ended = at
156 else if (!r) return false
157 else if (o.op === 'out') r.out = true
158 else if (o.op === 'point') {
159 // The round stays, except that a fight's first `t` starts round 1, as its first `n` does.
160 f.pointer = r.id
161 f.round = Math.max(f.round, 1)
162 } else {
163 const m = r.members[o.member]
164 if (!m) return false
165 if (o.op === 'kill') Object.assign(m, { dead: true, zero: false })
166 else {
167 m.working.push(o.delta)
168 m.hp += o.delta
169 m.zero = m.hp <= 0 && !m.dead
170 return { working: working(m) }
171 }
172 }
173 return {}
174}
175
176/** Does this op open a new fight when none is live? An add, a pack, or a PC's initiative. */
177const starts = (o: Op) => o.op === 'add' || o.op === 'pack' || o.op === 'init'
178
179/**
180 * Replay the events: undone ops are logged and skipped, an op that has nothing
181 * to act on (no live fight, its row gone) is logged as `? <op>`. An op that
182 * starts a fight while none is live begins a new one, `F` + its time.
183 */
184export function fold(events: Event[]): Fight | null {
185 const undone = undoneOf(events)
186 let f: Fight | null = null
187 events.forEach((e, i) => {
188 if ('undo' in e) return
189 const text = describe(e.op, f)
190 if (undone.has(i)) return void f?.log.push({ at: e.at, text, reverted: true })
191 if (!live(f) && starts(e.op)) f = { id: 'F' + e.at, startedAt: e.at, rows: [], pointer: null, round: 0, ended: null, log: [] }
192 const result = live(f) ? applyOp(f!, e.op, i, e.at) : false
193 if (!result) return void f?.log.push({ at: e.at, text: '? ' + text })
194 f!.log.push({ at: e.at, text, ...result })
195 })
196 return f
197}
198
199export type Pack = { name: string; rows: AddOp[] }
200
201/**
202 * The combat packs in `Prep/Fights.md`: each `## ` heading a pack, each
203 * non-blank line under it a group, `<name> [xN|×N] [hp N] [ac N] [init N]`.
204 * Lines before the first heading are ignored. A pack with a line that does not
205 * read is refused whole; `errors` names the line, `Fights.md L7: …`.
206 */
207export function parsePacks(text: string): { packs: Pack[]; errors: string[] } {
208 const packs: Pack[] = []
209 const errors: string[] = []
210 let cur: { name: string; line: number; rows: AddOp[]; error: string | null } | null = null
211 const close = () => {
212 if (!cur) return
213 if (!cur.error && !cur.rows.length) cur.error = `Fights.md L${cur.line}: "${cur.name}" has no groups`
214 if (cur.error) errors.push(cur.error)
215 else packs.push({ name: cur.name, rows: cur.rows })
216 }
217 text.split(/\r?\n/).forEach((raw, n) => {
218 const line = raw.trim()
219 const heading = /^##(?:\s+(.*))?$/.exec(line)
220 if (heading) {
221 close()
222 cur = { name: (heading[1] ?? '').trim(), line: n + 1, rows: [], error: null }
223 if (!cur.name) cur.error = `Fights.md L${n + 1}: a pack needs a name`
224 return
225 }
226 if (!cur || !line || cur.error) return
227 const read = readGroup(line)
228 if (typeof read === 'string') cur.error = `Fights.md L${n + 1}: ${read}`
229 else cur.rows.push(read)
230 })
231 close()
232 return { packs, errors }
233}
234
235/** One group line as an add, or why it does not read. */
236function readGroup(line: string): AddOp | string {
237 const bad = `can't read "${line}"`
238 const words = line.replace(/^-\s+/, '').split(/\s+/)
239 const name: string[] = []
240 const got: { count?: number; hp?: number; ac?: number; init?: number } = {}
241 for (let k = 0; k < words.length; k++) {
242 const w = words[k]!
243 const times = /^[x×](\d+)$/i.exec(w)
244 const key = w.toLowerCase()
245 const field = key === 'hp' || key === 'ac' || key === 'init' ? key : null
246 if (!times && !field) {
247 if (Object.keys(got).length) return bad
248 name.push(w)
249 continue
250 }
251 const value = times ? times[1]! : words[++k]
252 if (value === undefined || !/^\d+$/.test(value)) return bad
253 const slot = times ? 'count' : field!
254 if (slot in got) return bad
255 got[slot] = Number(value)
256 }
257 const text = name.join(' ')
258 if (!text || got.count === 0) return bad
259 const count = got.count ?? 1
260 if (count > 1 && got.hp === undefined) return 'a group needs HP'
261 return { op: 'add', name: text.charAt(0).toUpperCase() + text.slice(1), count, hp: got.hp ?? null, ac: got.ac ?? null, init: got.init ?? null }
262}
263hooks/look.ts 123 lines1// The default look: Alien (1979) computer interfaces meet John Provencher.
2// One text system for every surface: readout rules between sections, uppercase
3// readouts for labels, values in ink, gauges as glyph runs, one phosphor accent
4// for what is live. Pure: no mods API, and nothing reads a sheet; view.ts hands
5// in the numbers.
6//
7// The screen is the terminal: the pane paints no background and sets no theme.
8// Ink is the host's foreground (no `color`), dim is `dimColor`; only the raw
9// colours below are set. Dark is the intended look; a light theme keeps the
10// words and loses the colour.
11
12import type { TextProps } from 'claude-code'
13
14/**
15 * The raw colours. Phosphor and faint are sampled from the workbench mock the
16 * owner chose (factory T0037, `B-workbench-green.png`), which Studio
17 * (`journal/`) shares, so the two tools have one green.
18 */
19export const TOKENS = {
20 phosphor: '#4af626',
21 amber: '#f0a020',
22 alert: '#ff3b30',
23 cyan: '#3fc8e0',
24 faint: '#6a6a6a',
25} as const
26
27/**
28 * A receipt's indent in the transcript: the engine's rows carry a `⏺` and two
29 * columns of indent, a hook's row carries no bullet, so the verb sits in the
30 * text column (probe run 5 showed the detail at column 0 beside an indented
31 * engine block).
32 */
33export const RECEIPT_INDENT = 2
34
35/**
36 * How long a changed value stays inverse after a write. Handoff 17: "one
37 * 'flash' frame on a changed value (step C: redraw it inverse once, then
38 * normal)."
39 */
40export const FLASH_MS = 800
41
42/** One glyph pair for every gauge. `▮`/`▯` wait for a live check of the players' fonts. */
43export const GAUGE = { full: '█', spent: '░' } as const
44
45/** HP's state: phosphor above half, amber to a quarter, alert below. */
46export type HpState = 'phosphor' | 'amber' | 'alert'
47/** A gauge's full-glyph colour: an HP state, or ink for slots and resources. */
48export type Tone = HpState | 'ink'
49export type Segment = 'full' | 'spent' | 'temp'
50
51export function hpState(current: number, max: number): HpState {
52 const frac = max > 0 ? current / max : 0
53 return frac > 0.5 ? 'phosphor' : frac >= 0.25 ? 'amber' : 'alert'
54}
55
56/**
57 * Segments for current/max plus temp: one per hit point while they fit in
58 * `room`, else each stands for several. Temp HP follows the maximum.
59 */
60export function hpSegments(current: number, max: number, temp: number, room: number): Segment[] {
61 const total = Math.max(1, max) + Math.max(0, temp)
62 const per = Math.max(1, Math.ceil(total / Math.max(1, room)))
63 const n = Math.ceil(total / per)
64 const maxSegs = Math.ceil(Math.max(1, max) / per)
65 const full = Math.min(Math.ceil(Math.max(0, current) / per), maxSegs)
66 return Array.from({ length: n }, (_, i): Segment => (i < full ? 'full' : i < maxSegs ? 'spent' : 'temp'))
67}
68
69/** `n` segments, the first `full` of them full, the rest spent. */
70export const segments = (full: number, n: number): Segment[] =>
71 Array.from({ length: n }, (_, i): Segment => (i < full ? 'full' : 'spent'))
72
73/** The band's HP gauge: `round(current / max × cells)` full, the rest spent. */
74export function bandSegments(current: number, max: number, cells = 10): Segment[] {
75 const frac = max > 0 ? current / max : 0
76 return segments(Math.max(0, Math.min(cells, Math.round(frac * cells))), cells)
77}
78
79/** One run of a gauge: the glyphs and the props of the `Text` that draws them. */
80export interface Run {
81 text: string
82 props: TextProps
83}
84
85const toneProps = (tone: Tone): TextProps => (tone === 'ink' ? {} : { color: TOKENS[tone] })
86
87/**
88 * The `Text` runs for one gauge: full glyphs in the tone's colour, spent ones
89 * dim, temp as cyan full glyphs; empty runs dropped. `spaced` puts a space
90 * between glyphs (slot and resource pips).
91 */
92export function gaugeRuns(segments: Segment[], tone: Tone, opts: { spaced?: boolean } = {}): Run[] {
93 const sep = opts.spaced ? ' ' : ''
94 const runs: Run[] = []
95 const add = (seg: Segment, props: TextProps) => {
96 const n = segments.filter((s) => s === seg).length
97 if (!n) return
98 const glyph = seg === 'spent' ? GAUGE.spent : GAUGE.full
99 runs.push({ text: (runs.length ? sep : '') + Array(n).fill(glyph).join(sep), props })
100 }
101 add('full', toneProps(tone))
102 add('spent', { dimColor: true })
103 add('temp', { color: TOKENS.cyan })
104 return runs
105}
106
107/**
108 * Pips for `left` of `max` in `width` cells: a glyph each with a space between
109 * while they fit, else a meter of `width` glyphs without spaces.
110 */
111export function pipRuns(left: number, max: number, width: number, tone: Tone): Run[] {
112 const spaced = max * 2 - 1 <= width
113 const n = spaced ? max : width
114 const full = spaced ? left : Math.round((width * left) / Math.max(1, max))
115 return gaugeRuns(segments(full, n), tone, { spaced })
116}
117
118/** A readout rule, `── LABEL ───…`, exactly `columns` characters; `── ───…` without a label. */
119export function rule(label: string, columns: number): string {
120 const head = label ? `── ${label.toUpperCase()} ` : '── '
121 return (head + '─'.repeat(Math.max(0, columns - head.length))).slice(0, columns)
122}
123hooks/receipt.ts 252 lines1// A connector call's row in the transcript, as one line of words: a verb
2// readout (`WRITE`, `CHECK`, `SPELL`) and the answer, the citation inline as
3// the server gave it. The verb and shape come from the output, never the
4// input, so a mismatched answer passes; words may come from the input (a
5// preview's reason, a create's action). Anything the shapes below do not
6// match is `null`: the row stays Claude Code's own.
7// Vault receipts describe the request; LOGGED/CLOSED alone are confirmed by
8// the answer. The engine's result block is their detail. Interrupted rows pass.
9//
10// Pure and synchronous: every pane invalidate redraws every connector row
11// (`$.ui.invalidate('ui.render')` is plugin-wide). No mods API here.
12
13import { asApplied, asCheck, asGet, describeChange, payloadOf, textOf, type DiffItem, type Finding } from './sheet.ts'
14
15/** The rules connector's tools that draw a receipt; each has a fixture in tests/fixtures/bati. */
16export const CONNECTOR =
17 /^mcp__.+__(sheet_read|sheet_write|level_up|spell_get|monster_get|character_option_get|condition_search|variantrule_search|book_content_get)$/
18
19/** The vault's tools whose rows draw a receipt during play; `receipt()` draws the Table kinds and the reads. */
20export const VAULT = /^mcp__vault__(vault_query|canon_query|draft_write)$/
21
22export interface Receipt {
23 verb: string
24 body: string
25 state: 'running' | 'done' | 'error'
26 /** Lines the row cannot hold (findings in words), drawn over the result block in ctrl+o. */
27 detail: string[]
28}
29
30/** A `ToolUse` row's props, or a `ToolResult`'s with `isRunning: false` and no input. */
31export interface Row {
32 tool: string
33 input?: unknown
34 isRunning: boolean
35 isErrored: boolean
36 isInterrupted?: boolean
37 output?: unknown
38}
39
40type Sheets = (id: string) => Record<string, any> | undefined
41
42export const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
43const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
44const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
45
46const KIND: Record<string, string> = {
47 class: 'CLASS',
48 subclass: 'SUBCLASS',
49 feat: 'FEAT',
50 species: 'SPECIES',
51 background: 'BACKGROUND',
52 item: 'ITEM',
53 optional_feature: 'FEATURE',
54 class_spells: 'SPELLS',
55}
56
57const FIXED: Record<string, string> = {
58 level_up: 'LEVEL UP',
59 spell_get: 'SPELL',
60 monster_get: 'MONSTER',
61 condition_search: 'CONDITION',
62 variantrule_search: 'RULE',
63 book_content_get: 'BOOK',
64}
65
66const READS: Record<string, string> = { get: 'READ', list: 'LIST', history: 'HISTORY', check: 'CHECK', schema: 'SCHEMA' }
67
68const suffix = (tool: string) => tool.match(CONNECTOR)?.[1] ?? tool
69const request = (input: unknown): Record<string, any> => (isObj(input) && isObj(input.request) ? input.request : {})
70const kindVerb = (input: unknown) => (isObj(input) && KIND[input.kind]) || 'OPTION'
71
72/** The verb a call draws while it runs, from its input alone. */
73export function runningVerb(tool: string, input: unknown): string {
74 const t = suffix(tool)
75 const action = request(input).action
76 if (t === 'sheet_read') return READS[action] ?? 'READ'
77 if (t === 'sheet_write') return isObj(input) && input.apply === true ? (action === 'create' ? 'CREATE' : 'WRITE') : 'PREVIEW'
78 if (t === 'character_option_get') return kindVerb(input)
79 return FIXED[t] ?? t.toUpperCase()
80}
81
82/** ` · PHB p.186`: the source, and the page unless its evidence is absent; `''` without a source. */
83export function cite(c: unknown): string {
84 if (!isObj(c) || typeof c.source !== 'string') return ''
85 return ` · ${c.source}` + (typeof c.page === 'number' && c.page_evidence !== 'absent' ? ` p.${c.page}` : '')
86}
87
88/** `PHB p.186 · Resting > Long Rest`: a citation on its own line, with its section. */
89export function citeLine(c: unknown): string {
90 const head = cite(c).slice(3)
91 return head && isObj(c) && typeof c.section === 'string' ? `${head} · ${c.section}` : head
92}
93
94/** Findings in words, warnings first: `⚠ message · PHB p.174 · section`, `· message` for the rest. */
95export function findingLines(findings: Finding[]): string[] {
96 const line = (f: Finding, mark: string) => {
97 const c = citeLine(f.citation)
98 return `${mark} ${f.message}` + (c ? ` · ${c}` : '')
99 }
100 const warn = findings.filter((f) => f.severity === 'warning')
101 return [...warn.map((f) => line(f, '⚠')), ...findings.filter((f) => f.severity !== 'warning').map((f) => line(f, '·'))]
102}
103
104const done = (verb: string, body: string, detail: string[] = []): Receipt => ({ verb, body, state: 'done', detail })
105
106function firstSentence(text: string): string {
107 const m = text.match(/^[\s\S]*?\.(?=\s|$)/)
108 return (m ? m[0] : text).slice(0, 200)
109}
110
111const validDiff = (v: unknown): v is DiffItem[] => Array.isArray(v) && v.every((x) => isObj(x) && typeof x.path === 'string')
112
113/** The change in words plus the first diff item's rule: `HP 12 → 15 · long rest · PHB p.186`. */
114function change(character: string, reason: unknown, diff: DiffItem[], sheetOf: Sheets): string {
115 const body = describeChange({ version: 0, at: 0, reason: str(reason) ?? '', diff }, sheetOf(character) ?? {})
116 return body + cite(diff[0]?.rule)
117}
118
119function sheetRead(d: unknown, sheetOf: Sheets): Receipt | null {
120 const got = asGet(d)
121 if (got) return done('READ', got.sheet.name)
122 const check = asCheck(d)
123 if (check) {
124 const name = sheetOf(check.character.id)?.name ?? check.character.id
125 const w = check.findings.filter((f) => f.severity === 'warning').length
126 const i = check.findings.length - w
127 const counts = check.findings.length ? `${plural(w, 'warning', 'warnings')} · ${plural(i, 'note', 'notes')}` : 'clean'
128 return done('CHECK', `${name} · ${counts}`, findingLines(check.findings))
129 }
130 if (Array.isArray(d) && d.every((x) => isObj(x) && typeof x.id === 'string' && typeof x.name === 'string'))
131 return done('LIST', d.length ? d.map((x) => x.name).join(', ') : 'no sheets')
132 if (isObj(d) && isObj(d.character) && typeof d.character.id === 'string' && Array.isArray(d.versions)) {
133 const name = sheetOf(d.character.id)?.name ?? d.character.id
134 return done('HISTORY', `${name} · ${plural(d.versions.length, 'version', 'versions')}`)
135 }
136 if (isObj(d) && isObj(d.schema) && (d.schema.$schema || d.schema.properties)) return done('SCHEMA', 'sheet')
137 return null
138}
139
140function sheetWrite(d: unknown, input: unknown, sheetOf: Sheets): Receipt | null {
141 const req = request(input)
142 const w = asApplied(d)
143 if (w) {
144 const allAdds = w.diff.every((x) => !('from' in x))
145 if ((allAdds && !sheetOf(w.character)) || req.action === 'create') {
146 const parts = [w.name ?? w.character, str(req.reason), plural(w.diff.length, 'field', 'fields')]
147 return done('CREATE', parts.filter((p) => p !== undefined).join(' · '))
148 }
149 const rules = [...new Set(w.diff.map((x) => citeLine(x.rule)).filter((l) => l))]
150 return done('WRITE', change(w.character, req.reason, w.diff, sheetOf), rules.length > 1 ? rules : [])
151 }
152 if (isObj(d) && d.preview === true && typeof d.character === 'string' && validDiff(d.diff)) {
153 const findings: Finding[] = Array.isArray(d.findings) ? d.findings.filter((f: unknown) => isObj(f) && typeof f.message === 'string') : []
154 const warnings = findings.filter((f) => f.severity === 'warning')
155 const body = change(d.character, req.reason, d.diff, sheetOf)
156 return done('PREVIEW', body + (warnings.length ? ' · ' + plural(warnings.length, 'warning', 'warnings') : ''), findingLines(warnings))
157 }
158 return null
159}
160
161function lookup(t: string, d: unknown, input: unknown): Receipt | null {
162 if (!isObj(d)) return null
163 if (t === 'level_up') {
164 const l = d.level
165 if (!isObj(l) || typeof l.class !== 'string' || typeof l.from !== 'number' || typeof l.to !== 'number') return null
166 const names = Array.isArray(d.features) ? d.features.map((f: any) => str(f?.name)).filter((n: unknown) => n) : []
167 return done(FIXED[t]!, `${l.class} ${l.from} → ${l.to}` + (names.length ? ' · ' + names.join(', ') : '') + cite(d.citation))
168 }
169 if (t === 'condition_search' || t === 'variantrule_search') {
170 const results = Array.isArray(d.results) ? d.results.filter((r: unknown) => isObj(r) && typeof r.name === 'string') : []
171 if (!results.length) return null
172 if (results.length === 1) return done(FIXED[t]!, results[0].name + cite(results[0].citation))
173 return done(FIXED[t]!, results.slice(0, 3).map((r: any) => r.name).join(', ') + ` · ${results.length} results`)
174 }
175 if (t === 'book_content_get') {
176 const c = d.citation
177 if (!isObj(c) || typeof c.source !== 'string') return null
178 if (typeof c.section === 'string') return done('BOOK', c.section + cite(c))
179 return Array.isArray(d.sections) ? done('BOOK', `${c.source} · ${plural(d.sections.length, 'section', 'sections')}`) : null
180 }
181 if (!isObj(d.citation) || typeof d.citation.name !== 'string') return null
182 if (t === 'character_option_get') return done(kindVerb(input), d.citation.name + cite(d.citation))
183 if (t === 'spell_get' || t === 'monster_get') return done(FIXED[t]!, d.citation.name + cite(d.citation))
184 return null
185}
186
187/** Vault reads say what was asked; only a confirmed Table write says LOGGED/CLOSED. */
188function vaultReceipt(t: string, row: Row): Receipt | null {
189 const input = isObj(row.input) ? row.input : {}
190 const req = request(row.input)
191 const write = t === 'draft_write'
192 const note = input.kind === 'table-note'
193 let verb: string
194 let body: string
195 if (write) {
196 if (!note && input.kind !== 'table-close') return null
197 if (typeof input.text !== 'string' || typeof input.session !== 'string') return null
198 verb = note ? 'LOGGED' : 'CLOSED'
199 body = note ? input.text : `${input.session} · ${input.text}`
200 } else if (t === 'vault_query') {
201 if (typeof req.action !== 'string') return null
202 verb = 'VAULT'
203 const arg = [req.id, req.name, req.npc, req.query, req.text, req.since, req.session].find((v) => typeof v === 'string')
204 body = req.action + (arg === undefined ? '' : ` ${arg}`)
205 } else {
206 verb = 'CANON'
207 if (req.action === 'search' && typeof req.query === 'string')
208 body = `search "${req.query}"` + (typeof req.book === 'string' ? ` · ${req.book}` : '')
209 else if (req.action === 'page' && typeof req.book === 'string' && typeof req.printed_page === 'number')
210 body = `${req.book} p.${req.printed_page}`
211 else return null
212 }
213 if (row.isRunning && row.output === undefined)
214 return { verb: write ? (note ? 'LOG' : 'CLOSE') : verb, body: (write ? input.text : body) + ' …', state: 'running', detail: [] }
215 if (row.isErrored) {
216 const text = textOf(row.output)
217 return text ? { verb: write ? 'REFUSED' : 'ERROR', body: firstSentence(text), state: 'error', detail: [] } : null
218 }
219 const p = payloadOf(row.output)
220 if (!p || !('data' in p)) return null
221 if (write && (!isObj(p.data) || p.data.wrote !== true)) return null
222 if (t === 'canon_query' && req.action === 'search' && isObj(p.data) && p.data.total === 0) body += ' · NO HIT'
223 return done(verb, body)
224}
225
226/** The receipt for a connector row, or null when the row should stay Claude Code's. */
227export function receipt(row: Row, sheetOf: Sheets): Receipt | null {
228 const v = row.tool.match(VAULT)
229 if (v && !row.isInterrupted) return vaultReceipt(v[1]!, row)
230 const m = row.tool.match(CONNECTOR)
231 if (!m || row.isInterrupted) return null
232 const t = m[1]!
233 const sheetTool = t === 'sheet_read' || t === 'sheet_write'
234 if (row.isRunning && row.output === undefined) {
235 const req = request(row.input)
236 const i = isObj(row.input) ? row.input : {}
237 const words = [req.reason, req.action, i.name, i.query, i.section, i.source, i.class].find((w) => typeof w === 'string') ?? t
238 return { verb: runningVerb(row.tool, row.input), body: words + ' …', state: 'running', detail: [] }
239 }
240 if (row.isErrored) {
241 const text = textOf(row.output)
242 if (!text) return null
243 const body = firstSentence(text)
244 return { verb: sheetTool ? 'REFUSED' : 'ERROR', body, state: 'error', detail: text.length > body.length ? [text] : [] }
245 }
246 const p = payloadOf(row.output)
247 if (!p || !('data' in p)) return null
248 if (t === 'sheet_read') return sheetRead(p.data, sheetOf)
249 if (t === 'sheet_write') return sheetWrite(p.data, row.input, sheetOf)
250 return lookup(t, p.data, row.input)
251}
252hooks/sheet.ts 266 lines1// The sheet the pane draws, built only from the rules connector's own results.
2// `sheet_read get` gives the whole picture; a `sheet_write` result's diff is
3// applied to the cached copy, which then waits for the next get. Nothing here
4// computes a rule: the numbers are the server's `sheet` and `derived`.
5// No mods API here (validation forbids passing `$` to an imported function).
6
7export interface DiffItem {
8 path: string
9 from?: unknown
10 to?: unknown
11 rule?: unknown
12}
13
14export interface Change {
15 version: number
16 at: number
17 reason: string
18 diff: DiffItem[]
19}
20
21export interface Entry {
22 id: string
23 name: string
24 owner: string | null
25 version: number
26 sheet: Record<string, any>
27 derived: Record<string, any> | null
28 unresolved: string[]
29 /** When a get last confirmed this copy. */
30 readAt: number
31 /** When this copy last changed (a get or an applied diff). */
32 seenAt: number
33 /** The tool name's server part, for the record. */
34 server: string
35 /** A write's diff was applied and no get has confirmed it yet. */
36 refreshing: boolean
37 /** That diff touched something `derived` is computed from. */
38 derivedPending: boolean
39 /** Loaded from the store, written by an earlier session. */
40 fromStore: boolean
41 last: Change | null
42 /** The last `sheet_read check` of this sheet, counts only (the findings stay in the transcript). */
43 check?: { at: number; version: number; warnings: number; infos: number }
44}
45
46type Payload = { data: unknown } | { error: string }
47
48function parseText(text: string): Payload | null {
49 try {
50 return { data: JSON.parse(text) }
51 } catch {
52 return null
53 }
54}
55
56/** A result's text as the model read it: a string, or content blocks joined. */
57export function textOf(v: unknown): string {
58 if (typeof v === 'string') return v
59 if (Array.isArray(v)) return v.map(textOf).join('')
60 if (v && typeof v === 'object') {
61 const o = v as Record<string, unknown>
62 if (o.type === 'text' && typeof o.text === 'string') return o.text
63 if (typeof o.text === 'string') return o.text
64 if ('content' in o) return textOf(o.content)
65 if ('result' in o) return textOf(o.result)
66 }
67 return ''
68}
69
70/**
71 * A tool result unwrapped to the server's JSON. From a tool.call hook's
72 * `next(e)` Claude Code (2.1.288 types) gives `{ ref, result, text }`, `text`
73 * being the result as the model reads it, with `isError` on a failure; from
74 * `$.tool.call`, the same shape; MCP's own `{ content, isError }` also parses.
75 * The shape checks below decide whether the JSON is ours.
76 */
77export function payloadOf(result: unknown): Payload | null {
78 if (result === null || result === undefined) return null
79 if (typeof result === 'string') return parseText(result)
80 if (Array.isArray(result)) return parseText(textOf(result))
81 if (typeof result !== 'object') return null
82 const o = result as Record<string, unknown>
83 if ('deny' in o) return null
84 if (o.isError === true) return { error: textOf(o) }
85 if (typeof o.text === 'string') {
86 const p = parseText(o.text)
87 if (p) return p
88 }
89 if (o.structuredContent && typeof o.structuredContent === 'object') return { data: o.structuredContent }
90 if ('content' in o) return payloadOf(o.content)
91 if ('result' in o) return payloadOf(o.result)
92 return null
93}
94
95const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
96
97export interface GetPayload {
98 character: { id: string; owner?: string; version: number; updated_at?: string }
99 sheet: Record<string, any>
100 derived: Record<string, any> | null
101 unresolved: string[]
102}
103
104/** A `sheet_read get` answer, or null when the payload is anything else. */
105export function asGet(d: unknown): GetPayload | null {
106 if (!isObj(d) || !isObj(d.character) || !isObj(d.sheet)) return null
107 const c = d.character
108 if (typeof c.id !== 'string' || !Number.isInteger(c.version) || c.version < 1) return null
109 if (d.sheet.schema !== 1 || typeof d.sheet.name !== 'string') return null
110 if (d.derived !== null && !isObj(d.derived)) return null
111 const unresolved = Array.isArray(d.unresolved) ? d.unresolved.filter((u: unknown) => typeof u === 'string') : []
112 return { character: c as GetPayload['character'], sheet: d.sheet, derived: d.derived ?? null, unresolved }
113}
114
115export interface AppliedPayload {
116 character: string
117 name?: string
118 version: number
119 diff: DiffItem[]
120}
121
122/** A `sheet_write apply=true` answer that wrote a version, or null. */
123export function asApplied(d: unknown): AppliedPayload | null {
124 if (!isObj(d) || d.applied !== true || typeof d.character !== 'string' || !Number.isInteger(d.version)) return null
125 if (!Array.isArray(d.diff) || !d.diff.every((x: unknown) => isObj(x) && typeof x.path === 'string' && x.path.startsWith('/'))) return null
126 return { character: d.character, name: typeof d.name === 'string' ? d.name : undefined, version: d.version, diff: d.diff }
127}
128
129export interface Finding {
130 severity: string
131 message: string
132 citation?: unknown
133}
134
135export interface CheckPayload {
136 character: { id: string; version: number }
137 findings: Finding[]
138}
139
140/** A `sheet_read check` answer, or null when the payload is anything else. */
141export function asCheck(d: unknown): CheckPayload | null {
142 if (!isObj(d) || !isObj(d.character) || typeof d.character.id !== 'string' || !Number.isInteger(d.character.version)) return null
143 if (!Array.isArray(d.findings) || !d.findings.every((f: unknown) => isObj(f) && typeof f.severity === 'string' && typeof f.message === 'string')) return null
144 return { character: d.character as CheckPayload['character'], findings: d.findings }
145}
146
147/** `check` is the cached entry's, carried over: a get does not re-check. */
148export function entryFromGet(g: GetPayload, server: string, now: number, check?: Entry['check']): Entry {
149 return {
150 id: g.character.id,
151 name: g.sheet.name,
152 owner: typeof g.character.owner === 'string' ? g.character.owner : null,
153 version: g.character.version,
154 sheet: g.sheet,
155 derived: g.derived,
156 unresolved: g.unresolved,
157 readAt: now,
158 seenAt: now,
159 server,
160 refreshing: false,
161 derivedPending: false,
162 fromStore: false,
163 last: null,
164 ...(check ? { check } : {}),
165 }
166}
167
168/** Paths `derived` is not computed from (checked against the server's derive()). Anything else marks it pending. */
169const OUTSIDE_DERIVED = [
170 /^\/hp(\/|$)/,
171 /^\/hit_dice_spent(\/|$)/,
172 /^\/ac(\/|$)/,
173 /^\/speed(\/|$)/,
174 /^\/spellcasting\/(slots_used|pact_slots_used)(\/|$)/,
175 /^\/resources(\/|$)/,
176 /^\/inventory(\/|$)/,
177 /^\/currency(\/|$)/,
178 /^\/conditions(\/|$)/,
179 /^\/exhaustion$/,
180 /^\/house_rules(\/|$)/,
181 /^\/notes$/,
182 /^\/details(\/|$)/,
183]
184
185export const touchesDerived = (path: string) => !OUTSIDE_DERIVED.some((re) => re.test(path))
186
187const tokens = (path: string) => path.split('/').slice(1).map((t) => t.replace(/~1/g, '/').replace(/~0/g, '~'))
188const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
189
190/**
191 * The server's diff (store/sheets.ts `diff`, rest changes): `{path, from, to}`
192 * replaces, `{path, to}` adds, `{path, from}` removes. Applied to a copy.
193 * `matched` is false when a `from` disagrees with the cache, i.e. the cache
194 * missed a version.
195 */
196export function applyDiff(sheet: Record<string, any>, diff: DiffItem[]): { sheet: Record<string, any>; matched: boolean } {
197 const out = JSON.parse(JSON.stringify(sheet))
198 let matched = true
199 const removals: { parent: any; key: string }[] = []
200 for (const item of diff) {
201 const keys = tokens(item.path)
202 if (!keys.length) continue
203 let parent: any = out
204 for (const k of keys.slice(0, -1)) {
205 if (!parent[k] || typeof parent[k] !== 'object') parent[k] = {}
206 parent = parent[k]
207 }
208 const key = keys[keys.length - 1] ?? ''
209 if ('from' in item && !same(parent[key], item.from)) matched = false
210 if ('to' in item) parent[key] = JSON.parse(JSON.stringify(item.to))
211 else removals.push({ parent, key })
212 }
213 // Array removals last and highest index first, so earlier indexes stay put.
214 removals.sort((a, b) => Number(b.key) - Number(a.key))
215 for (const { parent, key } of removals) {
216 if (Array.isArray(parent)) parent.splice(Number(key), 1)
217 else delete parent[key]
218 }
219 return { sheet: out, matched }
220}
221
222/** The cached entry after a write: diff applied, waiting for a get. */
223export function entryAfterWrite(entry: Entry, w: AppliedPayload, reason: string, now: number): Entry {
224 const { sheet } = applyDiff(entry.sheet, w.diff)
225 return {
226 ...entry,
227 sheet,
228 name: typeof sheet.name === 'string' ? sheet.name : entry.name,
229 version: w.version,
230 seenAt: now,
231 refreshing: true,
232 derivedPending: entry.derivedPending || w.diff.some((d) => touchesDerived(d.path)),
233 last: { version: w.version, at: now, reason, diff: w.diff },
234 }
235}
236
237/** The character a `/sheet <name>` means, by id or name, ignoring case. */
238export function matchEntry(entries: Entry[], query: string): Entry | null {
239 const q = query.trim().toLowerCase()
240 if (!q) return null
241 return entries.find((e) => e.id === q || e.name.toLowerCase() === q) ?? entries.find((e) => e.name.toLowerCase().startsWith(q)) ?? null
242}
243
244/** A diff path in words: `/hp/current` → `HP`, `/resources/0/used` → `Wrath of the Storm used`. */
245export function pathLabel(path: string, sheet: Record<string, any>): string {
246 const [a = '', b, c] = tokens(path)
247 const t = [a, b, c]
248 if (t[0] === 'hp') return t[1] === 'current' || !t[1] ? 'HP' : 'HP ' + t[1]
249 if (t[0] === 'resources' && t[1] !== undefined) {
250 const r = sheet.resources?.[Number(t[1])]
251 return (r?.name ?? 'resource ' + t[1]) + (t[2] ? ' ' + t[2] : '')
252 }
253 if (t[0] === 'spellcasting' && t[1] === 'slots_used') return 'slot ' + (t[2] ?? '') + ' used'
254 if (t[0] === 'hit_dice_spent') return (t[1] ?? '') + ' spent'
255 return t.join(' ').replace(/_/g, ' ')
256}
257
258/** One line for the last change: `HP 12 → 15 · long rest`. */
259export function describeChange(c: Change, sheet: Record<string, any>): string {
260 const v = (x: unknown) => (x === undefined ? '∅' : x !== null && typeof x === 'object' ? '…' : String(x))
261 // A key the write created has no `from`: `slot 1 used → 1`, not `∅ → 1`.
262 const parts = c.diff.slice(0, 3).map((d) => pathLabel(d.path, sheet) + (d.from === undefined ? '' : ' ' + v(d.from)) + ' → ' + v(d.to))
263 if (c.diff.length > 3) parts.push('+' + (c.diff.length - 3) + ' more')
264 return parts.join(', ') + (c.reason ? ' · ' + c.reason : '')
265}
266hooks/table.ts 73 lines1// The DM's party view's data, from the vault server's own answers: the session
2// manifest (`vault_query session`) and the Table operation's log as this mod saw
3// it written (`draft_write table-note | table-close`). The log is what was seen
4// here, not the vault's file: the mod never reads the vault's files.
5// No mods API here (validation forbids passing `$` to an imported function).
6
7import type { Entry } from './sheet.ts'
8import { isObj } from './receipt.ts'
9
10export interface Manifest {
11 session: string
12 status: string
13 title: string | null
14 tableExists: boolean
15}
16
17export interface TableEntry {
18 at: number
19 text: string
20}
21
22export interface TableLog {
23 session: string
24 entries: TableEntry[]
25 closedAt: number | null
26 manifest: Manifest | null
27}
28
29export interface TableWrite {
30 kind: 'note' | 'close'
31 session: string
32 text: string
33}
34
35/**
36 * A `vault_query {action: 'session'}` answer, or null. `registry` is the
37 * registry row (or null), its `title` `""` when the note has none.
38 */
39export function asManifest(d: unknown): Manifest | null {
40 if (!isObj(d) || typeof d.session !== 'string' || typeof d.status !== 'string') return null
41 if (d.registry !== null && d.registry !== undefined && !isObj(d.registry)) return null
42 const title = d.registry?.title
43 return {
44 session: d.session,
45 status: d.status,
46 title: typeof title === 'string' && title ? title : null,
47 tableExists: Array.isArray(d.sources) && d.sources.some((s: any) => isObj(s) && s.kind === 'table' && s.exists === true),
48 }
49}
50
51/** A `draft_write` call `e` (its arguments) that wrote a table note or the close, or null. A dry run answers `wrote: false`. */
52export function asTableWrite(e: any, d: unknown): TableWrite | null {
53 if (!e || (e.kind !== 'table-note' && e.kind !== 'table-close')) return null
54 if (typeof e.session !== 'string' || typeof e.text !== 'string') return null
55 if (!isObj(d) || d.wrote !== true) return null
56 return { kind: e.kind === 'table-note' ? 'note' : 'close', session: e.session, text: e.text }
57}
58
59/** The log after a seen write: a note appends, a close sets `closedAt`. */
60export function applyWrite(log: TableLog | null, w: TableWrite, now: number): TableLog {
61 const base: TableLog = log ?? { session: w.session, entries: [], closedAt: null, manifest: null }
62 return w.kind === 'note' ? { ...base, entries: [...base.entries, { at: now, text: w.text }] } : { ...base, closedAt: now }
63}
64
65export const isSessionId = (s: string) => /^S\d\d$/.test(s)
66
67/** The party's rows, by name. (The DM's own roster order arrives with `party:roster`, handoff 18 step A.) */
68export const rosterRows = (entries: Entry[]): Entry[] => [...entries].sort((a, b) => a.name.localeCompare(b.name))
69
70/** Handoff 18's freshness rule, on when this mod last confirmed the sheet (it cannot know writes it did not see). */
71export const STALE_MS = 15 * 60_000
72export const stale = (readAt: number, now: number) => now - readAt > STALE_MS
73hooks/view.ts 871 lines1// The element trees: the pane and the band strip, one tree for every surface.
2// Everything shown is the server's `sheet` and `derived`; the only arithmetic
3// is "left = max − used" for slots, pips and Hit Dice. Pure: the elements come
4// in from register.ts, which owns every mods API call and handles the presses.
5//
6// The pane is built around a player's turn, not the sheet's sections:
7// - always visible: name and level, conditions when there are any, HP, AC ·
8// INIT · SPD, spell DC and attack per caster, slots, resources;
9// - one row of tabs, one open at a time, by the player's question: actions
10// (what do I roll?), saves and skills, features, gear, notes;
11// - faint readouts at the bottom: how fresh the numbers are, and the last check.
12// It draws with look.ts: readout rules between sections, uppercase readouts,
13// values in ink, gauges as glyph runs. One tree, two gauge drawings: the
14// terminal draws the gauges as glyph runs, the surfaces that have `Svg` draw
15// them, and the HP numerals, as pixel instruments (pixel.ts), never a word.
16// The surface is read only by `tabProps` and the gauge sites.
17//
18// The party view (`/party`, the DM's) is a second tree in the same pane: a row
19// per cached sheet, the Table log as seen here, the session, the re-read. While
20// a fight is on (fight.ts), its order is drawn above the party; the DM runs it
21// with keys: a digit on each row, a letter for each action.
22
23import type { ButtonProps, Elements, RenderElement, RenderNode, TextProps } from 'claude-code'
24import { actionGroups, type ActionRow } from './actions.ts'
25import { live, orderRows, statusText, type Fight, type Member, type Pack, type Row } from './fight.ts'
26import { describeChange, type Entry } from './sheet.ts'
27import { bandSegments, gaugeRuns, hpSegments, hpState, pipRuns, RECEIPT_INDENT, rule, TOKENS, type Run, type Segment, type Tone } from './look.ts'
28import { cellsSvg, hpSvg, pipsSvg, type Art } from './pixel.ts'
29import type { Receipt } from './receipt.ts'
30import { stale, type TableLog } from './table.ts'
31
32/** What `$.ui.resolve(e)` hands out, on any surface. */
33export type Els = Elements[keyof Elements]
34
35/** The elements every surface has, which the pane draws with. */
36type Base = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button' | 'Markdown'>
37
38/** The tabs, by the player's question. Hotkeys keep clear of the taps (`d h u`, a slot's digit, `p`); `r` and `t` stay free. */
39export const TABS = [
40 { id: 'actions', label: 'actions', hotkey: 'a', words: ['actions', 'action', 'spells', 'attacks'] },
41 { id: 'saves', label: 'saves', hotkey: 's', words: ['saves', 'save', 'skills', 'checks'] },
42 { id: 'features', label: 'features', hotkey: 'f', words: ['features', 'feats', 'traits'] },
43 { id: 'gear', label: 'gear', hotkey: 'g', words: ['gear', 'inventory', 'items', 'money'] },
44 { id: 'notes', label: 'notes', hotkey: 'n', words: ['notes', 'rules', 'character', 'details'] },
45] as const
46export type TabId = (typeof TABS)[number]['id']
47
48/** The tab a word names (`spells`, `saves`, `g`), or null. */
49export function tabOf(word: string): TabId | null {
50 const w = word.trim().toLowerCase()
51 return TABS.find((t) => t.id === w || t.hotkey === w || (t.words as readonly string[]).includes(w))?.id ?? null
52}
53
54/** The key of the DM-rules badge, which opens the Notes tab. */
55export const RULES_KEY = 'dm-rules'
56export const tabKey = (id: TabId) => 'tab-' + id
57
58/** The last re-read: `sheets` the gets that answered, `listed` the list answer's length. Module state, never stored. */
59export type Reread = { at: number; sheets: number; listed: number } | { refused: string } | { failed: string }
60/** The number field's question: which number, for which row and member, and its text so far. */
61export type Ask = { kind: 'damage' | 'heal' | 'init'; row: string; member: number; text: string }
62/** The sheet view's number field: damage or healing for the focused sheet, and its text so far. */
63export type Tap = { kind: 'damage' | 'heal'; text: string }
64/** The add form's five fields, as the DM has filled them so far. */
65export type Adding = { name: string; count: string; hp: string; ac: string; init: string }
66
67export interface PaneView {
68 view: 'sheet' | 'party'
69 /** Every cached sheet, in roster order. */
70 rows: Entry[]
71 /** The session's Table log as seen here, or null. */
72 log: TableLog | null
73 session: string | null
74 reread: Reread | null
75 entry: Entry | null
76 surface: string
77 columns: number
78 tab: TabId
79 /** Milliseconds since the epoch, for the freshness readout ("READ 13:19" on the day it was read). */
80 now: number
81 /**
82 * Buttons kept from this surface's last drawing. An unchanged Button is
83 * handed back as the same element, so its press handle survives the redraw
84 * (each new Button gets a new handle, and the old one is retired).
85 */
86 memo: Map<string, RenderElement>
87 /** The diff paths of the last applied write, while its flash lasts; else null. */
88 flash: string[] | null
89 /** The DM's fight, or null; drawn on the party view until it ends. */
90 fight: Fight | null
91 /** The picked row (by id) and, in a group, its member. */
92 sel: { row: string; member: number } | null
93 /** The open number field, with the row's name for its label. */
94 ask: (Ask & { name: string }) | null
95 /** The open add form. */
96 adding: Adding | null
97 /** Why the last key did nothing, until an op applies. */
98 note: string | null
99 /** The pack list `c` opened, or null. While it is open the digits load a pack, not pick a row. */
100 packs: Pack[] | null
101 /** The sheet view's open number field, for a tap. */
102 tap: Tap | null
103}
104
105/** The fight's keys, in the key row's order: [element key, hotkey, label]. */
106const FIGHT_KEYS = [
107 ['next', 'n', 'next'], ['undo', 'u', 'undo'], ['act-d', 'd', 'dmg'], ['act-h', 'h', 'heal'], ['act-i', 'i', 'init'],
108 ['act-x', 'x', 'kill'], ['act-o', 'o', 'out'], ['act-k', 'k', 'down'], ['act-t', 't', 'turn'], ['act-f', 'f', 'end'], ['act-a', 'a', 'add'],
109 ['act-c', 'c', 'packs'],
110] as const
111/** With no fight: `a add · c packs · i init`, and `u undo` while the last fight's end can be undone. */
112const IDLE_KEYS = [FIGHT_KEYS[10], FIGHT_KEYS[11], FIGHT_KEYS[4]]
113const ENDED_KEYS = [...IDLE_KEYS, FIGHT_KEYS[1]]
114const ADD_FIELDS = [
115 ['name', ''], ['count', '1'], ['hp', 'blank: no HP tracked'], ['ac', ''], ['init', ''],
116] as const
117
118const signed = (n: unknown) => (typeof n === 'number' ? (n >= 0 ? '+' + n : String(n)) : '?')
119const pad2 = (n: number) => String(n).padStart(2, '0')
120const clock = (ms: number) => {
121 const d = new Date(ms)
122 return pad2(d.getHours()) + ':' + pad2(d.getMinutes())
123}
124const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
125const day = (ms: number) => {
126 const d = new Date(ms)
127 return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${clock(ms)}`
128}
129const sameDay = (a: number, b: number) => new Date(a).toDateString() === new Date(b).toDateString()
130const RECHARGE: Record<string, string> = { short: 'short rest', long: 'long rest', dawn: 'dawn', none: 'no recharge' }
131const ORDINAL = (l: string) => ({ '1': '1st', '2': '2nd', '3': '3rd' })[l] ?? l + 'th'
132const ABILITIES = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
133
134// ------------------------------------------------------------------ facts (read off the entry)
135
136const unresolvedName = (name: string, unresolved: string[]) =>
137 unresolved.some((u) => u.toLowerCase() === name.toLowerCase() || u.toLowerCase().endsWith(' ' + name.toLowerCase()))
138
139function level(e: Entry): string {
140 const total = e.derived?.total_level
141 return (typeof total === 'number' ? String(total) : '?') + (e.derivedPending && typeof total === 'number' ? '?' : '')
142}
143
144/** `Lv 2 · Sorcerer 1 · Cleric 1`: level once, classes without subclasses (those are in Features). */
145function identityLine(e: Entry): string {
146 const classes = (e.sheet.classes ?? []).map((c: any) => `${c.name} ${c.level}`)
147 return [`Lv ${level(e)}`, ...classes].join(' · ')
148}
149
150interface Caster {
151 cls: string
152 dc: string
153 attack: string
154}
155
156function casters(e: Entry): Caster[] {
157 return ((e.derived?.spellcasting ?? []) as any[]).map((c) => ({ cls: String(c.class), dc: String(c.save_dc ?? '?'), attack: String(c.attack ?? '?') }))
158}
159
160interface Pips {
161 label: string
162 left: number
163 max: number
164 pending: boolean
165 note?: string
166 /** A slot row's tap: the label is then a Button with this key and hotkey. */
167 key?: string
168 hotkey?: string
169}
170
171/** Slot rows by level, `1` → `{label: '1st', key: 'slot-1', hotkey: '1'}`; pact slots last (`slot-pact`, `p`). */
172export function slotRows(e: Entry): (Pips & { level: number })[] {
173 const used = e.sheet.spellcasting?.slots_used ?? {}
174 const pending = e.derivedPending
175 const rows = Object.entries((e.derived?.spell_slots ?? {}) as Record<string, number>)
176 .sort(([a], [b]) => Number(a) - Number(b))
177 .map(([lvl, max]) => ({ label: ORDINAL(lvl), level: Number(lvl), max, left: Math.max(0, max - (used[lvl] ?? 0)), pending, key: 'slot-' + lvl, hotkey: lvl }))
178 const pact = e.derived?.pact_slots
179 if (pact && typeof pact.slots === 'number') {
180 const left = Math.max(0, pact.slots - (e.sheet.spellcasting?.pact_slots_used ?? 0))
181 rows.push({ label: 'Pact', level: Number(pact.level), max: pact.slots, left, pending, key: 'slot-pact', hotkey: 'p' })
182 }
183 return rows
184}
185
186function resources(e: Entry): Pips[] {
187 return (e.sheet.resources ?? []).map((r: any) => ({
188 label: String(r.name),
189 note: RECHARGE[r.recharge] ?? String(r.recharge ?? ''),
190 max: r.max,
191 left: Math.max(0, r.max - r.used),
192 pending: false,
193 }))
194}
195
196/** The alert field: `⚠ POISONED · EXHAUSTION 1`, or '' when there is nothing to say. */
197export function alertText(conditions: unknown[], exhaustion: number): string {
198 const list = conditions.map((c) => String(c).toUpperCase())
199 if (exhaustion > 0) list.push(`EXHAUSTION ${exhaustion}`)
200 return list.length ? '⚠ ' + list.join(' · ') : ''
201}
202
203/** Speeds other than walking: `swim 30`. Walking speed is the SPD stat. */
204function otherSpeeds(s: any): string[] {
205 const sp = s.speed ?? {}
206 return ['swim', 'fly', 'climb', 'burrow'].filter((k) => typeof sp[k] === 'number').map((k) => `${k} ${sp[k]}`)
207}
208
209/**
210 * The faint readout: "CHANGED 13:20 · NOT RE-READ" after a write, "READ 13:19"
211 * on the day it was read, "LAST KNOWN · 3 OCT, 13:19" after that. It goes by
212 * when the sheet was read, not by which session read it, so a module reload
213 * changes nothing. The change itself stays as written.
214 */
215export function freshness(e: Entry, now: number): string {
216 const pend = e.derivedPending ? ' · NUMBERS MARKED ? MAY CHANGE' : ''
217 if (e.refreshing) return `CHANGED ${clock(e.seenAt)} · NOT RE-READ` + (e.last ? ' · ' + describeChange(e.last, e.sheet) : '') + pend
218 return (sameDay(e.readAt, now) ? `READ ${clock(e.readAt)}` : `LAST KNOWN · ${day(e.readAt)}`) + pend
219}
220
221/** The DM's rules that name this thing (a spell, a feature), by whole word. */
222function rulesNaming(e: Entry, name: string): number {
223 const re = new RegExp('\\b' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i')
224 return (e.sheet.house_rules ?? []).filter((r: any) => re.test(String(r.rule ?? ''))).length
225}
226/** ★ marks a DM ruling, the same symbol as the header badge. */
227const dmMark = (e: Entry, name: string) => (rulesNaming(e, name) ? ' ★' : '')
228
229// ------------------------------------------------------------------ parts
230
231const text = (el: Base, children: string, props: TextProps = {}) => el.Text({ ...props, children: [children] })
232const row = (el: Base, children: RenderNode[], gap = 1) => el.Box({ flexDirection: 'row', columnGap: gap, children })
233const wrapRow = (el: Base, children: RenderNode[], gap = 2) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: gap, children })
234const col = (el: Base, children: RenderNode[]) => el.Box({ flexDirection: 'column', children })
235const md = (el: Base, lines: string[]) => el.Markdown({ text: lines.join('\n').slice(0, 10000) })
236const dim = (el: Base, s: string) => text(el, s, { dimColor: true })
237/** A gauge: its runs side by side, no gap. */
238const gauge = (el: Base, runs: Run[]) => row(el, runs.map((r) => text(el, r.text, r.props)), 0)
239/** The surfaces that draw pixel instruments: every one whose table has `Svg`. */
240const pixel = (surface: string) => surface !== 'terminal'
241/** A pixel instrument, centred on its row. */
242const svg = (el: Base, art: Art) => el.Box({ alignSelf: 'center', children: [(el as Elements['desktop']).Svg({ source: art.source, alt: art.alt })] })
243/** A gauge (the band's HP): glyph runs on the terminal, cells elsewhere. */
244const gaugeNode = (el: Base, surface: string, segs: Segment[], tone: Tone, alt: string) =>
245 pixel(surface) ? svg(el, cellsSvg(segs, tone, 4, 4, alt)) : gauge(el, gaugeRuns(segs, tone))
246/** Pips for `left` of `max` in `width` columns: glyph runs on the terminal, cells elsewhere. */
247const pipsNode = (el: Base, surface: string, left: number, max: number, width: number, tone: Tone, alt: string) =>
248 pixel(surface) ? svg(el, pipsSvg(left, max, tone, alt)) : gauge(el, pipRuns(left, max, width, tone))
249const of = (left: number, max: number) => `${left} of ${max}`
250/** A readout rule across the pane's body. */
251const ruleLine = (el: Base, label: string, columns: number) => text(el, rule(label, columns), { dimColor: true, wrap: 'truncate' })
252/** A section heading inside a tab: a dim uppercase readout, with anything beside it. */
253const heading = (el: Base, label: string, extra: RenderNode[] = []) =>
254 el.Box({ flexDirection: 'row', columnGap: 2, children: [dim(el, label.toUpperCase()), ...extra] })
255/** A section of a tab: its heading, then its lines, no space between. */
256const section = (el: Base, head: RenderNode, lines: RenderNode[]) => col(el, [head, ...lines])
257/** A number that may change on the next read: amber with a `?`. Otherwise bold ink. Inverse while it flashes. */
258const value = (el: Base, s: string, pending = false, flash = false) =>
259 text(el, pending ? s + '?' : s, { ...(pending ? { color: TOKENS.amber } : { bold: true }), ...(flash ? { inverse: true } : {}) })
260const stat = (el: Base, label: string, s: string, pending = false) => row(el, [dim(el, label), value(el, s, pending)])
261
262/** Does nothing: register.ts's `ui.press` hook acts on every press, with a fresh `$`. */
263const NOOP = () => {}
264
265/** Builds the pane's Buttons through the memo, so an unchanged Button is the same element as last time. */
266function buttons(el: Base, memo: Map<string, RenderElement>) {
267 const used = new Map<string, RenderElement>()
268 const make = (props: Omit<ButtonProps, 'onPress'> & { key: string; label: string }): RenderElement => {
269 const sig = JSON.stringify(props)
270 const kept = used.get(sig) ?? memo.get(sig) ?? el.Button({ ...props, onPress: NOOP })
271 used.set(sig, kept)
272 return kept
273 }
274 const done = () => {
275 memo.clear()
276 for (const [k, v] of used) memo.set(k, v)
277 }
278 return { make, done }
279}
280type Make = ReturnType<typeof buttons>['make']
281
282/**
283 * The tab row, one row: lowercase words, a hotkey each. The open tab is marked
284 * by more than colour. A plain Button draws the same whatever its variant
285 * (ButtonProps), so Desktop gets real buttons with the open one `primary`, and
286 * the terminal gets plain ones with `▸` before the open tab's label and the
287 * others dim. The engine's button chrome forces this one surface branch.
288 */
289export function tabProps(surface: string, tab: TabId) {
290 const terminal = surface === 'terminal'
291 return TABS.map((t) => {
292 const open = t.id === tab
293 return {
294 key: tabKey(t.id),
295 label: terminal && open ? '▸' + t.label : t.label,
296 hotkey: t.hotkey,
297 ...(terminal ? { plain: true as const } : {}),
298 ...(open ? (terminal ? {} : { variant: 'primary' as const }) : { dimColor: true }),
299 }
300 })
301}
302
303/**
304 * Pips in a table: a fixed label column, the pips, the count at a fixed column
305 * (inverse while flashing), a note. A row with a `key` draws its label as a
306 * plain Button (`1: 1st`), and the label column grows by the `1: `.
307 */
308function pipTable(el: Base, surface: string, rows: Pips[], labelProps: TextProps, flashing: Set<number>, make: Make): RenderNode[] {
309 const keyed = rows.some((r) => r.key)
310 const labelW = Math.min(20, Math.max(1, ...rows.map((r) => r.label.length)) + (keyed ? 3 : 0))
311 const pipW = Math.min(17, Math.max(1, ...rows.map((r) => r.max * 2 - 1)))
312 return rows.map((r, i) =>
313 row(el, [
314 el.Box({
315 width: labelW,
316 flexShrink: 0,
317 children: [
318 r.key
319 ? make({ key: r.key, label: r.label, ...(r.hotkey ? { hotkey: r.hotkey } : {}), plain: true, ...(labelProps.dimColor ? { dimColor: true } : {}) })
320 : text(el, r.label, { ...labelProps, wrap: 'wrap' }),
321 ],
322 }),
323 el.Box({ width: pipW, flexShrink: 0, children: [pipsNode(el, surface, r.left, r.max, pipW, 'ink', of(r.left, r.max))] }),
324 value(el, `${r.left}/${r.max}`, r.pending, flashing.has(i)),
325 ...(r.note ? [dim(el, r.note)] : []),
326 ]),
327 )
328}
329
330/** Actions: the turn menu. Weapons, cantrips, each slot level (its pips beside the heading), other magic; a row per action. */
331function actionsTab(el: Base, e: Entry, surface: string): RenderNode[] {
332 const slots = new Map(slotRows(e).map((s) => [s.level, s]))
333 const groups = actionGroups(e, slots, (name) => rulesNaming(e, name) > 0)
334 // The class leads a row only when more than one class casts, so a reader knows whose DC it is.
335 const showCls = new Set(groups.flatMap((g) => g.rows.map((r) => r.cls)).filter(Boolean)).size > 1
336 const actionRow = (r: ActionRow) =>
337 col(el, [
338 el.Box({
339 flexDirection: 'row',
340 justifyContent: 'space-between',
341 columnGap: 1,
342 children: [
343 wrapRow(
344 el,
345 [
346 text(el, r.name + (r.dm ? ' ★' : ''), { bold: true, wrap: 'wrap' }),
347 ...(r.always ? [dim(el, 'always')] : []),
348 ...r.badges.map((b) => dim(el, b.toUpperCase())),
349 ],
350 1,
351 ),
352 ...(r.effect ? [el.Box({ flexShrink: 0, children: [text(el, r.effect, { bold: true })] })] : []),
353 ],
354 }),
355 el.Box({
356 paddingLeft: 2,
357 children: [
358 wrapRow(
359 el,
360 [
361 ...(showCls && r.cls ? [dim(el, r.cls + ' ·')] : []),
362 // The lead carries the server's number; while that number may change, amber as `value` draws it.
363 ...(r.lead ? [text(el, r.lead, e.derivedPending && r.lead.endsWith('?') ? { color: TOKENS.amber } : {})] : []),
364 ...(r.facts.length ? [dim(el, r.facts.join(' · '))] : []),
365 ],
366 ),
367 ],
368 }),
369 ...(r.written ? [el.Box({ paddingLeft: 2, children: [dim(el, 'Sheet: ' + r.written)] })] : []),
370 ])
371 const out = groups.map((g) => {
372 const s = g.slots
373 const pipW = s ? Math.min(17, Math.max(1, s.max * 2 - 1)) : 0
374 const pips = s ? [pipsNode(el, surface, s.left, s.max, pipW, 'ink', of(s.left, s.max)), value(el, `${s.left}/${s.max}`, s.pending)] : []
375 return section(el, heading(el, g.label, pips), g.rows.map(actionRow))
376 })
377 if (!out.length) out.push(dim(el, 'No spells or written attacks on this sheet.'))
378 return out
379}
380
381const mark = (el: Base, m: string) => text(el, m, { color: TOKENS.phosphor })
382
383/** Saves & skills: the six saves (proficiency marked), checks, skills, passive Perception, Hit Dice. */
384function savesTab(el: Base, e: Entry): RenderNode[] {
385 const d = e.derived ?? {}
386 const s = e.sheet
387 const pend = e.derivedPending
388 const prof = new Set(s.proficiencies?.saves ?? [])
389 const saves = el.Box({
390 flexDirection: 'row',
391 flexWrap: 'wrap',
392 children: ABILITIES.map((a) =>
393 el.Box({
394 width: 12,
395 flexDirection: 'row',
396 columnGap: 1,
397 children: [
398 text(el, a.toUpperCase(), prof.has(a) ? {} : { dimColor: true }),
399 value(el, signed(d.saving_throws?.[a]), pend),
400 ...(prof.has(a) ? [mark(el, '●')] : []),
401 ],
402 }),
403 ),
404 })
405 const checks = wrapRow(el, [dim(el, 'CHECKS'), ...ABILITIES.map((a) => stat(el, a.toUpperCase(), signed(d.modifiers?.[a]), pend))])
406 const skillProf = new Map<string, boolean>((s.proficiencies?.skills ?? []).map((k: any) => [String(k.name).toLowerCase(), !!k.expertise]))
407 const skills = el.Box({
408 flexDirection: 'row',
409 flexWrap: 'wrap',
410 children: Object.entries((d.skills ?? {}) as Record<string, number>)
411 .sort(([a], [b]) => a.localeCompare(b))
412 .map(([name, n]) => {
413 const p = skillProf.get(name.toLowerCase())
414 return el.Box({
415 width: '50%',
416 flexDirection: 'row',
417 justifyContent: 'space-between',
418 paddingRight: 2,
419 children: [row(el, [text(el, name, p === undefined ? { dimColor: true } : {}), ...(p === undefined ? [] : [mark(el, p ? '◆' : '●')])]), value(el, signed(n), pend)],
420 })
421 }),
422 })
423 const hd = Object.entries((d.hit_dice ?? {}) as Record<string, number>).map(([die, n]) => `${die} ${Math.max(0, n - (s.hit_dice_spent?.[die] ?? 0))}/${n}`)
424 return [
425 section(el, heading(el, 'Saving throws'), [saves, checks]),
426 section(el, heading(el, 'Skills'), [
427 skills,
428 text(el, `Passive Perception ${d.passive_perception ?? '?'}${pend ? '?' : ''}` + (hd.length ? ` · Hit Dice ${hd.join(', ')}` : ''), { wrap: 'wrap' }),
429 dim(el, '● proficient · ◆ expertise'),
430 ]),
431 ]
432}
433
434function featuresTab(el: Base, e: Entry): RenderNode[] {
435 const s = e.sheet
436 const feat = (f: any) => `- **${f.name}**` + (f.from ? ` (${f.from})` : '') + (f.choice ? `: ${f.choice}` : '') + (f.note ? ` — ${f.note}` : '') + dmMark(e, f.name)
437 const features = [...(s.feats ?? []).map((f: any) => feat({ ...f, from: f.from ?? 'feat' })), ...(s.features ?? []).map(feat)]
438 const subclasses = (s.classes ?? []).filter((c: any) => c.subclass?.name).map((c: any) => `- **${c.subclass.name}** (${c.name} ${c.level})`)
439 const pr = s.proficiencies ?? {}
440 const prof = (
441 [
442 ['Armour', pr.armor],
443 ['Weapons', pr.weapons],
444 ['Tools', pr.tools],
445 ['Languages', pr.languages],
446 ] as [string, unknown][]
447 )
448 .filter(([, v]) => Array.isArray(v) && v.length)
449 .map(([k, v]) => `- **${k}:** ${(v as string[]).join(', ')}`)
450 return [
451 ...(subclasses.length ? [section(el, heading(el, 'Subclasses'), [md(el, subclasses)])] : []),
452 section(el, heading(el, 'Features'), [md(el, features.length ? features : ['_None._'])]),
453 ...(prof.length ? [section(el, heading(el, 'Proficiencies'), [md(el, prof)])] : []),
454 ]
455}
456
457function gearTab(el: Base, e: Entry): RenderNode[] {
458 const s = e.sheet
459 const items = (s.inventory ?? []).map(
460 (i: any) => `- ${i.name}` + (i.quantity !== 1 ? ` ×${i.quantity}` : '') + (i.attuned ? ' (attuned)' : '') + (i.note ? ` — ${i.note}` : ''),
461 )
462 const coins = Object.entries((s.currency ?? {}) as Record<string, number>)
463 .filter(([, n]) => n > 0)
464 .map(([k, n]) => `${n} ${k}`)
465 return [section(el, heading(el, 'Inventory'), [md(el, items.length ? items : ['_Nothing written._'])]), text(el, `Money ${coins.join(', ') || 'none'}`, { bold: true })]
466}
467
468/** Notes: the DM's rules first, then who the character is, with unresolved names flagged beside them. */
469function notesTab(el: Base, e: Entry): RenderNode[] {
470 const s = e.sheet
471 const out: RenderNode[] = []
472 const rules = (s.house_rules ?? []).map((r: any) => `- ${r.rule}` + (r.by || r.date ? ` _(${[r.by, r.date].filter(Boolean).join(', ')})_` : ''))
473 if (rules.length) out.push(section(el, heading(el, '★ DM rules'), [md(el, rules)]))
474 const flag = (n: string) => (unresolvedName(n, e.unresolved) ? ' — _not in the rules snapshot_' : '')
475 const who: string[] = []
476 if (s.species?.name) who.push(`- **Species:** ${s.species.name}${flag(s.species.name)}` + (s.species.note ? ` — ${s.species.note}` : ''))
477 if (s.background?.name) who.push(`- **Background:** ${s.background.name}${flag(s.background.name)}`)
478 const named = [s.species?.name, s.background?.name].filter(Boolean) as string[]
479 const otherUnresolved = e.unresolved.filter((u) => !named.some((n) => unresolvedName(n, [u])))
480 if (otherUnresolved.length) who.push(`- **Not in the rules snapshot:** ${otherUnresolved.join(', ')}`)
481 for (const [k, v] of Object.entries((s.details ?? {}) as Record<string, string>)) who.push(`- **${k.replace(/_/g, ' ')}:** ${v}`)
482 if (who.length) out.push(section(el, heading(el, 'Character'), [md(el, who)]))
483 if (s.notes) out.push(section(el, heading(el, 'Sheet notes'), [md(el, [String(s.notes)])]))
484 if (!out.length) out.push(dim(el, 'No notes on this sheet.'))
485 return out
486}
487
488function tabBody(el: Base, e: Entry, tab: TabId, surface: string): RenderNode[] {
489 switch (tab) {
490 case 'saves':
491 return savesTab(el, e)
492 case 'features':
493 return featuresTab(el, e)
494 case 'gear':
495 return gearTab(el, e)
496 case 'notes':
497 return notesTab(el, e)
498 default:
499 return actionsTab(el, e, surface)
500 }
501}
502
503/** Does the flash touch a path this pattern names? */
504const flashes = (v: PaneView, re: RegExp) => !!v.flash?.some((p) => re.test(p))
505/** The indexes of the rows whose pattern the flash touches. */
506const flashing = (v: PaneView, patterns: RegExp[]) => new Set(patterns.flatMap((re, i) => (flashes(v, re) ? [i] : [])))
507const slotPath = (s: { label: string; level: number }) =>
508 s.label === 'Pact' ? /^\/spellcasting\/pact_slots_used(\/|$)/ : new RegExp(`^/spellcasting/slots_used/${s.level}(/|$)`)
509
510/** `CHECK 13:19 · 3 WARNINGS · 5 NOTES`. */
511function checkText(c: NonNullable<Entry['check']>): string {
512 const n = (k: number, word: string) => `${k} ${word}${k === 1 ? '' : 'S'}`
513 return `CHECK ${clock(c.at)} · ${n(c.warnings, 'WARNING')} · ${n(c.infos, 'NOTE')}`
514}
515
516export const hpOf = (e: Entry) => {
517 const hp = e.sheet.hp ?? {}
518 return { current: Number(hp.current ?? 0), max: Number(hp.max ?? 0), temp: Number(hp.temp ?? 0) }
519}
520
521// ------------------------------------------------------------------ entry points
522
523/** The pane: identity, vitals, slots, resources, the tab row, the open tab, the freshness readout. */
524export function paneTree(el: Els, v: PaneView): RenderElement {
525 if (v.view === 'party') return partyTree(el, v)
526 const e = v.entry
527 if (!e) {
528 return col(el, [
529 row(el, [text(el, 'AWAITING SHEET'), text(el, '█', { color: TOKENS.phosphor })]),
530 text(el, 'Ask Claude to show your sheet; the pane fills in from its sheet_read result.', { dimColor: true, wrap: 'wrap' }),
531 ])
532 }
533 const { make, done } = buttons(el, v.memo)
534 const kids: RenderNode[] = [wrapRow(el, [text(el, e.name, { bold: true }), text(el, identityLine(e), { dimColor: true, wrap: 'wrap' })])]
535
536 // The DM-rules badge, a press away from the Notes tab.
537 const rules = (e.sheet.house_rules ?? []).length
538 // Beside it, the way to the party view, once there is more than this sheet to show.
539 const showParty = v.rows.length > 1 || v.log !== null
540 const badges = [
541 ...(rules ? [make({ key: RULES_KEY, label: `★ ${rules} dm rule${rules === 1 ? '' : 's'}`, hotkey: 'm', plain: true })] : []),
542 ...(showParty ? [make({ key: 'view-party', label: 'party', hotkey: '0', plain: true })] : []),
543 ]
544 if (badges.length) kids.push(wrapRow(el, badges))
545 // The alert field: inverse, the MU-TH-UR highlight. The words are the signal, the colour the second one.
546 const alert = alertText(e.sheet.conditions ?? [], Number(e.sheet.exhaustion ?? 0))
547 const alertFlash = flashes(v, /^\/(conditions(\/|$)|exhaustion$)/)
548 if (alert) kids.push(text(el, alert, { color: TOKENS.amber, inverse: true, bold: true, ...(alertFlash ? { underline: true } : {}), wrap: 'wrap' }))
549
550 // Vitals. On the terminal HP digits are always ink, inverse once HP is in
551 // alert; the state colour is on the gauge. Elsewhere the numbers are the
552 // pixel instrument, knocked out on an alert plate, and its alt carries them.
553 const { current, max, temp } = hpOf(e)
554 const state = hpState(current, max)
555 const hpFlash = flashes(v, /^\/hp(\/|$)/)
556 const digits = `${current}/${max}`
557 const tempText = temp > 0 ? `+${temp} TEMP` : ''
558 const room = Math.max(10, v.columns - 2 - 2 - digits.length - 2 - (tempText ? tempText.length + 2 : 0))
559 kids.push(
560 ruleLine(el, 'Vitals', v.columns),
561 pixel(v.surface)
562 ? el.Box({ flexDirection: 'row', columnGap: 2, alignItems: 'center', children: [dim(el, 'HP'), svg(el, hpSvg(current, max, temp, state, { flash: hpFlash }))] })
563 : row(
564 el,
565 [
566 dim(el, 'HP'),
567 text(el, digits, { bold: true, ...(state === 'alert' || hpFlash ? { inverse: true } : {}) }),
568 gauge(el, gaugeRuns(hpSegments(current, max, temp, room), state)),
569 ...(tempText ? [text(el, tempText, { color: TOKENS.cyan, bold: true })] : []),
570 ],
571 2,
572 ),
573 )
574 // The taps (register.ts): damage, heal, undo; the number field while one asks.
575 // The phone has no Input (see partyTree), so the surface is asked too.
576 kids.push(
577 wrapRow(el, [
578 make({ key: 'tap-d', label: 'dmg', hotkey: 'd', plain: true }),
579 make({ key: 'tap-h', label: 'heal', hotkey: 'h', plain: true }),
580 make({ key: 'tap-u', label: 'undo', hotkey: 'u', plain: true }),
581 ]),
582 )
583 if (v.note) kids.push(text(el, v.note, { color: TOKENS.amber, wrap: 'wrap' }))
584 if (v.tap && 'Input' in el && v.surface !== 'mobile') {
585 kids.push(el.Input({ key: 'tap-amount', label: `${v.tap.kind} · ${e.name}`, value: v.tap.text, autoFocus: true, submitLabel: 'apply', onSubmit: NOOP }))
586 }
587 const pending = e.derivedPending || !e.derived
588 const speeds = otherSpeeds(e.sheet)
589 kids.push(
590 wrapRow(el, [
591 stat(el, 'AC', String(e.sheet.ac?.value ?? '?')),
592 stat(el, 'INIT', signed(e.derived?.initiative), pending),
593 row(el, [stat(el, 'SPD', String(e.sheet.speed?.walk ?? '?')), ...(speeds.length ? [dim(el, speeds.join(', '))] : [])]),
594 ]),
595 )
596 const cs = casters(e)
597 if (cs.length) kids.push(wrapRow(el, cs.map((c) => row(el, [dim(el, c.cls), stat(el, 'DC', c.dc, e.derivedPending), stat(el, 'ATK', c.attack, e.derivedPending)]))))
598 const slots = slotRows(e)
599 if (slots.length) kids.push(ruleLine(el, 'Slots', v.columns), ...pipTable(el, v.surface, slots, { dimColor: true }, flashing(v, slots.map(slotPath)), make))
600 const res = resources(e)
601 const resPaths = res.map((_, i) => new RegExp(`^/resources/${i}(/|$)`))
602 if (res.length) kids.push(ruleLine(el, 'Resources', v.columns), ...pipTable(el, v.surface, res, {}, flashing(v, resPaths), make))
603
604 // The tab row and the open tab; a blank row above the tab row and the readouts
605 // (freshness, then the last check's count: bookkeeping, faint unless it warns).
606 const c = e.check
607 kids.push(
608 el.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, marginTop: 1, children: tabProps(v.surface, v.tab).map(make) }),
609 ruleLine(el, v.tab, v.columns),
610 ...tabBody(el, e, v.tab, v.surface),
611 el.Box({
612 flexDirection: 'column',
613 marginTop: 1,
614 children: [
615 text(el, freshness(e, v.now), { color: TOKENS.faint, wrap: 'wrap' }),
616 ...(c && c.warnings > 0 && c.version === e.version ? [text(el, checkText(c), { color: TOKENS.faint, wrap: 'wrap' })] : []),
617 ],
618 }),
619 )
620 done()
621 return col(el, kids)
622}
623
624/** `TABLE LOG · OPEN · 3 ENTRIES SEEN`, `· CLOSED 13:52`; `· NONE YET` with a session and nothing seen. */
625function logState(session: string | null, log: TableLog | null): string | null {
626 if (!session) return null
627 const n = log?.entries.length ?? 0
628 const closed = log?.closedAt ? ` · CLOSED ${clock(log.closedAt)}` : log?.manifest?.tableExists ? ' · CLOSED' : ''
629 if (!n && !closed) return 'TABLE LOG · NONE YET'
630 return `TABLE LOG · ${closed ? '' : 'OPEN · '}${n} ENTR${n === 1 ? 'Y' : 'IES'} SEEN` + closed
631}
632
633/** The faint readout under the party: the last re-read, else the oldest read. */
634function rereadText(v: PaneView): string | null {
635 const r = v.reread
636 if (r && 'refused' in r) return `RE-READ REFUSED · ALLOW ${r.refused}`
637 if (r && 'failed' in r) return `RE-READ FAILED · ${r.failed}`
638 if (r) return `RE-READ ${clock(r.at)} · SHEETS ${r.sheets} OF ${r.listed}`
639 if (!v.rows.length) return null
640 const oldest = Math.min(...v.rows.map((e) => e.readAt))
641 return sameDay(oldest, v.now) ? `OLDEST READ ${clock(oldest)}` : `OLDEST READ · ${day(oldest)}`
642}
643
644/**
645 * The party view: the session, the log's state, a row per sheet (AC, HP, the
646 * gauge, PP), the Table log's last entries, the re-read and its readout. A
647 * sheet not confirmed in the last 15 minutes is drawn hollow: its current HP
648 * unknown, the gauge empty, its freshness under the name.
649 *
650 * While a fight is on, the status line, the last op and the key row take the
651 * log state's place, and `── ORDER` comes above `── PARTY`: the PCs in the
652 * fight move into it, so none is drawn twice. Every row's name is a Button with
653 * a digit, in drawn order (Order first), so the digits renumber as the order
654 * moves; the digit is drawn beside the name. The number field and the add form
655 * are drawn only while asked, where the surface has `Input`.
656 */
657function partyTree(el: Els, v: PaneView): RenderElement {
658 const { make, done } = buttons(el, v.memo)
659 const kids: RenderNode[] = []
660 const m = v.log?.manifest
661 const f = live(v.fight)
662 kids.push(
663 v.session
664 ? row(el, [dim(el, 'SESSION'), text(el, v.session + (m?.title ? ` · ${m.title}` : ''), { wrap: 'wrap' })], 2)
665 : text(el, 'NO SESSION · /party S04, or start the Table operation', { dimColor: true, wrap: 'wrap' }),
666 )
667 const state = logState(v.session, v.log)
668 if (f) {
669 kids.push(text(el, statusText(f), { wrap: 'wrap' }))
670 const last = f.log[f.log.length - 1]
671 if (last) kids.push(dim(el, `${clock(last.at)} ${last.text}` + (last.working ? ` ${last.working}` : '') + (last.reverted ? ' undone' : '')))
672 } else if (state) kids.push(text(el, state, { dimColor: true, wrap: 'wrap' }))
673 const picked = v.sel && v.rows.some((e) => e.id === v.sel!.row)
674 const keys = [...(f ? FIGHT_KEYS : v.fight?.ended ? ENDED_KEYS : IDLE_KEYS), ...(picked ? [['act-s', 's', 'sheet'] as const] : [])]
675 kids.push(wrapRow(el, keys.map(([key, hotkey, label]) => make({ key, label, hotkey, plain: true }))))
676 if (v.note) kids.push(text(el, v.note, { color: TOKENS.amber, wrap: 'wrap' }))
677 if (v.packs) {
678 kids.push(ruleLine(el, 'Packs', v.columns))
679 v.packs.slice(0, 9).forEach((p, k) => {
680 const label = `${p.name} · ${p.rows.length} group${p.rows.length === 1 ? '' : 's'}`
681 kids.push(make({ key: 'pack-' + (k + 1), label, hotkey: String(k + 1), plain: true }))
682 })
683 }
684 // The number field and the add form: the ui.input hook in register.ts takes
685 // what is entered. The phone's table has no Input (Elements['mobile']), but
686 // resolve hands it one that draws an empty Box, so the surface is asked too.
687 if ('Input' in el && v.surface !== 'mobile') {
688 if (v.ask) {
689 const members = f?.rows.find((r) => r.id === v.ask!.row)?.members.length ?? 0
690 const who = members > 1 ? `${v.ask.name} ${v.ask.member + 1} of ${members}` : v.ask.name
691 const submitLabel = v.ask.kind === 'init' ? 'set' : 'apply'
692 kids.push(el.Input({ key: 'amount', label: `${v.ask.kind} · ${who}`, value: v.ask.text, autoFocus: true, submitLabel, onSubmit: NOOP }))
693 }
694 if (v.adding) {
695 const form = v.adding
696 for (const [field, placeholder] of ADD_FIELDS) {
697 kids.push(
698 el.Input({
699 key: 'add-' + field,
700 label: field,
701 ...(placeholder ? { placeholder } : {}),
702 value: form[field],
703 ...(field === 'name' ? { autoFocus: true } : {}),
704 submitLabel: field === 'init' ? 'add' : 'next',
705 onSubmit: NOOP,
706 }),
707 )
708 }
709 }
710 }
711
712 // Every row's name is a Button with the next digit, 1–9 in drawn order.
713 let digit = 0
714 /** A row's name as its button, padded to `w` and a column for the picked one's `▸` (a plain Button draws no variant on the terminal). */
715 const nameButton = (id: string, label: string, w: number) => {
716 digit++
717 const on = v.sel?.row === id
718 const name = (on ? '▸' : '') + label.slice(0, w)
719 const pad = w + 1 - name.length
720 const props = { key: 'row-' + id, label: name, ...(digit <= 9 && !v.packs ? { hotkey: String(digit) } : {}), plain: true as const, ...(on ? { variant: 'primary' as const } : {}) }
721 return row(el, [make(props), ...(pad > 0 ? [text(el, ' '.repeat(pad))] : [])], 0)
722 }
723 /** A PC's HP and gauge, from the sheet; hollow when stale. */
724 const pcHp = (e: Entry): RenderNode[] => {
725 const { current, max } = hpOf(e)
726 const hollow = stale(e.readAt, v.now)
727 const hp = hpState(current, max)
728 return [
729 text(el, hollow ? `—/${max}` : `${current}/${max}`, { bold: true, ...(!hollow && hp === 'alert' ? { inverse: true } : {}) }),
730 hollow
731 ? gaugeNode(el, v.surface, bandSegments(0, max), 'ink', `HP unknown, max ${max}`)
732 : gaugeNode(el, v.surface, bandSegments(current, max), hp, `HP ${current} of ${max}`),
733 ]
734 }
735 /** Under a PC's row, indented: its freshness when hollow, its alert field. */
736 const pcNotes = (e: Entry, left: number): RenderNode[] => {
737 const indent = (node: RenderNode) => el.Box({ paddingLeft: left, children: [node] })
738 const notes: RenderNode[] = []
739 if (stale(e.readAt, v.now)) notes.push(indent(text(el, freshness(e, v.now), { color: TOKENS.faint, wrap: 'wrap' })))
740 const alert = alertText(e.sheet.conditions ?? [], Number(e.sheet.exhaustion ?? 0))
741 if (alert) notes.push(indent(text(el, alert, { color: TOKENS.amber, inverse: true, bold: true, wrap: 'wrap' })))
742 return notes
743 }
744
745 const fought = new Set(f?.rows.map((r) => r.id) ?? [])
746 if (f) {
747 kids.push(ruleLine(el, 'Order', v.columns))
748 const order = orderRows(f)
749 const sheetOf = (r: Row) => (r.side === 'pc' ? v.rows.find((e) => e.id === r.id) : undefined)
750 const suffix = (r: Row) => (r.members.length > 1 ? ` ×${r.members.length}` : '')
751 const base = (r: Row) => sheetOf(r)?.name ?? r.name
752 const w = Math.min(14, Math.max(1, ...order.map((r) => base(r).length + suffix(r).length)))
753 /** The name cut to the column, keeping a group's ` ×N`. */
754 const fit = (r: Row) => base(r).slice(0, w - suffix(r).length) + suffix(r)
755 for (const r of order) {
756 const e = sheetOf(r)
757 const acting = f.pointer === r.id
758 const on = v.sel?.row === r.id
759 const ac = stat(el, 'AC', e ? String(e.sheet.ac?.value ?? '?') : r.ac === null ? '?' : String(r.ac))
760 kids.push(
761 el.Box({
762 flexDirection: 'row',
763 flexWrap: 'wrap',
764 columnGap: 2,
765 children: [
766 row(el, [
767 text(el, (acting ? '▶ ' : ' ') + (r.init === null ? '?' : String(r.init)).padStart(2)),
768 // No members: no HP tracked, which tonight is a player without a sheet.
769 r.side === 'pc' || !r.members.length ? text(el, '◆', { color: TOKENS.phosphor }) : text(el, '▲', { dimColor: true }),
770 nameButton(r.id, fit(r), w),
771 ]),
772 ac,
773 ...(e ? pcHp(e) : r.members.length ? monsterHp(el, v.surface, r, on ? v.sel!.member : null) : []),
774 ...(on && r.members.length > 1 ? [dim(el, 'press again: next member')] : []),
775 ...(r.down ? [text(el, '▒DOWN▒', { color: TOKENS.amber, inverse: true, bold: true })] : []),
776 ...(r.out ? [dim(el, 'OUT')] : []),
777 ],
778 }),
779 )
780 if (e) kids.push(...pcNotes(e, w + 13))
781 }
782 }
783
784 const party = v.rows.filter((e) => !fought.has(e.id))
785 if (party.length || !f) kids.push(ruleLine(el, 'Party', v.columns))
786 const nameW = Math.min(12, Math.max(1, ...v.rows.map((e) => e.name.length)))
787 for (const e of party) {
788 kids.push(
789 el.Box({
790 flexDirection: 'row',
791 flexWrap: 'wrap',
792 columnGap: 2,
793 children: [
794 nameButton(e.id, e.name, nameW),
795 stat(el, 'AC', String(e.sheet.ac?.value ?? '?')),
796 ...pcHp(e),
797 stat(el, 'PP', String(e.derived?.passive_perception ?? '?'), e.derivedPending || !e.derived),
798 ],
799 }),
800 ...pcNotes(e, nameW + 6),
801 )
802 }
803 if (!v.rows.length) {
804 kids.push(
805 row(el, [text(el, 'AWAITING SHEETS'), text(el, '█', { color: TOKENS.phosphor })]),
806 text(el, 'Press e to read every sheet this connector serves, or ask Claude to.', { dimColor: true, wrap: 'wrap' }),
807 )
808 }
809
810 const entries = v.log?.entries ?? []
811 if (entries.length) {
812 kids.push(ruleLine(el, 'Table log', v.columns))
813 if (entries.length > 8) kids.push(dim(el, `${entries.length - 8} earlier`))
814 for (const t of entries.slice(-8)) kids.push(row(el, [dim(el, clock(t.at)), text(el, t.text, { wrap: 'wrap' })], 2))
815 }
816
817 kids.push(el.Box({ flexDirection: 'row', marginTop: 1, children: [make({ key: 'reread', label: 're-read', hotkey: 'e', plain: true })] }))
818 const readout = rereadText(v)
819 if (readout) kids.push(text(el, readout, { color: TOKENS.faint, wrap: 'wrap' }))
820 done()
821 return col(el, kids)
822}
823
824/** A monster's HP: a single one's `hp/max` and gauge, `0?` or `✕`; a group's members as a run, `22 [13] 0? ✕` with the picked one bracketed. */
825function monsterHp(el: Base, surface: string, r: Row, picked: number | null): RenderNode[] {
826 const one = (m: Member) => (m.dead ? '✕' : m.zero ? '0?' : String(m.hp))
827 if (r.members.length > 1) return [text(el, r.members.map((m, i) => (i === picked ? `[${one(m)}]` : one(m))).join(' '))]
828 const m = r.members[0]!
829 if (m.dead || m.zero) return [text(el, one(m), { bold: true })]
830 return [text(el, `${m.hp}/${m.max}`, { bold: true }), gaugeNode(el, surface, bandSegments(m.hp, m.max), hpState(m.hp, m.max), `HP ${m.hp} of ${m.max}`)]
831}
832
833/** The band strip, while the pane is closed: name, a 10-cell HP gauge, HP, slots, the key hint. */
834export function bandTree(el: Els, e: Entry, surface: string): RenderElement {
835 const { current, max, temp } = hpOf(e)
836 const kids: RenderNode[] = [
837 text(el, e.name, { bold: true }),
838 gaugeNode(el, surface, bandSegments(current, max), hpState(current, max), `HP ${current} of ${max}`),
839 row(el, [text(el, `${current}/${max}`), ...(temp > 0 ? [text(el, `+${temp}`, { color: TOKENS.cyan })] : [])]),
840 ]
841 const slots = slotRows(e)
842 if (slots.length) {
843 const left = slots.reduce((n, s) => n + s.left, 0)
844 const total = slots.reduce((n, s) => n + s.max, 0)
845 // width = total: on the terminal, one unspaced cell per slot, as before
846 kids.push(pipsNode(el, surface, left, total, total, 'ink', of(left, total)))
847 }
848 kids.push(text(el, '/sheet', { dimColor: true }))
849 return row(el, kids)
850}
851
852/**
853 * A connector call's row in the transcript: the verb readout in a ten-column
854 * box, then the words. One line, the same on every surface; an error's verb is
855 * the alert field's amber inverse, a running call's words are dim.
856 */
857export function receiptTree(el: Els, r: Receipt): RenderElement {
858 const verbProps: TextProps = r.state === 'error' ? { color: TOKENS.amber, inverse: true, bold: true, wrap: 'truncate' } : { dimColor: true, wrap: 'truncate' }
859 return el.Box({
860 flexDirection: 'row',
861 columnGap: 1,
862 paddingLeft: RECEIPT_INDENT,
863 children: [el.Box({ width: 10, flexShrink: 0, children: [text(el, r.verb, verbProps)] }), text(el, r.body, { wrap: 'wrap', ...(r.state === 'running' ? { dimColor: true } : {}) })],
864 })
865}
866
867/** A receipt's result block (ctrl+o): the detail lines dim, then Claude Code's own block, last and once. */
868export function detailTree(el: Els, r: Receipt, engine: RenderNode): RenderElement {
869 return el.Box({ flexDirection: 'column', paddingLeft: RECEIPT_INDENT, children: [...r.detail.map((l) => text(el, l, { dimColor: true, wrap: 'wrap' })), engine] })
870}
871hooks/actions.ts 259 lines1// The Actions tab as a player's turn menu: the sheet's weapons and spells,
2// each looked up in the generated facts table (facts.ts, the rules server's
3// own snapshot) and worded the way a 2024 player reads them. Pure: no mods
4// API. Nothing here computes a rule:
5// - the facts are the snapshot's, copied by facts/build.py;
6// - the attack bonus and save DC are the sheet's `derived` numbers for the
7// class the spell is listed under, and a weapon's bonus is the sheet's own
8// written one;
9// - a cantrip's die is a lookup in the data's own scaling table at
10// `derived.total_level`.
11// A name with no record is shown as the sheet writes it and flagged, never guessed.
12
13import type { Entry } from './sheet.ts'
14import { SPELL_ALIASES, SPELLS, WEAPONS, type SpellRow, type WeaponRow } from './facts.ts'
15
16export interface ActionRow {
17 key: string
18 /** As the sheet writes it. */
19 name: string
20 /** The class the spell is listed under (its numbers); '' for weapons and other magic. */
21 cls: string
22 /** A DM rule names it: ★. */
23 dm: boolean
24 /** Always prepared (a domain or subclass spell). */
25 always: boolean
26 /** The headline: dice and damage type, healing, or the casting time when it is not an action. */
27 effect: string
28 /**
29 * What the turn needs first, at full strength: a casting time that is not an
30 * action (a bonus action or reaction is another part of the turn), then what
31 * to roll with the sheet's number: `ATK +5`, `CON save DC 11`, `ATK +2 (as written)`.
32 */
33 lead: string
34 /** The rest, dim: the level when no heading says it, range, a duration that is not instant; or a weapon's properties. */
35 facts: string[]
36 badges: ('conc' | 'ritual')[]
37 /** The rest of the sheet's own line for a weapon, after its bonus ("1d8 bludgeoning"), shown as written. */
38 written: string
39 /** No record in the snapshot: shown plainly and flagged. */
40 missing: boolean
41 /** The book the record came from (`XPHB`). */
42 source: string
43}
44
45export interface ActionGroup {
46 id: string
47 label: string
48 /** A slot level's pips: left and max. */
49 slots: { left: number; max: number; pending: boolean } | null
50 rows: ActionRow[]
51}
52
53// ------------------------------------------------------------------ lookup
54
55/** The server's name normalisation (`norm`): lower case, every run of other characters one space. */
56export const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
57
58/** Sources by name, for the name-only fallback. */
59function index(table: Record<string, unknown>): Map<string, string[]> {
60 const out = new Map<string, string[]>()
61 for (const k of Object.keys(table)) {
62 const at = k.lastIndexOf('|')
63 const name = k.slice(0, at)
64 out.set(name, [...(out.get(name) ?? []), k.slice(at + 1)])
65 }
66 return out
67}
68const SPELL_NAMES = index(SPELLS)
69const WEAPON_NAMES = index(WEAPONS)
70
71/** A 2024 sheet (or one that does not say) prefers the 2024 books; a 2014 sheet the 2014 ones. */
72const PREFER_2024 = ['xphb', 'efa', 'xdmg', 'phb', 'xge', 'tce', 'eepc', 'dmg', 'toa']
73const PREFER_2014 = ['phb', 'xge', 'tce', 'eepc', 'dmg', 'toa', 'xphb', 'efa', 'xdmg']
74const rank = (order: string[], s: string) => {
75 const i = order.indexOf(s)
76 return i < 0 ? order.length : i
77}
78
79function find<R>(table: Record<string, R>, names: Map<string, string[]>, wanted: string[], source: string | undefined, ruleset: unknown): { row: R; source: string } | null {
80 for (const n of wanted) {
81 if (source) {
82 const row = table[n + '|' + source.toLowerCase()]
83 if (row) return { row, source: source.toUpperCase() }
84 }
85 }
86 const order = ruleset === '2014' ? PREFER_2014 : PREFER_2024
87 for (const n of wanted) {
88 const sources = [...(names.get(n) ?? [])].sort((a, b) => rank(order, a) - rank(order, b))
89 const s = sources[0]
90 const row = s ? table[n + '|' + s] : undefined
91 if (s && row) return { row, source: s.toUpperCase() }
92 }
93 return null
94}
95
96/** A spell by its own name and source; else by name, the edition's books first; else null. */
97export function spellFact(name: string, source?: string, ruleset?: unknown): { row: SpellRow; source: string } | null {
98 const n = norm(name)
99 const alias = SPELL_ALIASES[n]
100 return find(SPELLS, SPELL_NAMES, alias ? [n, alias] : [n], source, ruleset)
101}
102
103/** A weapon by its own name and source, then by name; a plural ("Daggers") also tries the singular. */
104export function weaponFact(name: string, source?: string, ruleset?: unknown): { row: WeaponRow; source: string } | null {
105 const n = norm(name)
106 const wanted = [n, ...(n.endsWith('s') ? [n.slice(0, -1)] : [])]
107 return find(WEAPONS, WEAPON_NAMES, wanted, source, ruleset)
108}
109
110/** A cantrip's dice at this character level, from the data's table ("1:1d10 5:2d10 …"), or ''. */
111export function cantripDice(table: string, level: number | null): string {
112 if (!table.includes(':')) return table
113 if (level === null) return ''
114 let out = ''
115 for (const pair of table.split(' ')) {
116 const [lv, dice] = pair.split(':')
117 if (Number(lv) <= level) out = dice ?? ''
118 }
119 return out
120}
121
122// ------------------------------------------------------------------ wording
123
124const ORDINAL = (l: number) => ({ 1: '1st', 2: '2nd', 3: '3rd' })[l] ?? l + 'th'
125const signed = (n: unknown) => (typeof n === 'number' ? (n >= 0 ? '+' + n : String(n)) : typeof n === 'string' ? n : '?')
126
127/** `1d10 fire`, `5d6 fire/radiant`, `3d8 · 6 types`, `force`. */
128function damageText(dice: string, types: string[]): string {
129 const t = types.length > 2 ? `${types.length} types` : types.join('/')
130 return dice ? (types.length > 2 ? `${dice} · ${t}` : `${dice} ${t}`) : t
131}
132
133interface Caster {
134 dc: string
135 attack: string
136}
137
138/** One spell row: the facts as a 2024 player reads them, the roll with the class's own number. */
139export function spellRow(key: string, name: string, source: string | undefined, e: Entry, opts: { cls: string; caster: Caster | null; dm: boolean; always: boolean; otherLevel?: boolean }): ActionRow {
140 const hit = spellFact(name, source, e.sheet.ruleset)
141 const base = { key, name, cls: opts.cls, dm: opts.dm, always: opts.always, written: '', badges: [] as ActionRow['badges'] }
142 if (!hit) return { ...base, effect: '', lead: '', facts: ['Not in the rules snapshot'], missing: true, source: source ?? '' }
143 const [level, , time, range, , duration, flagText, damage, save, diceText] = hit.row
144 const flags = flagText ? flagText.split(' ') : []
145 const types = damage ? damage.split(',') : []
146 const total = typeof e.derived?.total_level === 'number' ? e.derived.total_level : null
147 const dice = level === 0 ? cantripDice(diceText, total) : diceText
148 const pend = e.derivedPending ? '?' : ''
149 let effect = types.length ? damageText(dice, types) : flags.includes('heal') && dice ? `heal ${dice}${flags.includes('mod') ? ' + mod' : ''}` : ''
150 if (!effect && time !== 'Action') effect = time
151 const attack = flags.includes('melee') ? 'Melee' : flags.includes('ranged') ? 'Ranged' : ''
152 const roll = attack
153 ? `${attack} ATK${opts.caster ? ' ' + opts.caster.attack + pend : ''}`
154 : save
155 ? `${save} save${opts.caster ? ' DC ' + opts.caster.dc + pend : ''}`
156 : ''
157 const lead = [...(time !== 'Action' && effect !== time ? [time] : []), ...(roll ? [roll] : [])].join(' · ')
158 const facts = [
159 ...(opts.otherLevel ? [level === 0 ? 'Cantrip' : ORDINAL(level)] : []),
160 range,
161 ...(duration && duration !== 'Instant' ? [duration] : []),
162 ].filter(Boolean)
163 const badges: ActionRow['badges'] = [...(flags.includes('conc') ? ['conc' as const] : []), ...(flags.includes('ritual') ? ['ritual' as const] : [])]
164 return { ...base, effect, lead, facts, badges, missing: false, source: hit.source }
165}
166
167/**
168 * A weapon line as a player reads it: "+2 · 1d8 bludgeoning". The transcription
169 * writes "attack written +2, 1d8 bludgeoning; …"; only that prefix is taken off,
170 * the rest follows after a ·. Anything else is shown as written. No bonus is computed.
171 */
172export function weaponLine(note: string): string {
173 const m = /^attack written ([+-]\d+), (.+)$/.exec(note.trim())
174 return m ? [m[1], ...(m[2] ?? '').split('; ')].join(' · ') : note
175}
176
177/**
178 * Weapon Mastery (a 2024 class feature). Its `choice`, when written, names the
179 * weapons it covers ("Longsword, Handaxe"); without one, every weapon's
180 * mastery property is shown.
181 */
182function mastery(e: Entry): { all: boolean; names: string[] } | null {
183 const f = [...(e.sheet.features ?? []), ...(e.sheet.feats ?? [])].find((x: any) => norm(String(x.name ?? '')) === 'weapon mastery')
184 if (!f) return null
185 const names = typeof f.choice === 'string' ? f.choice.split(/,|;|\band\b/).map(norm).filter(Boolean) : []
186 return { all: !names.length, names }
187}
188const singular = (n: string) => (n.endsWith('s') ? n.slice(0, -1) : n)
189
190/** The weapon's data line: `Melee · Versatile 1d8 · Topple`, mastery only with Weapon Mastery. */
191export function weaponFacts(row: WeaponRow, withMastery: boolean): string[] {
192 const [, kind, , , versatile, props, range, masteryName] = row
193 const words = (props ? props.split(',') : []).map((p) =>
194 p === 'Versatile' && versatile ? `Versatile ${versatile}` : (p === 'Thrown' || p === 'Ammunition') && range ? `${p} ${range}` : p,
195 )
196 return [kind === 'ranged' ? 'Ranged' : 'Melee', ...words, ...(withMastery && masteryName ? [masteryName] : [])]
197}
198
199function weaponRows(e: Entry, dmNames: (name: string) => boolean): ActionRow[] {
200 const wm = mastery(e)
201 const out: ActionRow[] = []
202 ;(e.sheet.inventory ?? []).forEach((i: any, n: number) => {
203 const name = String(i.name ?? '')
204 const note = String(i.note ?? '')
205 const hit = weaponFact(name, i.source, e.sheet.ruleset)
206 const writtenAttack = /^attack written /i.test(note.trim())
207 if (!hit && !writtenAttack) return
208 // The written bonus is the roll, labelled as written; the rest of the sheet's line follows the facts.
209 const parts = note ? weaponLine(note).split(' · ') : []
210 const lead = writtenAttack ? `ATK ${parts[0]} (as written)` : ''
211 const written = (writtenAttack ? parts.slice(1) : parts).join(' · ')
212 const base = { key: 'weapon-' + n, name, cls: '', dm: dmNames(name), always: false, lead, written, badges: [] as ActionRow['badges'] }
213 if (!hit) {
214 out.push({ ...base, effect: '', facts: ['Not in the rules snapshot'], missing: true, source: String(i.source ?? '') })
215 return
216 }
217 const [, , dice, type] = hit.row
218 const covered = !!wm && (wm.all || wm.names.some((w) => singular(w) === singular(norm(name))))
219 const effect = [dice, type].filter(Boolean).join(' ')
220 // A sheet line that only repeats the rules is not shown twice.
221 const same = written.toLowerCase() === effect.toLowerCase()
222 out.push({ ...base, written: same ? '' : written, effect, facts: weaponFacts(hit.row, covered), missing: false, source: hit.source })
223 })
224 return out
225}
226
227/**
228 * The turn menu: Weapons, Cantrips, then each slot level (its pips in the
229 * heading), then Other magic (spells from species, feats or items, and any
230 * spell whose level neither the sheet nor the snapshot gives).
231 */
232export function actionGroups(e: Entry, slots: Map<number, { left: number; max: number; pending: boolean }>, dmNames: (name: string) => boolean): ActionGroup[] {
233 const groups: ActionGroup[] = []
234 const weapons = weaponRows(e, dmNames)
235 if (weapons.length) groups.push({ id: 'weapons', label: 'Weapons', slots: null, rows: weapons })
236 const casters = new Map<string, Caster>(((e.derived?.spellcasting ?? []) as any[]).map((c) => [String(c.class), { dc: String(c.save_dc ?? '?'), attack: signed(c.attack) }]))
237 const byLevel = new Map<number, ActionRow[]>()
238 const other: ActionRow[] = []
239 for (const sc of e.sheet.spellcasting?.by_class ?? []) {
240 const cls = String(sc.class ?? '')
241 const caster = casters.get(cls) ?? null
242 const add = (lvl: number | null, row: ActionRow) => {
243 if (lvl === null) other.push(row)
244 else byLevel.set(lvl, [...(byLevel.get(lvl) ?? []), row])
245 }
246 ;(sc.cantrips ?? []).forEach((c: any, i: number) => add(0, spellRow(`spell-${cls}-c${i}`, c.name, c.source, e, { cls, caster, dm: dmNames(c.name), always: false })))
247 ;(sc.spells ?? []).forEach((sp: any, i: number) => {
248 const level = typeof sp.level === 'number' ? sp.level : spellFact(sp.name, sp.source, e.sheet.ruleset)?.row[0] ?? null
249 add(level, spellRow(`spell-${cls}-${i}`, sp.name, sp.source, e, { cls, caster, dm: dmNames(sp.name), always: !!sp.always, otherLevel: level === null }))
250 })
251 }
252 for (const [lvl, rows] of [...byLevel].sort(([a], [b]) => a - b)) {
253 groups.push({ id: lvl === 0 ? 'cantrips' : 'level-' + lvl, label: lvl === 0 ? 'Cantrips' : `${ORDINAL(lvl)} level`, slots: lvl === 0 ? null : slots.get(lvl) ?? null, rows })
254 }
255 ;(e.sheet.spellcasting?.other ?? []).forEach((x: any, i: number) => other.push(spellRow('other-' + i, x.name, x.source, e, { cls: '', caster: null, dm: dmNames(x.name), always: false, otherLevel: true })))
256 if (other.length) groups.push({ id: 'other', label: 'Other magic', slots: null, rows: other })
257 return groups
258}
259hooks/pixel.ts 146 lines1// The pixel instruments of the surfaces that have `Svg` (Desktop, VS Code,
2// mobile): the HP numerals and gauge, and pips. Numerals and cells only, never
3// a word: every word stays the host's type. The terminal draws the same
4// gauges as glyph runs (look.ts). Pure: no mods API.
5//
6// Drawn at a fixed integer scale (2 or 4 CSS px per art px) with the markup's
7// own width and height, so nothing rescales a stroke. No frame, no panel, no
8// bloom: the Svg paints nothing but its pixels. Dark is the intended look; on
9// a light theme the pips vanish and the counts beside them stay.
10
11import { hpSegments, segments, TOKENS, type HpState, type Segment, type Tone } from './look.ts'
12
13/** The Svg's own colours, beside look.ts's tokens: the Text tree uses the host's ink and `dimColor` instead. */
14const SVG = { ink: '#ededed', dim: '#9a9a9a', void: '#000000' } as const
15
16/** The instrument's width budget in CSS px: the body of a 360 px Desktop pane. */
17const MAX_W = 300
18
19/**
20 * The numeral font: 5×7, the HD44780 dot-matrix digits (a 1979 instrument's
21 * own), a slashed zero, `1` `/` `-` three wide.
22 */
23export const FONT: Record<string, string[]> = {
24 '0': ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
25 '1': ['.#.', '##.', '.#.', '.#.', '.#.', '.#.', '###'],
26 '2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
27 '3': ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
28 '4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
29 '5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
30 '6': ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
31 '7': ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
32 '8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
33 '9': ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
34 '/': ['..#', '..#', '.#.', '.#.', '.#.', '#..', '#..'],
35 '+': ['.....', '..#..', '..#..', '#####', '..#..', '..#..', '.....'],
36 '-': ['...', '...', '...', '###', '...', '...', '...'],
37 '?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'],
38}
39
40/** A character's glyph; an unknown one draws as `?`. */
41const glyph = (ch: string) => FONT[ch] ?? FONT['?']!
42
43/** Width in art px: the glyphs' widths plus one between. */
44export const textWidth = (s: string) => [...s].reduce((n, ch) => n + glyph(ch)[0]!.length + 1, 0) - 1
45
46/** A drawing: the markup, what it says, its size in CSS px. */
47export interface Art {
48 source: string
49 alt: string
50 width: number
51 height: number
52}
53
54/** Rectangles on an integer grid, merged into one `<path>` per run of a colour. */
55export function painter() {
56 const runs: { fill: string; d: string }[] = []
57 const rect = (x: number, y: number, w: number, h: number, fill: string) => {
58 if (w <= 0 || h <= 0) return
59 const d = `M${x} ${y}h${w}v${h}h${-w}z`
60 const top = runs[runs.length - 1]
61 if (top && top.fill === fill) top.d += d
62 else runs.push({ fill, d })
63 }
64 /** Draws `s` with its top-left at (x, y), each art px `scale` CSS px. */
65 const text = (s: string, x: number, y: number, scale: number, fill: string) => {
66 let gx = 0
67 for (const ch of s) {
68 const g = glyph(ch)
69 g.forEach((bits, row) => {
70 for (const m of bits.matchAll(/#+/g)) {
71 rect(x + (gx + m.index!) * scale, y + row * scale, m[0].length * scale, scale, fill)
72 }
73 })
74 gx += g[0]!.length + 1
75 }
76 }
77 const svg = (w: number, h: number) =>
78 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" shape-rendering="crispEdges">` +
79 runs.map((r) => `<path fill="${r.fill}" d="${r.d}"/>`).join('') +
80 '</svg>'
81 return { rect, text, svg }
82}
83
84const colourOf = (tone: Tone) => (tone === 'ink' ? SVG.ink : TOKENS[tone])
85
86/**
87 * Cells `cw`×`ch` art px at scale 2, one art px apart, from (x, y): full solid
88 * in the tone, spent a checkerboard of it, temp solid cyan. Returns the width.
89 */
90function cells(p: ReturnType<typeof painter>, segs: Segment[], tone: Tone, x: number, y: number, cw: number, ch: number): number {
91 const fill = colourOf(tone)
92 segs.forEach((seg, k) => {
93 const cx = x + k * (cw + 1) * 2
94 if (seg !== 'spent') return p.rect(cx, y, cw * 2, ch * 2, seg === 'temp' ? TOKENS.cyan : fill)
95 for (let j = 0; j < ch; j++) for (let i = 0; i < cw; i++) if ((i + j) % 2 === 0) p.rect(cx + i * 2, y + j * 2, 2, 2, fill)
96 })
97 return Math.max(0, (segs.length * (cw + 1) - 1) * 2)
98}
99
100/** A gauge or pips alone: cells at scale 2. */
101export function cellsSvg(segs: Segment[], tone: Tone, cw: number, ch: number, alt: string): Art {
102 const p = painter()
103 const width = cells(p, segs, tone, 0, 0, cw, ch)
104 return { source: p.svg(width, ch * 2), alt, width, height: ch * 2 }
105}
106
107/**
108 * The HP instrument: the current HP at 4× in the state's colour (knocked out,
109 * void on a plate of that colour under a quarter or while `flash`), `/max` dim
110 * at 2× on its baseline, temp cyan, then the gauge centred beside it. Always
111 * 36 CSS px high.
112 */
113export function hpSvg(current: number, max: number, temp: number, state: HpState, opts: { flash?: boolean } = {}): Art {
114 const p = painter()
115 const cur = String(current)
116 const heroW = textWidth(cur) * 4
117 let x: number
118 if (opts.flash || state === 'alert') {
119 p.rect(0, 0, heroW + 8, 36, TOKENS[state])
120 p.text(cur, 4, 4, 4, SVG.void)
121 x = heroW + 10
122 } else {
123 p.text(cur, 0, 4, 4, TOKENS[state])
124 x = heroW + 4
125 }
126 const rest = '/' + max
127 p.text(rest, x, 18, 2, SVG.dim)
128 x += textWidth(rest) * 2
129 if (temp > 0) {
130 const t = '+' + temp
131 p.text(t, x + 6, 18, 2, TOKENS.cyan)
132 x += 6 + textWidth(t) * 2
133 }
134 x += 8
135 const room = Math.max(8, Math.min(20, Math.floor((MAX_W - x) / 10)))
136 const width = x + cells(p, hpSegments(current, max, temp, room), state, x, 11, 4, 7)
137 return { source: p.svg(width, 36), alt: `HP ${current} of ${max}` + (temp > 0 ? `, +${temp} temp` : ''), width, height: 36 }
138}
139
140/** Pips: 4×4 cells, one per use while they fit in 10, else a 10-cell meter. */
141export function pipsSvg(left: number, max: number, tone: Tone, alt: string): Art {
142 const n = Math.min(max, 10)
143 const full = n === max ? left : Math.round((10 * left) / max)
144 return cellsSvg(segments(full, n), tone, 4, 4, alt)
145}
146hooks/facts.ts 997 lines1// GENERATED by tools/toa-sheet/facts/build.py: do not edit. Regenerate after a
2// rules data refresh (tools/rules/README.md, "Refresh the data").
3// Source: 5etools-mirror-3/5etools-src @ 3a09c05a3a3be94423cd2b3c33936034eeae02f2,
4// the rules server's own pinned snapshot (tools/rules/pins.env), every file checked
5// against tools/rules/data-manifest.json. Facts only: no descriptions, no prose.
6//
7// Keys are `name|source`, the name lower-cased with every run of other characters
8// a space (the server's `norm`). Values are positional (SpellRow, WeaponRow).
9
10export const DATA_REV = '3a09c05a3a3be94423cd2b3c33936034eeae02f2'
11
12/**
13 * [level, school letter, casting time, range or area, components (VSM), duration,
14 * flags (conc ritual melee ranged heal mod), damage types, save ability, dice].
15 * Dice: a slot spell's base ("2d8"); a cantrip's table by character level
16 * ("1:1d10 5:2d10 11:3d10 17:4d10"; "/" between alternatives); "" when the data
17 * gives no single base.
18 */
19export type SpellRow = readonly [number, string, string, string, string, string, string, string, string, string]
20
21/** [category, melee or ranged, damage dice, damage type, versatile dice, properties, range, mastery]. */
22export type WeaponRow = readonly [string, string, string, string, string, string, string, string]
23
24export const SPELLS: Record<string, SpellRow> = {
25"abi dalzim s horrid wilting|xge":[8,"N","Action","150 ft","VSM","Instant","","necrotic","CON",""],
26"absorb elements|xge":[1,"A","Reaction","Self","S","1 round","","","",""],
27"acid splash|phb":[0,"C","Action","60 ft","VS","Instant","","acid","DEX","1:1d6 5:2d6 11:3d6 17:4d6"],
28"acid splash|xphb":[0,"V","Action","60 ft","VS","Instant","","acid","DEX","1:1d6 5:2d6 11:3d6 17:4d6"],
29"aganazzar s scorcher|xge":[2,"V","Action","30 ft","VSM","Instant","","fire","DEX","3d8"],
30"aid|phb":[2,"A","Action","30 ft","VSM","8 hr","heal","","",""],
31"aid|xphb":[2,"A","Action","30 ft","VSM","8 hr","heal","","",""],
32"alarm|phb":[1,"A","1 min","30 ft","VSM","8 hr","ritual","","",""],
33"alarm|xphb":[1,"A","1 min","30 ft","VSM","8 hr","ritual","","",""],
34"alter self|phb":[2,"T","Action","Self","VS","1 hr","conc","bludgeoning,piercing,slashing","",""],
35"alter self|xphb":[2,"T","Action","Self","VS","1 hr","conc","bludgeoning,piercing,slashing","",""],
36"animal friendship|phb":[1,"E","Action","30 ft","VSM","24 hr","","","WIS",""],
37"animal friendship|xphb":[1,"E","Action","30 ft","VSM","24 hr","","","WIS",""],
38"animal messenger|phb":[2,"E","Action","30 ft","VSM","24 hr","ritual","","",""],
39"animal messenger|xphb":[2,"E","Action","30 ft","VSM","24 hr","ritual","","CHA",""],
40"animal shapes|phb":[8,"T","Action","30 ft","VS","24 hr","conc","","",""],
41"animal shapes|xphb":[8,"T","Action","30 ft","VS","24 hr","","","",""],
42"animate dead|phb":[3,"N","1 min","10 ft","VSM","Instant","","","",""],
43"animate dead|xphb":[3,"N","1 min","10 ft","VSM","Instant","","","",""],
44"animate objects|phb":[5,"T","Action","120 ft","VS","1 min","conc","bludgeoning,piercing,slashing","",""],
45"animate objects|xphb":[5,"T","Action","120 ft","VS","1 min","conc","bludgeoning,piercing,slashing","",""],
46"antilife shell|phb":[5,"A","Action","10 ft radius","VS","1 hr","conc","","",""],
47"antilife shell|xphb":[5,"A","Action","10 ft emanation","VS","1 hr","conc","","",""],
48"antimagic field|phb":[8,"A","Action","10 ft sphere","VSM","1 hr","conc","","",""],
49"antimagic field|xphb":[8,"A","Action","10 ft emanation","VSM","1 hr","conc","","",""],
50"antipathy sympathy|phb":[8,"E","1 hr","60 ft","VSM","10 days","","","WIS",""],
51"antipathy sympathy|xphb":[8,"E","1 hr","60 ft","VSM","10 days","","","WIS",""],
52"arcane eye|phb":[4,"D","Action","30 ft","VSM","1 hr","conc","","",""],
53"arcane eye|xphb":[4,"D","Action","30 ft","VSM","1 hr","conc","","",""],
54"arcane gate|phb":[6,"C","Action","500 ft","VS","10 min","conc","","",""],
55"arcane gate|xphb":[6,"C","Action","500 ft","VS","10 min","conc","","",""],
56"arcane lock|phb":[2,"A","Action","Touch","VSM","Until dispelled","","","",""],
57"arcane lock|xphb":[2,"A","Action","Touch","VSM","Until dispelled","","","",""],
58"arcane vigor|xphb":[2,"A","Bonus Action","Self","VS","Instant","heal","","",""],
59"armor of agathys|phb":[1,"A","Action","Self","VSM","1 hr","","cold","",""],
60"armor of agathys|xphb":[1,"A","Bonus Action","Self","VSM","1 hr","","cold","",""],
61"arms of hadar|phb":[1,"C","Action","10 ft radius","VS","Instant","","necrotic","STR","2d6"],
62"arms of hadar|xphb":[1,"C","Action","10 ft emanation","VS","Instant","","necrotic","STR","2d6"],
63"astral projection|phb":[9,"N","1 hr","10 ft","VSM","Special","","","",""],
64"astral projection|xphb":[9,"N","1 hr","10 ft","VSM","Until dispelled","","","",""],
65"augury|phb":[2,"D","1 min","Self","VSM","Instant","ritual","","",""],
66"augury|xphb":[2,"D","1 min","Self","VSM","Instant","ritual","","",""],
67"aura of life|phb":[4,"A","Action","30 ft radius","V","10 min","conc heal","","",""],
68"aura of life|xphb":[4,"A","Action","30 ft emanation","V","10 min","conc heal","","",""],
69"aura of purity|phb":[4,"A","Action","30 ft radius","V","10 min","conc","","",""],
70"aura of purity|xphb":[4,"A","Action","30 ft emanation","V","10 min","conc","","",""],
71"aura of vitality|phb":[3,"V","Action","30 ft radius","V","1 min","conc heal","","",""],
72"aura of vitality|xphb":[3,"A","Action","30 ft emanation","V","1 min","conc heal","","",""],
73"awaken|phb":[5,"T","8 hr","Touch","VSM","Instant","","","",""],
74"awaken|xphb":[5,"T","8 hr","Touch","VSM","Instant","","","",""],
75"bane|phb":[1,"E","Action","30 ft","VSM","1 min","conc","","CHA",""],
76"bane|xphb":[1,"E","Action","30 ft","VSM","1 min","conc","","CHA",""],
77"banishing smite|phb":[5,"A","Bonus Action","Self","V","1 min","conc","force","",""],
78"banishing smite|xphb":[5,"C","Bonus Action","Self","V","1 min","conc","force","",""],
79"banishment|phb":[4,"A","Action","60 ft","VSM","1 min","conc","","CHA",""],
80"banishment|xphb":[4,"A","Action","30 ft","VSM","1 min","conc","","CHA",""],
81"barkskin|phb":[2,"T","Action","Touch","VSM","1 hr","conc","","",""],
82"barkskin|xphb":[2,"T","Bonus Action","Touch","VSM","1 hr","","","",""],
83"beacon of hope|phb":[3,"A","Action","30 ft","VS","1 min","conc heal","","",""],
84"beacon of hope|xphb":[3,"A","Action","30 ft","VS","1 min","conc heal","","",""],
85"beast bond|xge":[1,"D","Action","Touch","VSM","10 min","conc","","",""],
86"beast sense|phb":[2,"D","Action","Touch","S","1 hr","conc ritual","","",""],
87"beast sense|xphb":[2,"D","Action","Touch","S","1 hr","conc ritual","","",""],
88"befuddlement|xphb":[8,"E","Action","150 ft","VSM","Instant","","psychic","INT",""],
89"bestow curse|phb":[3,"N","Action","Touch","VS","1 min","conc","necrotic","WIS",""],
90"bestow curse|xphb":[3,"N","Action","Touch","VS","1 min","conc","necrotic","WIS",""],
91"bigby s hand|phb":[5,"V","Action","120 ft","VSM","1 min","conc melee","bludgeoning,force","",""],
92"bigby s hand|xphb":[5,"V","Action","120 ft","VSM","1 min","conc melee","bludgeoning,force","DEX/STR",""],
93"blade barrier|phb":[6,"V","Action","90 ft","VS","10 min","conc","slashing","DEX",""],
94"blade barrier|xphb":[6,"V","Action","90 ft","VS","10 min","conc","force","DEX",""],
95"blade of disaster|tce":[9,"C","Bonus Action","60 ft","VS","1 min","conc melee","force","",""],
96"blade ward|phb":[0,"A","Action","Self","VS","1 round","","","",""],
97"blade ward|xphb":[0,"A","Action","Self","VS","1 min","conc","","",""],
98"bless|phb":[1,"E","Action","30 ft","VSM","1 min","conc","","",""],
99"bless|xphb":[1,"E","Action","30 ft","VSM","1 min","conc","","",""],
100"blight|phb":[4,"N","Action","30 ft","VS","Instant","","necrotic","CON","8d8"],
101"blight|xphb":[4,"N","Action","30 ft","VS","Instant","","necrotic","CON","8d8"],
102"blinding smite|phb":[3,"V","Bonus Action","Self","V","1 min","conc","radiant","CON",""],
103"blinding smite|xphb":[3,"V","Bonus Action","Self","V","1 min","","radiant","CON","3d8"],
104"blindness deafness|phb":[2,"N","Action","30 ft","V","1 min","","","CON",""],
105"blindness deafness|xphb":[2,"T","Action","120 ft","V","1 min","","","CON",""],
106"blink|phb":[3,"T","Action","Self","VS","1 min","","","",""],
107"blink|xphb":[3,"T","Action","Self","VS","1 min","","","",""],
108"blur|phb":[2,"I","Action","Self","V","1 min","conc","","",""],
109"blur|xphb":[2,"I","Action","Self","V","1 min","conc","","",""],
110"bones of the earth|xge":[6,"T","Action","120 ft","VS","Instant","","bludgeoning","DEX",""],
111"booming blade|tce":[0,"V","Action","5 ft radius","SM","1 round","melee","thunder","","1:1d8/ 5:2d8/1d8 11:3d8/2d8 17:4d8/3d8"],
112"branding smite|phb":[2,"V","Bonus Action","Self","V","1 min","conc","radiant","","2d6"],
113"burning hands|phb":[1,"V","Action","15 ft cone","VS","Instant","","fire","DEX","3d6"],
114"burning hands|xphb":[1,"V","Action","15 ft cone","VS","Instant","","fire","DEX","3d6"],
115"call lightning|phb":[3,"C","Action","120 ft","VS","10 min","conc","lightning","DEX","3d10"],
116"call lightning|xphb":[3,"C","Action","120 ft","VS","10 min","conc","lightning","DEX","3d10"],
117"calm emotions|phb":[2,"E","Action","60 ft","VS","1 min","conc","","CHA",""],
118"calm emotions|xphb":[2,"E","Action","60 ft","VS","1 min","conc","","CHA",""],
119"catapult|xge":[1,"T","Action","60 ft","S","Instant","","bludgeoning","DEX","3d8"],
120"catnap|xge":[3,"E","Action","30 ft","SM","10 min","","","",""],
121"cause fear|xge":[1,"N","Action","60 ft","V","1 min","conc","","WIS",""],
122"ceremony|xge":[1,"A","1 hr","Touch","VSM","Instant","ritual","","",""],
123"chain lightning|phb":[6,"V","Action","150 ft","VSM","Instant","","lightning","DEX",""],
124"chain lightning|xphb":[6,"V","Action","150 ft","VSM","Instant","","lightning","DEX",""],
125"chaos bolt|xge":[1,"V","Action","120 ft","VS","Instant","ranged","acid,cold,fire,force,lightning,poison,psychic,thunder","","2d8 + 1d6"],
126"charm monster|xge":[4,"E","Action","30 ft","VS","1 hr","","","WIS",""],
127"charm monster|xphb":[4,"E","Action","30 ft","VS","1 hr","","","WIS",""],
128"charm person|phb":[1,"E","Action","30 ft","VS","1 hr","","","WIS",""],
129"charm person|xphb":[1,"E","Action","30 ft","VS","1 hr","","","WIS",""],
130"chill touch|phb":[0,"N","Action","120 ft","VS","1 round","ranged","necrotic","","1:1d8 5:2d8 11:3d8 17:4d8"],
131"chill touch|xphb":[0,"N","Action","Touch","VS","Instant","melee","necrotic","","1:1d10 5:2d10 11:3d10 17:4d10"],
132"chromatic orb|phb":[1,"V","Action","90 ft","VSM","Instant","ranged","acid,cold,fire,lightning,poison,thunder","","3d8"],
133"chromatic orb|xphb":[1,"V","Action","90 ft","VSM","Instant","ranged","acid,cold,fire,lightning,poison,thunder","","3d8"],
134"circle of death|phb":[6,"N","Action","150 ft","VSM","Instant","","necrotic","CON","8d6"],
135"circle of death|xphb":[6,"N","Action","150 ft","VSM","Instant","","necrotic","CON","8d8"],
136"circle of power|phb":[5,"A","Action","30 ft radius","V","10 min","conc","","",""],
137"circle of power|xphb":[5,"A","Action","30 ft emanation","V","10 min","conc","","",""],
138"clairvoyance|phb":[3,"D","10 min","1 mile","VSM","10 min","conc","","",""],
139"clairvoyance|xphb":[3,"D","10 min","1 mile","VSM","10 min","conc","","",""],
140"clone|phb":[8,"N","1 hr","Touch","VSM","Instant","","","",""],
141"clone|xphb":[8,"N","1 hr","Touch","VSM","Instant","","","",""],
142"cloud of daggers|phb":[2,"C","Action","60 ft","VSM","1 min","conc","slashing","","4d4"],
143"cloud of daggers|xphb":[2,"C","Action","60 ft","VSM","1 min","conc","slashing","","4d4"],
144"cloudkill|phb":[5,"C","Action","120 ft","VS","10 min","conc","poison","CON","5d8"],
145"cloudkill|xphb":[5,"C","Action","120 ft","VS","10 min","conc","poison","CON","5d8"],
146"color spray|phb":[1,"I","Action","15 ft cone","VSM","1 round","","","",""],
147"color spray|xphb":[1,"I","Action","15 ft cone","VSM","Instant","","","CON",""],
148"command|phb":[1,"E","Action","60 ft","V","1 round","","","WIS",""],
149"command|xphb":[1,"E","Action","60 ft","V","Instant","","","WIS",""],
150"commune with nature|phb":[5,"D","1 min","Self","VS","Instant","ritual","","",""],
151"commune with nature|xphb":[5,"D","1 min","Self","VS","1 min","ritual","","",""],
152"commune|phb":[5,"D","1 min","Self","VSM","1 min","ritual","","",""],
153"commune|xphb":[5,"D","1 min","Self","VSM","1 min","ritual","","",""],
154"compelled duel|phb":[1,"E","Bonus Action","30 ft","V","1 min","conc","","WIS",""],
155"compelled duel|xphb":[1,"E","Bonus Action","30 ft","V","1 min","conc","","WIS",""],
156"comprehend languages|phb":[1,"D","Action","Self","VSM","1 hr","ritual","","",""],
157"comprehend languages|xphb":[1,"D","Action","Self","VSM","1 hr","ritual","","",""],
158"compulsion|phb":[4,"E","Action","30 ft","VS","1 min","conc","","WIS",""],
159"compulsion|xphb":[4,"E","Action","30 ft","VS","1 min","conc","","WIS",""],
160"cone of cold|phb":[5,"V","Action","60 ft cone","VSM","Instant","","cold","CON","8d8"],
161"cone of cold|xphb":[5,"V","Action","60 ft cone","VSM","Instant","","cold","CON","8d8"],
162"confusion|phb":[4,"E","Action","90 ft","VSM","1 min","conc","","WIS",""],
163"confusion|xphb":[4,"E","Action","90 ft","VSM","1 min","conc","","WIS",""],
164"conjure animals|phb":[3,"C","Action","60 ft","VS","1 hr","conc","","",""],
165"conjure animals|xphb":[3,"C","Action","60 ft","VS","10 min","conc","slashing","DEX","3d10"],
166"conjure barrage|phb":[3,"C","Action","60 ft cone","VSM","Instant","","","DEX",""],
167"conjure barrage|xphb":[3,"C","Action","60 ft cone","VSM","Instant","","force","DEX","5d8"],
168"conjure celestial|phb":[7,"C","1 min","90 ft","VS","1 hr","conc","","",""],
169"conjure celestial|xphb":[7,"C","Action","90 ft","VS","10 min","conc","radiant","DEX","6d12"],
170"conjure elemental|phb":[5,"C","1 min","90 ft","VSM","1 hr","conc","","",""],
171"conjure elemental|xphb":[5,"C","Action","60 ft","VS","10 min","conc","lightning,thunder,fire,cold","DEX",""],
172"conjure fey|phb":[6,"C","1 min","90 ft","VS","1 hr","conc","","",""],
173"conjure fey|xphb":[6,"C","Action","60 ft","VS","10 min","conc","psychic","","3d12"],
174"conjure minor elementals|phb":[4,"C","1 min","90 ft","VS","1 hr","conc","","",""],
175"conjure minor elementals|xphb":[4,"C","Action","15 ft emanation","VS","10 min","conc","acid,cold,fire,lightning","","2d8"],
176"conjure volley|phb":[5,"C","Action","150 ft","VSM","Instant","","","DEX",""],
177"conjure volley|xphb":[5,"C","Action","150 ft","VSM","Instant","","force","DEX",""],
178"conjure woodland beings|phb":[4,"C","Action","60 ft","VSM","1 hr","conc","","",""],
179"conjure woodland beings|xphb":[4,"C","Action","10 ft emanation","VS","10 min","conc","force","WIS","5d8"],
180"contact other plane|phb":[5,"D","1 min","Self","V","1 min","ritual","psychic","INT",""],
181"contact other plane|xphb":[5,"D","1 min","Self","V","1 min","ritual","psychic","INT",""],
182"contagion|phb":[5,"N","Action","Touch","VS","7 days","melee","","CON",""],
183"contagion|xphb":[5,"N","Action","Touch","VS","7 days","","necrotic","CON",""],
184"contingency|phb":[6,"V","10 min","Self","VSM","10 days","","","",""],
185"contingency|xphb":[6,"A","10 min","Self","VSM","10 days","","","",""],
186"continual flame|phb":[2,"V","Action","Touch","VSM","Until dispelled","","","",""],
187"continual flame|xphb":[2,"V","Action","Touch","VSM","Until dispelled","","","",""],
188"control flames|xge":[0,"T","Action","60 ft","S","Instant or 1 hr","","","",""],
189"control water|phb":[4,"T","Action","300 ft","VSM","10 min","conc","bludgeoning","STR",""],
190"control water|xphb":[4,"T","Action","300 ft","VSM","10 min","conc","bludgeoning","STR",""],
191"control weather|phb":[8,"T","10 min","5 miles radius","VSM","8 hr","conc","","",""],
192"control weather|xphb":[8,"T","10 min","5 miles sphere","VSM","8 hr","conc","","",""],
193"control winds|xge":[5,"T","Action","300 ft","VS","1 hr","conc","","STR",""],
194"cordon of arrows|phb":[2,"T","Action","5 ft","VSM","8 hr","","piercing","DEX",""],
195"cordon of arrows|xphb":[2,"T","Action","Touch","VSM","8 hr","","piercing","DEX",""],
196"counterspell|phb":[3,"A","Reaction","60 ft","S","Instant","","","",""],
197"counterspell|xphb":[3,"A","Reaction","60 ft","S","Instant","","","CON",""],
198"create bonfire|xge":[0,"C","Action","60 ft","VS","1 min","conc","fire","DEX","1:1d8 5:2d8 11:3d8 17:4d8"],
199"create food and water|phb":[3,"C","Action","30 ft","VS","Instant","","","",""],
200"create food and water|xphb":[3,"C","Action","30 ft","VS","Instant","","","",""],
201"create homunculus|xge":[6,"T","1 hr","Touch","VSM","Instant","","","",""],
202"create or destroy water|phb":[1,"T","Action","30 ft","VSM","Instant","","","",""],
203"create or destroy water|xphb":[1,"T","Action","30 ft","VSM","Instant","","","",""],
204"create undead|phb":[6,"N","1 min","10 ft","VSM","Instant","","","",""],
205"create undead|xphb":[6,"N","1 min","10 ft","VSM","Instant","","","",""],
206"creation|phb":[5,"I","1 min","30 ft","VSM","Special","","","",""],
207"creation|xphb":[5,"I","1 min","30 ft","VSM","Special","","","",""],
208"crown of madness|phb":[2,"E","Action","120 ft","VS","1 min","conc","","WIS",""],
209"crown of madness|xphb":[2,"E","Action","120 ft","VS","1 min","conc","","WIS",""],
210"crown of stars|xge":[7,"V","Action","Self","VS","1 hr","ranged","radiant","",""],
211"crusader s mantle|phb":[3,"V","Action","30 ft radius","V","1 min","conc","radiant","",""],
212"crusader s mantle|xphb":[3,"V","Action","30 ft emanation","V","1 min","conc","radiant","",""],
213"cure wounds|phb":[1,"V","Action","Touch","VS","Instant","heal mod","","","1d8"],
214"cure wounds|xphb":[1,"A","Action","Touch","VS","Instant","heal mod","","","2d8"],
215"dancing lights|phb":[0,"V","Action","120 ft","VSM","1 min","conc","","",""],
216"dancing lights|xphb":[0,"I","Action","120 ft","VSM","1 min","conc","","",""],
217"danse macabre|xge":[5,"N","Action","60 ft","VS","1 hr","conc","","",""],
218"darkness|phb":[2,"V","Action","60 ft","VM","10 min","conc","","",""],
219"darkness|xphb":[2,"V","Action","60 ft","VM","10 min","conc","","",""],
220"darkvision|phb":[2,"T","Action","Touch","VSM","8 hr","","","",""],
221"darkvision|xphb":[2,"T","Action","Touch","VSM","8 hr","","","",""],
222"dawn|xge":[5,"V","Action","60 ft","VSM","1 min","conc","radiant","CON",""],
223"daylight|phb":[3,"V","Action","60 ft","VS","1 hr","","","",""],
224"daylight|xphb":[3,"V","Action","60 ft","VS","1 hr","","","",""],
225"death ward|phb":[4,"A","Action","Touch","VS","8 hr","","","",""],
226"death ward|xphb":[4,"A","Action","Touch","VS","8 hr","","","",""],
227"delayed blast fireball|phb":[7,"V","Action","150 ft","VSM","1 min","conc","fire","DEX","12d6"],
228"delayed blast fireball|xphb":[7,"V","Action","150 ft","VSM","1 min","conc","fire","DEX","12d6"],
229"demiplane|phb":[8,"C","Action","60 ft","S","1 hr","","","",""],
230"demiplane|xphb":[8,"C","Action","60 ft","S","1 hr","","","",""],
231"destructive wave|phb":[5,"V","Action","30 ft radius","V","Instant","","necrotic,radiant,thunder","CON",""],
232"destructive wave|xphb":[5,"V","Action","30 ft emanation","V","Instant","","necrotic,radiant,thunder","CON",""],
233"detect evil and good|phb":[1,"D","Action","Self","VS","10 min","conc","","",""],
234"detect evil and good|xphb":[1,"D","Action","30 ft sphere","VS","10 min","conc","","",""],
235"detect magic|phb":[1,"D","Action","Self","VS","10 min","conc ritual","","",""],
236"detect magic|xphb":[1,"D","Action","30 ft sphere","VS","10 min","conc ritual","","",""],
237"detect poison and disease|phb":[1,"D","Action","Self","VSM","10 min","conc ritual","","",""],
238"detect poison and disease|xphb":[1,"D","Action","30 ft sphere","VSM","10 min","conc ritual","","",""],
239"detect thoughts|phb":[2,"D","Action","Self","VSM","1 min","conc","","WIS",""],
240"detect thoughts|xphb":[2,"D","Action","Self","VSM","1 min","conc","","WIS",""],
241"dimension door|phb":[4,"C","Action","500 ft","V","Instant","","force","",""],
242"dimension door|xphb":[4,"C","Action","500 ft","V","Instant","","force","",""],
243"disguise self|phb":[1,"I","Action","Self","VS","1 hr","","","",""],
244"disguise self|xphb":[1,"I","Action","Self","VS","1 hr","","","",""],
245"disintegrate|phb":[6,"T","Action","60 ft","VSM","Instant","","force","DEX","10d6 + 40"],
246"disintegrate|xphb":[6,"T","Action","60 ft","VSM","Instant","","force","DEX","10d6 + 40"],
247"dispel evil and good|phb":[5,"A","Action","Self","VSM","1 min","conc melee","","CHA",""],
248"dispel evil and good|xphb":[5,"A","Action","Self","VSM","1 min","conc","","CHA",""],
249"dispel magic|phb":[3,"A","Action","120 ft","VS","Instant","","","",""],
250"dispel magic|xphb":[3,"A","Action","120 ft","VS","Instant","","","",""],
251"dissonant whispers|phb":[1,"E","Action","60 ft","V","Instant","","psychic","WIS","3d6"],
252"dissonant whispers|xphb":[1,"E","Action","60 ft","V","Instant","","psychic","WIS","3d6"],
253"divination|phb":[4,"D","Action","Self","VSM","Instant","ritual","","",""],
254"divination|xphb":[4,"D","Action","Self","VSM","Instant","ritual","","",""],
255"divine favor|phb":[1,"V","Bonus Action","Self","VS","1 min","conc","radiant","",""],
256"divine favor|xphb":[1,"T","Bonus Action","Self","VS","1 min","","radiant","",""],
257"divine smite|xphb":[1,"V","Bonus Action","Self","V","Instant","","radiant","","2d8"],
258"divine word|phb":[7,"V","Bonus Action","30 ft","V","Instant","","","CHA",""],
259"divine word|xphb":[7,"V","Bonus Action","30 ft","V","Instant","","","CHA",""],
260"dominate beast|phb":[4,"E","Action","60 ft","VS","1 min","conc","","WIS",""],
261"dominate beast|xphb":[4,"E","Action","60 ft","VS","1 min","conc","","WIS",""],
262"dominate monster|phb":[8,"E","Action","60 ft","VS","1 hr","conc","","WIS",""],
263"dominate monster|xphb":[8,"E","Action","60 ft","VS","1 hr","conc","","WIS",""],
264"dominate person|phb":[5,"E","Action","60 ft","VS","1 min","conc","","WIS",""],
265"dominate person|xphb":[5,"E","Action","60 ft","VS","1 min","conc","","WIS",""],
266"dragon s breath|xge":[2,"T","Bonus Action","Touch","VSM","1 min","conc","acid,cold,fire,lightning,poison","DEX","3d6"],
267"dragon s breath|xphb":[2,"T","Bonus Action","Touch","VSM","1 min","conc","acid,cold,fire,lightning,poison","DEX","3d6"],
268"drawmij s instant summons|phb":[6,"C","1 min","Touch","VSM","Until dispelled","ritual","","",""],
269"drawmij s instant summons|xphb":[6,"C","1 min","Touch","VSM","Until dispelled","ritual","","",""],
270"dream of the blue veil|tce":[7,"C","10 min","20 ft","VSM","6 hr","","","",""],
271"dream|phb":[5,"I","1 min","Special","VSM","8 hr","","psychic","WIS",""],
272"dream|xphb":[5,"I","1 min","Self","VSM","8 hr","","psychic","WIS",""],
273"druid grove|xge":[6,"A","10 min","Touch","VSM","24 hr","","","",""],
274"druidcraft|phb":[0,"T","Action","30 ft","VS","Instant","","","",""],
275"druidcraft|xphb":[0,"T","Action","30 ft","VS","Instant","","","",""],
276"dust devil|xge":[2,"C","Action","60 ft","VSM","1 min","conc","bludgeoning","STR","1d8"],
277"earth tremor|xge":[1,"V","Action","10 ft","VS","Instant","","bludgeoning","DEX","1d6"],
278"earthbind|xge":[2,"T","Action","300 ft","V","1 min","conc","","STR",""],
279"earthquake|phb":[8,"V","Action","500 ft","VSM","1 min","conc","bludgeoning","CON/DEX",""],
280"earthquake|xphb":[8,"T","Action","500 ft","VSM","1 min","conc","bludgeoning","CON/DEX",""],
281"eldritch blast|phb":[0,"V","Action","120 ft","VS","Instant","ranged","force","",""],
282"eldritch blast|xphb":[0,"V","Action","120 ft","VS","Instant","ranged","force","",""],
283"elemental bane|xge":[4,"T","Action","90 ft","VS","1 min","conc","acid,cold,fire,lightning,thunder","CON",""],
284"elemental weapon|phb":[3,"T","Action","Touch","VS","1 hr","conc","acid,cold,fire,lightning,thunder","","1d4"],
285"elemental weapon|xphb":[3,"T","Action","Touch","VS","1 hr","conc","acid,cold,fire,lightning,thunder","",""],
286"elementalism|xphb":[0,"T","Action","30 ft","VS","Instant","","","",""],
287"enemies abound|xge":[3,"E","Action","120 ft","VS","1 min","conc","","INT",""],
288"enervation|xge":[5,"N","Action","60 ft","VS","1 min","conc","necrotic","DEX","4d8;2d8"],
289"enhance ability|phb":[2,"T","Action","Touch","VSM","1 hr","conc","","",""],
290"enhance ability|xphb":[2,"T","Action","Touch","VSM","1 hr","conc","","",""],
291"enlarge reduce|phb":[2,"T","Action","30 ft","VSM","1 min","conc","","CON",""],
292"enlarge reduce|xphb":[2,"T","Action","30 ft","VSM","1 min","conc","","CON",""],
293"ensnaring strike|phb":[1,"C","Bonus Action","Self","V","1 min","conc","piercing","STR","1d6"],
294"ensnaring strike|xphb":[1,"C","Bonus Action","Self","V","1 min","conc","piercing","STR","1d6"],
295"entangle|phb":[1,"C","Action","90 ft","VS","1 min","conc","","STR",""],
296"entangle|xphb":[1,"C","Action","90 ft","VS","1 min","conc","","STR",""],
297"enthrall|phb":[2,"E","Action","60 ft","VS","1 min","","","WIS",""],
298"enthrall|xphb":[2,"E","Action","60 ft","VS","1 min","conc","","WIS",""],
299"erupting earth|xge":[3,"T","Action","120 ft","VSM","Instant","","bludgeoning","DEX","3d12"],
300"etherealness|phb":[7,"T","Action","Self","VS","8 hr","","force","",""],
301"etherealness|xphb":[7,"C","Action","Self","VS","8 hr","","force","",""],
302"evard s black tentacles|phb":[4,"C","Action","90 ft","VSM","1 min","conc","bludgeoning","DEX",""],
303"evard s black tentacles|xphb":[4,"C","Action","90 ft","VSM","1 min","conc","bludgeoning","STR",""],
304"expeditious retreat|phb":[1,"T","Bonus Action","Self","VS","10 min","conc","","",""],
305"expeditious retreat|xphb":[1,"T","Bonus Action","Self","VS","10 min","conc","","",""],
306"eyebite|phb":[6,"N","Action","Self","VS","1 min","conc","","WIS",""],
307"eyebite|xphb":[6,"N","Action","Self","VS","1 min","conc","","WIS",""],
308"fabricate|phb":[4,"T","10 min","120 ft","VS","Instant","","","",""],
309"fabricate|xphb":[4,"T","10 min","120 ft","VS","Instant","","","",""],
310"faerie fire|phb":[1,"V","Action","60 ft","V","1 min","conc","","DEX",""],
311"faerie fire|xphb":[1,"V","Action","60 ft","V","1 min","conc","","DEX",""],
312"false life|phb":[1,"N","Action","Self","VSM","1 hr","","","",""],
313"false life|xphb":[1,"N","Action","Self","VSM","Instant","","","",""],
314"far step|xge":[5,"C","Bonus Action","Self","V","1 min","conc","","",""],
315"fear|phb":[3,"I","Action","30 ft cone","VSM","1 min","conc","","WIS",""],
316"fear|xphb":[3,"I","Action","30 ft cone","VSM","1 min","conc","","WIS",""],
317"feather fall|phb":[1,"T","Reaction","60 ft","VM","1 min","","","",""],
318"feather fall|xphb":[1,"T","Reaction","60 ft","VM","1 min","","","",""],
319"feeblemind|phb":[8,"E","Action","150 ft","VSM","Instant","","psychic","INT",""],
320"feign death|phb":[3,"N","Action","Touch","VSM","1 hr","ritual","","",""],
321"feign death|xphb":[3,"N","Action","Touch","VSM","1 hr","ritual","","",""],
322"find familiar|phb":[1,"C","1 hr","10 ft","VSM","Instant","ritual","","",""],
323"find familiar|xphb":[1,"C","1 hr","10 ft","VSM","Instant","ritual","","",""],
324"find greater steed|xge":[4,"C","10 min","30 ft","VS","Instant","","","",""],
325"find steed|phb":[2,"C","10 min","30 ft","VS","Instant","","","",""],
326"find steed|xphb":[2,"C","Action","30 ft","VS","Instant","","","",""],
327"find the path|phb":[6,"D","1 min","Self","VSM","1 day","conc","","",""],
328"find the path|xphb":[6,"D","1 min","Self","VSM","1 day","conc","","",""],
329"find traps|phb":[2,"D","Action","120 ft","VS","Instant","","","",""],
330"find traps|xphb":[2,"D","Action","120 ft","VS","Instant","","","",""],
331"finger of death|phb":[7,"N","Action","60 ft","VS","Instant","","necrotic","CON",""],
332"finger of death|xphb":[7,"N","Action","60 ft","VS","Instant","","necrotic","CON",""],
333"fire bolt|phb":[0,"V","Action","120 ft","VS","Instant","ranged","fire","","1:1d10 5:2d10 11:3d10 17:4d10"],
334"fire bolt|xphb":[0,"V","Action","120 ft","VS","Instant","ranged","fire","","1:1d10 5:2d10 11:3d10 17:4d10"],
335"fire shield|phb":[4,"V","Action","Self","VSM","10 min","","cold,fire","",""],
336"fire shield|xphb":[4,"V","Action","Self","VSM","10 min","","cold,fire","",""],
337"fire storm|phb":[7,"V","Action","150 ft","VS","Instant","","fire","DEX",""],
338"fire storm|xphb":[7,"V","Action","150 ft","VS","Instant","","fire","DEX",""],
339"fireball|phb":[3,"V","Action","150 ft","VSM","Instant","","fire","DEX","8d6"],
340"fireball|xphb":[3,"V","Action","150 ft","VSM","Instant","","fire","DEX","8d6"],
341"flame arrows|xge":[3,"T","Action","Touch","VS","1 hr","conc","fire","",""],
342"flame blade|phb":[2,"V","Bonus Action","Self","VSM","10 min","conc melee","fire","","3d6"],
343"flame blade|xphb":[2,"V","Bonus Action","Self","VSM","10 min","conc melee","fire","","3d6"],
344"flame strike|phb":[5,"V","Action","60 ft","VSM","Instant","","fire,radiant","DEX","4d6"],
345"flame strike|xphb":[5,"V","Action","60 ft","VSM","Instant","","fire,radiant","DEX","5d6"],
346"flaming sphere|phb":[2,"C","Action","60 ft","VSM","1 min","conc","fire","DEX","2d6"],
347"flaming sphere|xphb":[2,"C","Action","60 ft","VSM","1 min","conc","fire","DEX","2d6"],
348"flesh to stone|phb":[6,"T","Action","60 ft","VSM","1 min","conc","","CON",""],
349"flesh to stone|xphb":[6,"T","Action","60 ft","VSM","1 min","conc","","CON",""],
350"fly|phb":[3,"T","Action","Touch","VSM","10 min","conc","","",""],
351"fly|xphb":[3,"T","Action","Touch","VSM","10 min","conc","","",""],
352"fog cloud|phb":[1,"C","Action","120 ft","VS","1 hr","conc","","",""],
353"fog cloud|xphb":[1,"C","Action","120 ft","VS","1 hr","conc","","",""],
354"forbiddance|phb":[6,"A","10 min","Touch","VSM","1 day","ritual","necrotic,radiant","",""],
355"forbiddance|xphb":[6,"A","10 min","Touch","VSM","1 day","ritual","necrotic,radiant","",""],
356"forcecage|phb":[7,"V","Action","100 ft","VSM","1 hr","","","CHA",""],
357"forcecage|xphb":[7,"V","Action","100 ft","VSM","1 hr","conc","","CHA",""],
358"foresight|phb":[9,"D","1 min","Touch","VSM","8 hr","","","",""],
359"foresight|xphb":[9,"D","1 min","Touch","VSM","8 hr","","","",""],
360"fount of moonlight|xphb":[4,"V","Action","Self","VS","10 min","conc","radiant","CON",""],
361"freedom of movement|phb":[4,"A","Action","Touch","VSM","1 hr","","","",""],
362"freedom of movement|xphb":[4,"A","Action","Touch","VSM","1 hr","","","",""],
363"friends|phb":[0,"E","Action","Self","SM","1 min","conc","","",""],
364"friends|xphb":[0,"E","Action","10 ft","SM","1 min","conc","","WIS",""],
365"frostbite|xge":[0,"V","Action","60 ft","VS","Instant","","cold","CON","1:1d6 5:2d6 11:3d6 17:4d6"],
366"gaseous form|phb":[3,"T","Action","Touch","VSM","1 hr","conc","","",""],
367"gaseous form|xphb":[3,"T","Action","Touch","VSM","1 hr","conc","","",""],
368"gate|phb":[9,"C","Action","60 ft","VSM","1 min","conc","","",""],
369"gate|xphb":[9,"C","Action","60 ft","VSM","1 min","conc","","",""],
370"geas|phb":[5,"E","1 min","60 ft","V","30 days","","psychic","WIS",""],
371"geas|xphb":[5,"E","1 min","60 ft","V","30 days","","psychic","WIS",""],
372"gentle repose|phb":[2,"N","Action","Touch","VSM","10 days","ritual","","",""],
373"gentle repose|xphb":[2,"N","Action","Touch","VSM","10 days","ritual","","",""],
374"giant insect|phb":[4,"T","Action","30 ft","VS","10 min","conc","","",""],
375"giant insect|xphb":[4,"C","Action","60 ft","VS","10 min","conc","","",""],
376"glibness|phb":[8,"T","Action","Self","V","1 hr","","","",""],
377"glibness|xphb":[8,"E","Action","Self","V","1 hr","","","",""],
378"globe of invulnerability|phb":[6,"A","Action","10 ft radius","VSM","1 min","conc","","",""],
379"globe of invulnerability|xphb":[6,"A","Action","10 ft emanation","VSM","1 min","conc","","",""],
380"glyph of warding|phb":[3,"A","1 hr","Touch","VSM","Until dispelled or triggered","","acid,cold,fire,lightning,thunder","DEX","5d8"],
381"glyph of warding|xphb":[3,"A","1 hr","Touch","VSM","Until dispelled or triggered","","acid,cold,fire,lightning,thunder","DEX","5d8"],
382"goodberry|phb":[1,"T","Action","Touch","VSM","Instant","heal","","",""],
383"goodberry|xphb":[1,"C","Action","Touch","VSM","24 hr","heal","","",""],
384"grasping vine|phb":[4,"C","Bonus Action","30 ft","VS","1 min","conc","","DEX",""],
385"grasping vine|xphb":[4,"C","Bonus Action","60 ft","VS","1 min","conc melee","bludgeoning","",""],
386"grease|phb":[1,"C","Action","60 ft","VSM","1 min","","","DEX",""],
387"grease|xphb":[1,"C","Action","60 ft","VSM","1 min","","","DEX",""],
388"greater invisibility|phb":[4,"I","Action","Touch","VS","1 min","conc","","",""],
389"greater invisibility|xphb":[4,"I","Action","Touch","VS","1 min","conc","","",""],
390"greater restoration|phb":[5,"A","Action","Touch","VSM","Instant","","","",""],
391"greater restoration|xphb":[5,"A","Action","Touch","VSM","Instant","","","",""],
392"green flame blade|tce":[0,"V","Action","5 ft radius","SM","Instant","melee","fire","","1:{{spellcasting_mod}}/ 5:1d8 + {{spellcasting_mod}}/1d8 11:2d8 + {{spellcasting_mod}}/2d8 17:3d8 + {{spellcasting_mod}}/3d8"],
393"guardian of faith|phb":[4,"C","Action","30 ft","V","8 hr","","radiant","DEX",""],
394"guardian of faith|xphb":[4,"C","Action","30 ft","V","8 hr","","radiant","DEX",""],
395"guardian of nature|xge":[4,"T","Bonus Action","Self","V","1 min","conc","force","",""],
396"guards and wards|phb":[6,"A","10 min","Touch","VSM","24 hr","","","",""],
397"guards and wards|xphb":[6,"A","1 hr","Touch","VSM","24 hr","","","",""],
398"guidance|phb":[0,"D","Action","Touch","VS","1 min","conc","","",""],
399"guidance|xphb":[0,"D","Action","Touch","VS","1 min","conc","","",""],
400"guiding bolt|phb":[1,"V","Action","120 ft","VS","1 round","ranged","radiant","","4d6"],
401"guiding bolt|xphb":[1,"V","Action","120 ft","VS","1 round","ranged","radiant","","4d6"],
402"gust of wind|phb":[2,"V","Action","60 ft line","VSM","1 min","conc","","STR",""],
403"gust of wind|xphb":[2,"V","Action","60 ft line","VSM","1 min","conc","","STR",""],
404"gust|xge":[0,"T","Action","30 ft","VS","Instant","","","STR",""],
405"hail of thorns|phb":[1,"C","Bonus Action","Self","V","1 min","conc","piercing","DEX","1d10"],
406"hail of thorns|xphb":[1,"C","Bonus Action","Self","V","Instant","","piercing","DEX","1d10"],
407"hallow|phb":[5,"V","24 hr","Touch","VSM","Until dispelled","","","CHA",""],
408"hallow|xphb":[5,"A","24 hr","Touch","VSM","Until dispelled","","","",""],
409"hallucinatory terrain|phb":[4,"I","10 min","300 ft","VSM","24 hr","","","",""],
410"hallucinatory terrain|xphb":[4,"I","10 min","300 ft","VSM","24 hr","","","",""],
411"harm|phb":[6,"N","Action","60 ft","VS","Instant","","necrotic","CON",""],
412"harm|xphb":[6,"N","Action","60 ft","VS","Instant","","necrotic","CON",""],
413"haste|phb":[3,"T","Action","30 ft","VSM","1 min","conc","","",""],
414"haste|xphb":[3,"T","Action","30 ft","VSM","1 min","conc","","",""],
415"healing spirit|xge":[2,"C","Bonus Action","60 ft","VS","1 min","conc heal mod","","","1d6"],
416"healing word|phb":[1,"V","Bonus Action","60 ft","V","Instant","heal mod","","","1d4"],
417"healing word|xphb":[1,"A","Bonus Action","60 ft","V","Instant","heal mod","","","2d4"],
418"heal|phb":[6,"V","Action","60 ft","VS","Instant","heal","","",""],
419"heal|xphb":[6,"A","Action","60 ft","VS","Instant","heal","","","70"],
420"heat metal|phb":[2,"T","Action","60 ft","VSM","1 min","conc","fire","CON","2d8"],
421"heat metal|xphb":[2,"T","Action","60 ft","VSM","1 min","conc","fire","CON","2d8"],
422"hellish rebuke|phb":[1,"V","Reaction","60 ft","VS","Instant","","fire","DEX","2d10"],
423"hellish rebuke|xphb":[1,"V","Reaction","60 ft","VS","Instant","","fire","DEX","2d10"],
424"heroes feast|phb":[6,"C","10 min","30 ft","VSM","Instant","heal","","",""],
425"heroes feast|xphb":[6,"C","10 min","10 ft cube","VSM","Instant","heal","","",""],
426"heroism|phb":[1,"E","Action","Touch","VS","1 min","conc","","",""],
427"heroism|xphb":[1,"E","Action","Touch","VS","1 min","conc","","",""],
428"hex|phb":[1,"E","Bonus Action","90 ft","VSM","1 hr","conc","necrotic","",""],
429"hex|xphb":[1,"E","Bonus Action","90 ft","VSM","1 hr","conc","necrotic","",""],
430"hold monster|phb":[5,"E","Action","90 ft","VSM","1 min","conc","","WIS",""],
431"hold monster|xphb":[5,"E","Action","90 ft","VSM","1 min","conc","","WIS",""],
432"hold person|phb":[2,"E","Action","60 ft","VSM","1 min","conc","","WIS",""],
433"hold person|xphb":[2,"E","Action","60 ft","VSM","1 min","conc","","WIS",""],
434"holy aura|phb":[8,"A","Action","Self","VSM","1 min","conc","","CON",""],
435"holy aura|xphb":[8,"A","Action","30 ft emanation","VSM","1 min","conc","","CON",""],
436"holy weapon|xge":[5,"V","Bonus Action","Touch","VS","1 hr","conc","radiant","CON",""],
437"homunculus servant|efa":[2,"C","1 hr","10 ft","VSM","Instant","ritual","","",""],
438"hunger of hadar|phb":[3,"C","Action","150 ft","VSM","1 min","conc","acid,cold","DEX",""],
439"hunger of hadar|xphb":[3,"C","Action","150 ft","VSM","1 min","conc","acid,cold","DEX","2d6"],
440"hunter s mark|phb":[1,"D","Bonus Action","90 ft","V","1 hr","conc","","",""],
441"hunter s mark|xphb":[1,"D","Bonus Action","90 ft","V","1 hr","conc","force","",""],
442"hypnotic pattern|phb":[3,"I","Action","120 ft","SM","1 min","conc","","WIS",""],
443"hypnotic pattern|xphb":[3,"I","Action","120 ft","SM","1 min","conc","","WIS",""],
444"ice knife|xge":[1,"C","Action","60 ft","SM","Instant","ranged","cold,piercing","DEX",""],
445"ice knife|xphb":[1,"C","Action","60 ft","SM","Instant","ranged","cold,piercing","DEX",""],
446"ice storm|phb":[4,"V","Action","300 ft","VSM","Instant","","bludgeoning,cold","DEX",""],
447"ice storm|xphb":[4,"V","Action","300 ft","VSM","Instant","","bludgeoning,cold","DEX",""],
448"identify|phb":[1,"D","1 min","Touch","VSM","Instant","ritual","","",""],
449"identify|xphb":[1,"D","1 min","Touch","VSM","Instant","ritual","","",""],
450"illusory dragon|xge":[8,"I","Action","120 ft","S","1 min","conc","acid,cold,fire,lightning,necrotic,poison","WIS/INT",""],
451"illusory script|phb":[1,"I","1 min","Touch","SM","10 days","ritual","","",""],
452"illusory script|xphb":[1,"I","1 min","Touch","SM","10 days","ritual","","",""],
453"immolation|xge":[5,"V","Action","90 ft","V","1 min","conc","fire","DEX",""],
454"imprisonment|phb":[9,"A","1 min","30 ft","VSM","Until dispelled","","","WIS",""],
455"imprisonment|xphb":[9,"A","1 min","30 ft","VSM","Until dispelled","","","WIS",""],
456"incendiary cloud|phb":[8,"C","Action","150 ft","VS","1 min","conc","fire","DEX",""],
457"incendiary cloud|xphb":[8,"C","Action","150 ft","VS","1 min","conc","fire","DEX",""],
458"infernal calling|xge":[5,"C","1 min","90 ft","VSM","1 hr","conc","","",""],
459"infestation|xge":[0,"C","Action","30 ft","VSM","Instant","","poison","CON","1:1d6 5:2d6 11:3d6 17:4d6"],
460"inflict wounds|phb":[1,"N","Action","Touch","VS","Instant","melee","necrotic","","3d10"],
461"inflict wounds|xphb":[1,"N","Action","Touch","VS","Instant","","necrotic","CON","2d10"],
462"insect plague|phb":[5,"C","Action","300 ft","VSM","10 min","conc","piercing","CON","4d10"],
463"insect plague|xphb":[5,"C","Action","300 ft","VSM","10 min","conc","piercing","CON","4d10"],
464"intellect fortress|tce":[3,"A","Action","30 ft","V","1 hr","conc","","",""],
465"investiture of flame|xge":[6,"T","Action","Self","VS","10 min","conc","fire","DEX",""],
466"investiture of ice|xge":[6,"T","Action","Self","VS","10 min","conc","cold","CON",""],
467"investiture of stone|xge":[6,"T","Action","Self","VS","10 min","conc","","DEX",""],
468"investiture of wind|xge":[6,"T","Action","Self","VS","10 min","conc","bludgeoning","CON",""],
469"invisibility|phb":[2,"I","Action","Touch","VSM","1 hr","conc","","",""],
470"invisibility|xphb":[2,"I","Action","Touch","VSM","1 hr","conc","","",""],
471"invulnerability|xge":[9,"A","Action","Self","VSM","10 min","conc","","",""],
472"jallarzi s storm of radiance|xphb":[5,"V","Action","120 ft","VSM","1 min","conc","radiant,thunder","CON","2d10"],
473"jump|phb":[1,"T","Action","Touch","VSM","1 min","","","",""],
474"jump|xphb":[1,"T","Bonus Action","Touch","VSM","1 min","","","",""],
475"knock|phb":[2,"T","Action","60 ft","V","Instant","","","",""],
476"knock|xphb":[2,"T","Action","60 ft","V","Instant","","","",""],
477"legend lore|phb":[5,"D","10 min","Self","VSM","Instant","","","",""],
478"legend lore|xphb":[5,"D","10 min","Self","VSM","Instant","","","",""],
479"leomund s secret chest|phb":[4,"C","Action","Touch","VSM","Instant","","","",""],
480"leomund s secret chest|xphb":[4,"C","Action","Touch","VSM","Until dispelled","","","",""],
481"leomund s tiny hut|phb":[3,"V","1 min","10 ft hemisphere","VSM","8 hr","ritual","","",""],
482"leomund s tiny hut|xphb":[3,"V","1 min","10 ft emanation","VSM","8 hr","ritual","","",""],
483"lesser restoration|phb":[2,"A","Action","Touch","VS","Instant","","","",""],
484"lesser restoration|xphb":[2,"A","Bonus Action","Touch","VS","Instant","","","",""],
485"levitate|phb":[2,"T","Action","60 ft","VSM","10 min","conc","","CON",""],
486"levitate|xphb":[2,"T","Action","60 ft","VSM","10 min","conc","","CON",""],
487"life transference|xge":[3,"N","Action","30 ft","VS","Instant","","necrotic","","4d8"],
488"lightning arrow|phb":[3,"T","Bonus Action","Self","VS","1 min","conc","lightning","DEX","4d8;2d8"],
489"lightning arrow|xphb":[3,"T","Bonus Action","Self","VS","Instant","","lightning","DEX","4d8;2d8"],
490"lightning bolt|phb":[3,"V","Action","100 ft line","VSM","Instant","","lightning","DEX","8d6"],
491"lightning bolt|xphb":[3,"V","Action","100 ft line","VSM","Instant","","lightning","DEX","8d6"],
492"lightning lure|tce":[0,"V","Action","15 ft radius","V","Instant","","lightning","STR","1:1d8 5:2d8 11:3d8 17:4d8"],
493"light|phb":[0,"V","Action","Touch","VM","1 hr","","","DEX",""],
494"light|xphb":[0,"V","Action","Touch","VM","1 hr","","","",""],
495"locate animals or plants|phb":[2,"D","Action","Self","VSM","Instant","ritual","","",""],
496"locate animals or plants|xphb":[2,"D","Action","Self","VSM","Instant","ritual","","",""],
497"locate creature|phb":[4,"D","Action","Self","VSM","1 hr","conc","","",""],
498"locate creature|xphb":[4,"D","Action","Self","VSM","1 hr","conc","","",""],
499"locate object|phb":[2,"D","Action","Self","VSM","10 min","conc","","",""],
500"locate object|xphb":[2,"D","Action","Self","VSM","10 min","conc","","",""],
501"longstrider|phb":[1,"T","Action","Touch","VSM","1 hr","","","",""],
502"longstrider|xphb":[1,"T","Action","Touch","VSM","1 hr","","","",""],
503"maddening darkness|xge":[8,"V","Action","150 ft","VM","10 min","conc","psychic","WIS",""],
504"maelstrom|xge":[5,"V","Action","120 ft","VSM","1 min","conc","bludgeoning","STR",""],
505"mage armor|phb":[1,"A","Action","Touch","VSM","8 hr","","","",""],
506"mage armor|xphb":[1,"A","Action","Touch","VSM","8 hr","","","",""],
507"mage hand|phb":[0,"C","Action","30 ft","VS","1 min","","","",""],
508"mage hand|xphb":[0,"C","Action","30 ft","VS","1 min","","","",""],
509"magic circle|phb":[3,"A","1 min","10 ft","VSM","1 hr","","","CHA",""],
510"magic circle|xphb":[3,"A","1 min","10 ft","VSM","1 hr","","","CHA",""],
511"magic jar|phb":[6,"N","1 min","Self","VSM","Until dispelled","","","CHA",""],
512"magic jar|xphb":[6,"N","1 min","Self","VSM","Until dispelled","","","CHA",""],
513"magic missile|phb":[1,"V","Action","120 ft","VS","Instant","","force","",""],
514"magic missile|xphb":[1,"V","Action","120 ft","VS","Instant","","force","",""],
515"magic mouth|phb":[2,"I","1 min","30 ft","VSM","Until dispelled","ritual","","",""],
516"magic mouth|xphb":[2,"I","1 min","30 ft","VSM","Until dispelled","ritual","","",""],
517"magic stone|xge":[0,"T","Bonus Action","Touch","VS","1 min","ranged","bludgeoning","",""],
518"magic weapon|phb":[2,"T","Bonus Action","Touch","VS","1 hr","conc","","",""],
519"magic weapon|xphb":[2,"T","Bonus Action","Touch","VS","1 hr","","","",""],
520"major image|phb":[3,"I","Action","120 ft","VSM","10 min","conc","","",""],
521"major image|xphb":[3,"I","Action","120 ft","VSM","10 min","conc","","",""],
522"mass cure wounds|phb":[5,"V","Action","60 ft","VS","Instant","heal mod","","","3d8"],
523"mass cure wounds|xphb":[5,"A","Action","60 ft","VS","Instant","heal mod","","","5d8"],
524"mass healing word|phb":[3,"V","Bonus Action","60 ft","V","Instant","heal mod","","","1d4"],
525"mass healing word|xphb":[3,"A","Bonus Action","60 ft","V","Instant","heal mod","","","2d4"],
526"mass heal|phb":[9,"V","Action","60 ft","VS","Instant","heal","","",""],
527"mass heal|xphb":[9,"A","Action","60 ft","VS","Instant","heal","","",""],
528"mass polymorph|xge":[9,"T","Action","120 ft","VSM","1 hr","conc","","WIS",""],
529"mass suggestion|phb":[6,"E","Action","60 ft","VM","24 hr","","","WIS",""],
530"mass suggestion|xphb":[6,"E","Action","60 ft","VM","24 hr","","","WIS",""],
531"maximilian s earthen grasp|xge":[2,"T","Action","30 ft","VSM","1 min","conc","bludgeoning","STR",""],
532"maze|phb":[8,"C","Action","60 ft","VS","10 min","conc","","",""],
533"maze|xphb":[8,"C","Action","60 ft","VS","10 min","conc","","",""],
534"meld into stone|phb":[3,"T","Action","Touch","VS","8 hr","ritual","bludgeoning","",""],
535"meld into stone|xphb":[3,"T","Action","Touch","VS","8 hr","ritual","force","",""],
536"melf s acid arrow|phb":[2,"V","Action","90 ft","VSM","Instant","ranged","acid","","4d4;2d4"],
537"melf s acid arrow|xphb":[2,"V","Action","90 ft","VSM","Instant","ranged","acid","","4d4;2d4"],
538"melf s minute meteors|xge":[3,"V","Action","Self","VSM","10 min","conc","fire","DEX",""],
539"mending|phb":[0,"T","1 min","Touch","VSM","Instant","","","",""],
540"mending|xphb":[0,"T","1 min","Touch","VSM","Instant","","","",""],
541"mental prison|xge":[6,"I","Action","60 ft","S","1 min","conc","psychic","INT",""],
542"message|phb":[0,"T","Action","120 ft","VSM","1 round","","","",""],
543"message|xphb":[0,"T","Action","120 ft","SM","1 round","","","",""],
544"meteor swarm|phb":[9,"V","Action","1 mile","VS","Instant","","bludgeoning,fire","DEX",""],
545"meteor swarm|xphb":[9,"V","Action","1 mile","VS","Instant","","bludgeoning,fire","DEX",""],
546"mighty fortress|xge":[8,"C","1 min","1 mile","VSM","Instant","","","",""],
547"mind blank|phb":[8,"A","Action","Touch","VS","24 hr","","","",""],
548"mind blank|xphb":[8,"A","Action","Touch","VS","24 hr","","","",""],
549"mind sliver|tce":[0,"E","Action","60 ft","V","1 round","","psychic","INT","1:1d6 5:2d6 11:3d6 17:4d6"],
550"mind sliver|xphb":[0,"E","Action","60 ft","V","Instant","","psychic","INT","1:1d6 5:2d6 11:3d6 17:4d6"],
551"mind spike|xge":[2,"D","Action","60 ft","S","1 hr","conc","psychic","WIS","3d8"],
552"mind spike|xphb":[2,"D","Action","120 ft","S","1 hr","conc","psychic","WIS","3d8"],
553"minor illusion|phb":[0,"I","Action","30 ft","SM","1 min","","","",""],
554"minor illusion|xphb":[0,"I","Action","30 ft","SM","1 min","","","",""],
555"mirage arcane|phb":[7,"I","10 min","Sight","VS","10 days","","","",""],
556"mirage arcane|xphb":[7,"I","10 min","Sight","VS","10 days","","","",""],
557"mirror image|phb":[2,"I","Action","Self","VS","1 min","","","",""],
558"mirror image|xphb":[2,"I","Action","Self","VS","1 min","","","",""],
559"mislead|phb":[5,"I","Action","Self","S","1 hr","conc","","",""],
560"mislead|xphb":[5,"I","Action","Self","S","1 hr","conc","","",""],
561"misty step|phb":[2,"C","Bonus Action","Self","V","Instant","","","",""],
562"misty step|xphb":[2,"C","Bonus Action","Self","V","Instant","","","",""],
563"modify memory|phb":[5,"E","Action","30 ft","VS","1 min","conc","","WIS",""],
564"modify memory|xphb":[5,"E","Action","30 ft","VS","1 min","conc","","WIS",""],
565"mold earth|xge":[0,"T","Action","30 ft","S","Instant or 1 hr","","","",""],
566"moonbeam|phb":[2,"V","Action","120 ft","VSM","1 min","conc","radiant","CON","2d10"],
567"moonbeam|xphb":[2,"V","Action","120 ft","VSM","1 min","conc","radiant","CON","2d10"],
568"mordenkainen s faithful hound|phb":[4,"C","Action","30 ft","VSM","8 hr","","piercing","",""],
569"mordenkainen s faithful hound|xphb":[4,"C","Action","30 ft","VSM","8 hr","","force","DEX",""],
570"mordenkainen s magnificent mansion|phb":[7,"C","1 min","300 ft","VSM","24 hr","","","",""],
571"mordenkainen s magnificent mansion|xphb":[7,"C","1 min","300 ft","VSM","24 hr","","","",""],
572"mordenkainen s private sanctum|phb":[4,"A","10 min","120 ft","VSM","24 hr","","","",""],
573"mordenkainen s private sanctum|xphb":[4,"A","10 min","120 ft","VSM","24 hr","","","",""],
574"mordenkainen s sword|phb":[7,"V","Action","60 ft","VSM","1 min","conc melee","force","",""],
575"mordenkainen s sword|xphb":[7,"V","Action","90 ft","VSM","1 min","conc melee","force","",""],
576"move earth|phb":[6,"T","Action","120 ft","VSM","2 hr","conc","","",""],
577"move earth|xphb":[6,"T","Action","120 ft","VSM","2 hr","conc","","",""],
578"negative energy flood|xge":[5,"N","Action","60 ft","VM","Instant","","necrotic","CON",""],
579"nondetection|phb":[3,"A","Action","Touch","VSM","8 hr","","","",""],
580"nondetection|xphb":[3,"A","Action","Touch","VSM","8 hr","","","",""],
581"nystul s magic aura|phb":[2,"I","Action","Touch","VSM","24 hr","","","",""],
582"nystul s magic aura|xphb":[2,"I","Action","Touch","VSM","24 hr","","","",""],
583"otiluke s freezing sphere|phb":[6,"V","Action","300 ft","VSM","Instant","","cold","CON","10d6"],
584"otiluke s freezing sphere|xphb":[6,"V","Action","300 ft","VSM","Instant","","cold","CON","10d6"],
585"otiluke s resilient sphere|phb":[4,"V","Action","30 ft","VSM","1 min","conc","","DEX",""],
586"otiluke s resilient sphere|xphb":[4,"A","Action","30 ft","VSM","1 min","conc","","DEX",""],
587"otto s irresistible dance|phb":[6,"E","Action","30 ft","V","1 min","conc","","WIS",""],
588"otto s irresistible dance|xphb":[6,"E","Action","30 ft","V","1 min","conc","","WIS",""],
589"pass without trace|phb":[2,"A","Action","Self","VSM","1 hr","conc","","",""],
590"pass without trace|xphb":[2,"A","Action","30 ft emanation","VSM","1 hr","conc","","",""],
591"passwall|phb":[5,"T","Action","30 ft","VSM","1 hr","","","",""],
592"passwall|xphb":[5,"T","Action","30 ft","VSM","1 hr","","","",""],
593"phantasmal force|phb":[2,"I","Action","60 ft","VSM","1 min","conc","psychic","INT",""],
594"phantasmal force|xphb":[2,"I","Action","60 ft","VSM","1 min","conc","psychic","INT",""],
595"phantasmal killer|phb":[4,"I","Action","120 ft","VS","1 min","conc","psychic","WIS","4d10"],
596"phantasmal killer|xphb":[4,"I","Action","120 ft","VS","1 min","conc","psychic","WIS","4d10"],
597"phantom steed|phb":[3,"I","1 min","30 ft","VS","1 hr","ritual","","",""],
598"phantom steed|xphb":[3,"I","1 min","30 ft","VS","1 hr","ritual","","",""],
599"planar ally|phb":[6,"C","10 min","60 ft","VS","Instant","","","",""],
600"planar ally|xphb":[6,"C","10 min","60 ft","VS","Instant","","","",""],
601"planar binding|phb":[5,"A","1 hr","60 ft","VSM","24 hr","","","CHA",""],
602"planar binding|xphb":[5,"A","1 hr","60 ft","VSM","24 hr","","","CHA",""],
603"plane shift|phb":[7,"C","Action","Touch","VSM","Instant","melee","","CHA",""],
604"plane shift|xphb":[7,"C","Action","Touch","VSM","Instant","","","",""],
605"plant growth|phb":[3,"T","Action or 8 hr","150 ft","VS","Instant","","","",""],
606"plant growth|xphb":[3,"T","Action or 8 hr","150 ft","VS","Instant","","","",""],
607"poison spray|phb":[0,"C","Action","10 ft","VS","Instant","","poison","CON","1:1d12 5:2d12 11:3d12 17:4d12"],
608"poison spray|xphb":[0,"N","Action","30 ft","VS","Instant","ranged","poison","","1:1d12 5:2d12 11:3d12 17:4d12"],
609"polymorph|phb":[4,"T","Action","60 ft","VSM","1 hr","conc","","WIS",""],
610"polymorph|xphb":[4,"T","Action","60 ft","VSM","1 hr","conc","","WIS",""],
611"power word fortify|xphb":[7,"E","Action","60 ft","V","Instant","","","",""],
612"power word heal|phb":[9,"V","Action","Touch","VS","Instant","heal","","",""],
613"power word heal|xphb":[9,"E","Action","60 ft","VS","Instant","heal","","",""],
614"power word kill|phb":[9,"E","Action","60 ft","V","Instant","","","",""],
615"power word kill|xphb":[9,"E","Action","60 ft","V","Instant","","psychic","",""],
616"power word pain|xge":[7,"E","Action","60 ft","V","Instant","","","CON",""],
617"power word stun|phb":[8,"E","Action","60 ft","V","Instant","","","CON",""],
618"power word stun|xphb":[8,"E","Action","60 ft","V","Instant","","","CON",""],
619"prayer of healing|phb":[2,"V","10 min","30 ft","V","Instant","heal mod","","","2d8"],
620"prayer of healing|xphb":[2,"A","10 min","30 ft","V","Instant","heal","","","2d8"],
621"prestidigitation|phb":[0,"T","Action","10 ft","VS","1 hr","","","",""],
622"prestidigitation|xphb":[0,"T","Action","10 ft","VS","1 hr","","","",""],
623"primal savagery|xge":[0,"T","Action","Self","S","Instant","melee","acid","","1:1d10 5:2d10 11:3d10 17:4d10"],
624"primordial ward|xge":[6,"A","Action","Self","VS","1 min","conc","","",""],
625"prismatic spray|phb":[7,"V","Action","60 ft cone","VS","Instant","","acid,cold,fire,lightning,poison","DEX/CON/WIS",""],
626"prismatic spray|xphb":[7,"V","Action","60 ft cone","VS","Instant","","acid,cold,fire,lightning,poison","DEX/CON/WIS",""],
627"prismatic wall|phb":[9,"A","Action","60 ft","VS","10 min","","acid,cold,fire,force,lightning,poison","CON/DEX/WIS",""],
628"prismatic wall|xphb":[9,"A","Action","60 ft","VS","10 min","","acid,cold,fire,force,lightning,poison","CON/DEX/WIS",""],
629"produce flame|phb":[0,"C","Action","Self","VS","10 min","ranged","fire","","1:1d8 5:2d8 11:3d8 17:4d8"],
630"produce flame|xphb":[0,"C","Bonus Action","Self","VS","10 min","ranged","fire","","1:1d8 5:2d8 11:3d8 17:4d8"],
631"programmed illusion|phb":[6,"I","Action","120 ft","VSM","Until dispelled","","","",""],
632"programmed illusion|xphb":[6,"I","Action","120 ft","VSM","Until dispelled","","","",""],
633"project image|phb":[7,"I","Action","500 miles","VSM","1 day","conc","","",""],
634"project image|xphb":[7,"I","Action","500 miles","VSM","1 day","conc","","",""],
635"protection from energy|phb":[3,"A","Action","Touch","VS","1 hr","conc","","",""],
636"protection from energy|xphb":[3,"A","Action","Touch","VS","1 hr","conc","","",""],
637"protection from evil and good|phb":[1,"A","Action","Touch","VSM","10 min","conc","","",""],
638"protection from evil and good|xphb":[1,"A","Action","Touch","VSM","10 min","conc","","",""],
639"protection from poison|phb":[2,"A","Action","Touch","VS","1 hr","","","",""],
640"protection from poison|xphb":[2,"A","Action","Touch","VS","1 hr","","","",""],
641"psychic scream|xge":[9,"E","Action","90 ft","S","Instant","","psychic","INT",""],
642"purify food and drink|phb":[1,"T","Action","10 ft","VS","Instant","ritual","","",""],
643"purify food and drink|xphb":[1,"T","Action","10 ft","VS","Instant","ritual","","",""],
644"pyrotechnics|xge":[2,"T","Action","60 ft","VS","Instant","","","CON",""],
645"raise dead|phb":[5,"N","1 hr","Touch","VSM","Instant","heal","","",""],
646"raise dead|xphb":[5,"N","1 hr","Touch","VSM","Instant","heal","","",""],
647"rary s telepathic bond|phb":[5,"D","Action","30 ft","VSM","1 hr","ritual","","",""],
648"rary s telepathic bond|xphb":[5,"D","Action","30 ft","VSM","1 hr","ritual","","",""],
649"ray of enfeeblement|phb":[2,"N","Action","60 ft","VS","1 min","conc ranged","","CON",""],
650"ray of enfeeblement|xphb":[2,"N","Action","60 ft","VS","1 min","conc","","CON",""],
651"ray of frost|phb":[0,"V","Action","60 ft","VS","Instant","ranged","cold","","1:1d8 5:2d8 11:3d8 17:4d8"],
652"ray of frost|xphb":[0,"V","Action","60 ft","VS","Instant","ranged","cold","","1:1d8 5:2d8 11:3d8 17:4d8"],
653"ray of sickness|phb":[1,"N","Action","60 ft","VS","Instant","ranged","poison","CON","2d8"],
654"ray of sickness|xphb":[1,"N","Action","60 ft","VS","Instant","ranged","poison","","2d8"],
655"regenerate|phb":[7,"T","1 min","Touch","VSM","1 hr","heal","","",""],
656"regenerate|xphb":[7,"T","1 min","Touch","VSM","1 hr","heal","","",""],
657"reincarnate|phb":[5,"T","1 hr","Touch","VSM","Instant","heal","","",""],
658"reincarnate|xphb":[5,"N","1 hr","Touch","VSM","Instant","heal","","",""],
659"remove curse|phb":[3,"A","Action","Touch","VS","Instant","","","",""],
660"remove curse|xphb":[3,"A","Action","Touch","VS","Instant","","","",""],
661"resistance|phb":[0,"A","Action","Touch","VSM","1 min","conc","","",""],
662"resistance|xphb":[0,"A","Action","Touch","VS","1 min","conc","","",""],
663"resurrection|phb":[7,"N","1 hr","Touch","VSM","Instant","heal","","",""],
664"resurrection|xphb":[7,"N","1 hr","Touch","VSM","Instant","heal","","",""],
665"reverse gravity|phb":[7,"T","Action","100 ft","VSM","1 min","conc","","DEX",""],
666"reverse gravity|xphb":[7,"T","Action","100 ft","VSM","1 min","conc","","DEX",""],
667"revivify|phb":[3,"N","Action","Touch","VSM","Instant","heal","","",""],
668"revivify|xphb":[3,"N","Action","Touch","VSM","Instant","heal","","",""],
669"rope trick|phb":[2,"T","Action","Touch","VSM","1 hr","","","",""],
670"rope trick|xphb":[2,"T","Action","Touch","VSM","1 hr","","","",""],
671"sacred flame|phb":[0,"V","Action","60 ft","VS","Instant","","radiant","DEX","1:1d8 5:2d8 11:3d8 17:4d8"],
672"sacred flame|xphb":[0,"V","Action","60 ft","VS","Instant","","radiant","DEX","1:1d8 5:2d8 11:3d8 17:4d8"],
673"sanctuary|phb":[1,"A","Bonus Action","30 ft","VSM","1 min","","","WIS",""],
674"sanctuary|xphb":[1,"A","Bonus Action","30 ft","VSM","1 min","","","WIS",""],
675"scatter|xge":[6,"C","Action","30 ft","V","Instant","","","WIS",""],
676"scorching ray|phb":[2,"V","Action","120 ft","VS","Instant","ranged","fire","",""],
677"scorching ray|xphb":[2,"V","Action","120 ft","VS","Instant","ranged","fire","",""],
678"scrying|phb":[5,"D","10 min","Self","VSM","10 min","conc","","WIS",""],
679"scrying|xphb":[5,"D","10 min","Self","VSM","10 min","conc","","WIS",""],
680"searing smite|phb":[1,"V","Bonus Action","Self","V","1 min","conc","fire","CON","1d6"],
681"searing smite|xphb":[1,"V","Bonus Action","Self","V","1 min","","fire","CON","1d6"],
682"see invisibility|phb":[2,"D","Action","Self","VSM","1 hr","","","",""],
683"see invisibility|xphb":[2,"D","Action","Self","VSM","1 hr","","","",""],
684"seeming|phb":[5,"I","Action","30 ft","VS","8 hr","","","CHA",""],
685"seeming|xphb":[5,"I","Action","30 ft","VS","8 hr","","","CHA",""],
686"sending|phb":[3,"V","Action","Unlimited","VSM","1 round","","","",""],
687"sending|xphb":[3,"D","Action","Unlimited","VSM","Instant","","","",""],
688"sequester|phb":[7,"T","Action","Touch","VSM","Until dispelled","","","",""],
689"sequester|xphb":[7,"T","Action","Touch","VSM","Until dispelled","","","",""],
690"shadow blade|xge":[2,"I","Bonus Action","Self","VS","1 min","conc","psychic","",""],
691"shadow of moil|xge":[4,"N","Action","Self","VSM","1 min","conc","necrotic","",""],
692"shape water|xge":[0,"T","Action","30 ft","S","Instant or 1 hr","","","",""],
693"shapechange|phb":[9,"T","Action","Self","VSM","1 hr","conc","","",""],
694"shapechange|xphb":[9,"T","Action","Self","VSM","1 hr","conc","","",""],
695"shatter|phb":[2,"V","Action","60 ft","VSM","Instant","","thunder","CON","3d8"],
696"shatter|xphb":[2,"V","Action","60 ft","VSM","Instant","","thunder","CON","3d8"],
697"shield of faith|phb":[1,"A","Bonus Action","60 ft","VSM","10 min","conc","","",""],
698"shield of faith|xphb":[1,"A","Bonus Action","60 ft","VSM","10 min","conc","","",""],
699"shield|phb":[1,"A","Reaction","Self","VS","1 round","","","",""],
700"shield|xphb":[1,"A","Reaction","Self","VS","1 round","","","",""],
701"shillelagh|phb":[0,"T","Bonus Action","Touch","VSM","1 min","","","",""],
702"shillelagh|xphb":[0,"T","Bonus Action","Self","VSM","1 min","","force","","1:1d8 5:1d10 11:1d12 17:2d6"],
703"shining smite|xphb":[2,"T","Bonus Action","Self","V","1 min","conc","radiant","","2d6"],
704"shocking grasp|phb":[0,"V","Action","Touch","VS","Instant","melee","lightning","","1:1d8 5:2d8 11:3d8 17:4d8"],
705"shocking grasp|xphb":[0,"V","Action","Touch","VS","Instant","melee","lightning","","1:1d8 5:2d8 11:3d8 17:4d8"],
706"sickening radiance|xge":[4,"V","Action","120 ft","VS","10 min","conc","radiant","CON",""],
707"silence|phb":[2,"I","Action","120 ft","VS","10 min","conc ritual","","",""],
708"silence|xphb":[2,"I","Action","120 ft","VS","10 min","conc ritual","","",""],
709"silent image|phb":[1,"I","Action","60 ft","VSM","10 min","conc","","",""],
710"silent image|xphb":[1,"I","Action","60 ft","VSM","10 min","conc","","",""],
711"simulacrum|phb":[7,"I","12 hr","Touch","VSM","Until dispelled","","","",""],
712"simulacrum|xphb":[7,"I","12 hr","Touch","VSM","Until dispelled","","","",""],
713"skill empowerment|xge":[5,"T","Action","Touch","VS","1 hr","conc","","",""],
714"skywrite|xge":[2,"T","Action","Sight","VS","1 hr","conc ritual","","",""],
715"sleep|phb":[1,"E","Action","90 ft","VSM","1 min","","","",""],
716"sleep|xphb":[1,"E","Action","60 ft","VSM","1 min","conc","","WIS",""],
717"sleet storm|phb":[3,"C","Action","150 ft","VSM","1 min","conc","","DEX/CON",""],
718"sleet storm|xphb":[3,"C","Action","150 ft","VSM","1 min","conc","","DEX",""],
719"slow|phb":[3,"T","Action","120 ft","VSM","1 min","conc","","WIS",""],
720"slow|xphb":[3,"T","Action","120 ft","VSM","1 min","conc","","WIS",""],
721"snare|xge":[1,"A","1 min","Touch","SM","8 hr","","","DEX",""],
722"snilloc s snowball swarm|xge":[2,"V","Action","90 ft","VSM","Instant","","cold","DEX","3d6"],
723"sorcerous burst|xphb":[0,"V","Action","120 ft","VS","Instant","ranged","acid,cold,fire,lightning,poison,psychic,thunder","","1:1d8 5:2d8 11:3d8 17:4d8"],
724"soul cage|xge":[6,"N","Reaction","60 ft","VSM","8 hr","heal","","",""],
725"spare the dying|phb":[0,"N","Action","Touch","VS","Instant","","","",""],
726"spare the dying|xphb":[0,"N","Action","15 ft","VS","Instant","","","",""],
727"speak with animals|phb":[1,"D","Action","Self","VS","10 min","ritual","","",""],
728"speak with animals|xphb":[1,"D","Action","Self","VS","10 min","ritual","","",""],
729"speak with dead|phb":[3,"N","Action","10 ft","VSM","10 min","","","",""],
730"speak with dead|xphb":[3,"N","Action","10 ft","VSM","10 min","","","",""],
731"speak with plants|phb":[3,"T","Action","30 ft radius","VS","10 min","","","",""],
732"speak with plants|xphb":[3,"T","Action","30 ft emanation","VS","10 min","","","",""],
733"spider climb|phb":[2,"T","Action","Touch","VSM","1 hr","conc","","",""],
734"spider climb|xphb":[2,"T","Action","Touch","VSM","1 hr","conc","","",""],
735"spike growth|phb":[2,"T","Action","150 ft","VSM","10 min","conc","piercing","",""],
736"spike growth|xphb":[2,"T","Action","150 ft","VSM","10 min","conc","piercing","",""],
737"spirit guardians|phb":[3,"C","Action","15 ft radius","VSM","10 min","conc","necrotic,radiant","WIS","3d8"],
738"spirit guardians|xphb":[3,"C","Action","15 ft emanation","VSM","10 min","conc","necrotic,radiant","WIS","3d8"],
739"spirit shroud|tce":[3,"N","Bonus Action","Self","VS","1 min","conc","radiant,necrotic,cold","","1d8"],
740"spiritual weapon|phb":[2,"V","Bonus Action","60 ft","VS","1 min","melee","force","","1d8"],
741"spiritual weapon|xphb":[2,"V","Bonus Action","60 ft","VS","1 min","conc melee","force","","1d8"],
742"staggering smite|phb":[4,"V","Bonus Action","Self","V","1 min","conc","psychic","WIS",""],
743"staggering smite|xphb":[4,"E","Bonus Action","Self","V","Instant","","psychic","WIS","4d6"],
744"starry wisp|xphb":[0,"V","Action","60 ft","VS","Instant","ranged","radiant","","1:1d8 5:2d8 11:3d8 17:4d8"],
745"steel wind strike|xge":[5,"C","Action","30 ft","SM","Instant","melee","force","",""],
746"steel wind strike|xphb":[5,"C","Action","30 ft","SM","Instant","melee","force","",""],
747"stinking cloud|phb":[3,"C","Action","90 ft","VSM","1 min","conc","","CON",""],
748"stinking cloud|xphb":[3,"C","Action","90 ft","VSM","1 min","conc","","CON",""],
749"stone shape|phb":[4,"T","Action","Touch","VSM","Instant","","","",""],
750"stone shape|xphb":[4,"T","Action","Touch","VSM","Instant","","","",""],
751"stoneskin|phb":[4,"A","Action","Touch","VSM","1 hr","conc","","",""],
752"stoneskin|xphb":[4,"T","Action","Touch","VSM","1 hr","conc","","",""],
753"storm of vengeance|phb":[9,"C","Action","Sight","VS","1 min","conc","acid,bludgeoning,cold,lightning,thunder","CON/DEX",""],
754"storm of vengeance|xphb":[9,"C","Action","1 mile","VS","1 min","conc","acid,bludgeoning,cold,lightning,thunder","CON/DEX",""],
755"storm sphere|xge":[4,"V","Action","150 ft","VS","1 min","conc ranged","bludgeoning,lightning","STR",""],
756"suggestion|phb":[2,"E","Action","30 ft","VM","8 hr","conc","","WIS",""],
757"suggestion|xphb":[2,"E","Action","30 ft","VM","8 hr","conc","","WIS",""],
758"summon aberration|tce":[4,"C","Action","90 ft","VSM","1 hr","conc","","",""],
759"summon aberration|xphb":[4,"C","Action","90 ft","VSM","1 hr","conc","","",""],
760"summon beast|tce":[2,"C","Action","90 ft","VSM","1 hr","conc","","",""],
761"summon beast|xphb":[2,"C","Action","90 ft","VSM","1 hr","conc","","",""],
762"summon celestial|tce":[5,"C","Action","90 ft","VSM","1 hr","conc","","",""],
763"summon celestial|xphb":[5,"C","Action","90 ft","VSM","1 hr","conc","","",""],
764"summon construct|tce":[4,"C","Action","90 ft","VSM","1 hr","conc","","",""],
765"summon construct|xphb":[4,"C","Action","90 ft","VSM","1 hr","conc","","",""],
766"summon dragon|xphb":[5,"C","Action","60 ft","VSM","1 hr","conc","","",""],
767"summon elemental|tce":[4,"C","Action","90 ft","VSM","1 hr","conc","","",""],
768"summon elemental|xphb":[4,"C","Action","90 ft","VSM","1 hr","conc","","",""],
769"summon fey|tce":[3,"C","Action","90 ft","VSM","1 hr","conc","","",""],
770"summon fey|xphb":[3,"C","Action","90 ft","VSM","1 hr","conc","","",""],
771"summon fiend|tce":[6,"C","Action","90 ft","VSM","1 hr","conc","","",""],
772"summon fiend|xphb":[6,"C","Action","90 ft","VSM","1 hr","conc","","",""],
773"summon greater demon|xge":[4,"C","Action","60 ft","VSM","1 hr","conc","","CHA",""],
774"summon lesser demons|xge":[3,"C","Action","60 ft","VSM","1 hr","conc","","",""],
775"summon shadowspawn|tce":[3,"C","Action","90 ft","VSM","1 hr","conc","","",""],
776"summon undead|tce":[3,"N","Action","90 ft","VSM","1 hr","conc","","",""],
777"summon undead|xphb":[3,"N","Action","90 ft","VSM","1 hr","conc","","",""],
778"sunbeam|phb":[6,"V","Action","60 ft line","VSM","1 min","conc","radiant","CON",""],
779"sunbeam|xphb":[6,"V","Action","60 ft line","VSM","1 min","conc","radiant","CON",""],
780"sunburst|phb":[8,"V","Action","150 ft","VSM","Instant","","radiant","CON",""],
781"sunburst|xphb":[8,"V","Action","150 ft","VSM","Instant","","radiant","CON",""],
782"swift quiver|phb":[5,"T","Bonus Action","Touch","VSM","1 min","conc","","",""],
783"swift quiver|xphb":[5,"T","Bonus Action","Self","VSM","1 min","conc","","",""],
784"sword burst|tce":[0,"C","Action","5 ft radius","V","Instant","","force","DEX","1:1d6 5:2d6 11:3d6 17:4d6"],
785"symbol|phb":[7,"A","1 min","Touch","VSM","Until dispelled or triggered","","necrotic","CON/WIS/CHA/INT",""],
786"symbol|xphb":[7,"A","1 min","Touch","VSM","Until dispelled or triggered","","necrotic","CON/WIS/INT",""],
787"synaptic static|xge":[5,"E","Action","120 ft","VS","Instant","","psychic","INT/CON",""],
788"synaptic static|xphb":[5,"E","Action","120 ft","VS","Instant","","psychic","INT/CON",""],
789"tasha s bubbling cauldron|xphb":[6,"C","Action","5 ft","VSM","10 min","heal","","",""],
790"tasha s caustic brew|tce":[1,"V","Action","30 ft line","VSM","1 min","conc","acid","DEX","2d4"],
791"tasha s hideous laughter|phb":[1,"E","Action","30 ft","VSM","1 min","conc","","WIS",""],
792"tasha s hideous laughter|xphb":[1,"E","Action","30 ft","VSM","1 min","conc","","WIS",""],
793"tasha s mind whip|tce":[2,"E","Action","90 ft","V","1 round","","psychic","INT",""],
794"tasha s otherworldly guise|tce":[6,"T","Bonus Action","Self","VSM","1 min","conc","","",""],
795"telekinesis|phb":[5,"T","Action","60 ft","VS","10 min","conc","","",""],
796"telekinesis|xphb":[5,"T","Action","60 ft","VS","10 min","conc","","STR",""],
797"telepathy|phb":[8,"V","Action","Unlimited","VSM","24 hr","","","",""],
798"telepathy|xphb":[8,"D","Action","Unlimited","VSM","24 hr","","","",""],
799"teleportation circle|phb":[5,"C","1 min","10 ft","VM","1 round","","","",""],
800"teleportation circle|xphb":[5,"C","1 min","10 ft","VM","1 round","","","",""],
801"teleport|phb":[7,"C","Action","10 ft","V","Instant","","force","",""],
802"teleport|xphb":[7,"C","Action","10 ft","V","Instant","","force","",""],
803"temple of the gods|xge":[7,"C","1 hr","120 ft","VSM","24 hr","heal","","CHA",""],
804"tenser s floating disk|phb":[1,"C","Action","30 ft","VSM","1 hr","ritual","","",""],
805"tenser s floating disk|xphb":[1,"C","Action","30 ft","VSM","1 hr","ritual","","",""],
806"tenser s transformation|xge":[6,"T","Action","Self","VSM","10 min","conc","force","CON",""],
807"thaumaturgy|phb":[0,"T","Action","30 ft","V","1 min","","","",""],
808"thaumaturgy|xphb":[0,"T","Action","30 ft","V","1 min","","","",""],
809"thorn whip|phb":[0,"T","Action","30 ft","VSM","Instant","melee","piercing","","1:1d6 5:2d6 11:3d6 17:4d6"],
810"thorn whip|xphb":[0,"T","Action","30 ft","VSM","Instant","melee","piercing","","1:1d6 5:2d6 11:3d6 17:4d6"],
811"thunder step|xge":[3,"C","Action","90 ft","V","Instant","","thunder","CON","3d10"],
812"thunderclap|xge":[0,"V","Action","5 ft","S","Instant","","thunder","CON","1:1d6 5:2d6 11:3d6 17:4d6"],
813"thunderclap|xphb":[0,"V","Action","5 ft emanation","S","Instant","","thunder","CON","1:1d6 5:2d6 11:3d6 17:4d6"],
814"thunderous smite|phb":[1,"V","Bonus Action","Self","V","1 min","conc","thunder","STR",""],
815"thunderous smite|xphb":[1,"V","Bonus Action","Self","V","Instant","","thunder","STR","2d6"],
816"thunderwave|phb":[1,"V","Action","15 ft cube","VS","Instant","","thunder","CON","2d8"],
817"thunderwave|xphb":[1,"V","Action","15 ft cube","VS","Instant","","thunder","CON","2d8"],
818"tidal wave|xge":[3,"C","Action","120 ft","VSM","Instant","","bludgeoning","DEX",""],
819"time stop|phb":[9,"T","Action","Self","V","Instant","","","",""],
820"time stop|xphb":[9,"T","Action","Self","V","Instant","","","",""],
821"tiny servant|xge":[3,"T","1 min","Touch","VS","8 hr","","","",""],
822"toll the dead|xge":[0,"N","Action","60 ft","VS","Instant","","necrotic","WIS","1:1d8/1d12 5:2d8/2d12 11:3d8/3d12 17:4d8/4d12"],
823"toll the dead|xphb":[0,"N","Action","60 ft","VS","Instant","","necrotic","WIS","1:1d8/1d12 5:2d8/2d12 11:3d8/3d12 17:4d8/4d12"],
824"tongues|phb":[3,"D","Action","Touch","VM","1 hr","","","",""],
825"tongues|xphb":[3,"D","Action","Touch","VM","1 hr","","","",""],
826"transmute rock|xge":[5,"T","Action","120 ft","VSM","Until dispelled","","bludgeoning","STR/DEX",""],
827"transport via plants|phb":[6,"C","Action","10 ft","VS","1 round","","","",""],
828"transport via plants|xphb":[6,"C","Action","10 ft","VS","1 min","","","",""],
829"tree stride|phb":[5,"C","Action","Self","VS","1 min","conc","","",""],
830"tree stride|xphb":[5,"C","Action","Self","VS","1 min","conc","","",""],
831"true polymorph|phb":[9,"T","Action","30 ft","VSM","1 hr","conc","","WIS",""],
832"true polymorph|xphb":[9,"T","Action","30 ft","VSM","1 hr","conc","","WIS",""],
833"true resurrection|phb":[9,"N","1 hr","Touch","VSM","Instant","heal","","",""],
834"true resurrection|xphb":[9,"N","1 hr","Touch","VSM","Instant","heal","","",""],
835"true seeing|phb":[6,"D","Action","Touch","VSM","1 hr","","","",""],
836"true seeing|xphb":[6,"D","Action","Touch","VSM","1 hr","","","",""],
837"true strike|phb":[0,"D","Action","30 ft","S","1 round","conc","","",""],
838"true strike|xphb":[0,"D","Action","Self","SM","Instant","","radiant","","5:1d6 11:2d6 17:3d6"],
839"tsunami|phb":[8,"C","1 min","Sight","VS","6 rounds","conc","bludgeoning","STR",""],
840"tsunami|xphb":[8,"C","1 min","1 mile","VS","6 rounds","conc","bludgeoning","STR",""],
841"unseen servant|phb":[1,"C","Action","60 ft","VSM","1 hr","ritual","","",""],
842"unseen servant|xphb":[1,"C","Action","60 ft","VSM","1 hr","ritual","","",""],
843"vampiric touch|phb":[3,"N","Action","Self","VS","1 min","conc melee","necrotic","","3d6"],
844"vampiric touch|xphb":[3,"N","Action","Self","VS","1 min","conc melee","necrotic","","3d6"],
845"vicious mockery|phb":[0,"E","Action","60 ft","V","Instant","","psychic","WIS","1:1d4 5:2d4 11:3d4 17:4d4"],
846"vicious mockery|xphb":[0,"E","Action","60 ft","V","Instant","","psychic","WIS","1:1d6 5:2d6 11:3d6 17:4d6"],
847"vitriolic sphere|xge":[4,"V","Action","150 ft","VSM","Instant","","acid","DEX","10d4"],
848"vitriolic sphere|xphb":[4,"V","Action","150 ft","VSM","Instant","","acid","DEX","10d4"],
849"wall of fire|phb":[4,"V","Action","120 ft","VSM","1 min","conc","fire","DEX","5d8"],
850"wall of fire|xphb":[4,"V","Action","120 ft","VSM","1 min","conc","fire","DEX","5d8"],
851"wall of force|phb":[5,"V","Action","120 ft","VSM","10 min","conc","","",""],
852"wall of force|xphb":[5,"V","Action","120 ft","VSM","10 min","conc","","",""],
853"wall of ice|phb":[6,"V","Action","120 ft","VSM","10 min","conc","cold","DEX/CON",""],
854"wall of ice|xphb":[6,"V","Action","120 ft","VSM","10 min","conc","cold","DEX/CON",""],
855"wall of light|xge":[5,"V","Action","120 ft","VSM","10 min","conc ranged","radiant","CON","4d8"],
856"wall of sand|xge":[3,"V","Action","90 ft","VSM","10 min","conc","","",""],
857"wall of stone|phb":[5,"V","Action","120 ft","VSM","10 min","conc","","DEX",""],
858"wall of stone|xphb":[5,"V","Action","120 ft","VSM","10 min","conc","","DEX",""],
859"wall of thorns|phb":[6,"C","Action","120 ft","VSM","10 min","conc","piercing,slashing","DEX","7d8"],
860"wall of thorns|xphb":[6,"C","Action","120 ft","VSM","10 min","conc","piercing,slashing","DEX","7d8"],
861"wall of water|xge":[3,"V","Action","60 ft","VSM","10 min","conc","","",""],
862"warding bond|phb":[2,"A","Action","Touch","VSM","1 hr","","","",""],
863"warding bond|xphb":[2,"A","Action","Touch","VSM","1 hr","","","",""],
864"warding wind|xge":[2,"V","Action","Self","V","10 min","conc","","",""],
865"water breathing|phb":[3,"T","Action","30 ft","VSM","24 hr","ritual","","",""],
866"water breathing|xphb":[3,"T","Action","30 ft","VSM","24 hr","ritual","","",""],
867"water walk|phb":[3,"T","Action","30 ft","VSM","1 hr","ritual","","",""],
868"water walk|xphb":[3,"T","Action","30 ft","VSM","1 hr","ritual","","",""],
869"watery sphere|xge":[4,"C","Action","90 ft","VSM","1 min","conc","","STR",""],
870"web|phb":[2,"C","Action","60 ft","VSM","1 hr","conc","fire","DEX",""],
871"web|xphb":[2,"C","Action","60 ft","VSM","1 hr","conc","fire","DEX",""],
872"weird|phb":[9,"I","Action","120 ft","VS","1 min","conc","psychic","WIS",""],
873"weird|xphb":[9,"I","Action","120 ft","VS","1 min","conc","psychic","WIS",""],
874"whirlwind|xge":[7,"V","Action","300 ft","VM","1 min","conc","bludgeoning","DEX/STR",""],
875"wind walk|phb":[6,"T","1 min","30 ft","VSM","8 hr","","","",""],
876"wind walk|xphb":[6,"T","1 min","30 ft","VSM","8 hr","","","",""],
877"wind wall|phb":[3,"V","Action","120 ft","VSM","1 min","conc","bludgeoning","STR",""],
878"wind wall|xphb":[3,"V","Action","120 ft","VSM","1 min","conc","bludgeoning","STR",""],
879"wish|phb":[9,"C","Action","Self","V","Instant","","necrotic","",""],
880"wish|xphb":[9,"C","Action","Self","V","Instant","","necrotic","",""],
881"witch bolt|phb":[1,"V","Action","30 ft","VSM","1 min","conc ranged","lightning","","1d12"],
882"witch bolt|xphb":[1,"V","Action","60 ft","VSM","1 min","conc ranged","lightning","","2d12"],
883"word of radiance|xge":[0,"V","Action","5 ft","VM","Instant","","radiant","CON","1:1d6 5:2d6 11:3d6 17:4d6"],
884"word of radiance|xphb":[0,"V","Action","5 ft emanation","VM","Instant","","radiant","CON","1:1d6 5:2d6 11:3d6 17:4d6"],
885"word of recall|phb":[6,"C","Action","5 ft","V","Instant","","","",""],
886"word of recall|xphb":[6,"C","Action","5 ft","V","Instant","","","",""],
887"wrath of nature|xge":[5,"V","Action","120 ft","VS","1 min","conc ranged","bludgeoning,slashing","DEX/STR",""],
888"wrathful smite|phb":[1,"V","Bonus Action","Self","V","1 min","conc","psychic","WIS",""],
889"wrathful smite|xphb":[1,"N","Bonus Action","Self","V","1 min","","necrotic","WIS","1d6"],
890"yolande s regal presence|xphb":[5,"E","Action","10 ft emanation","VSM","1 min","conc","psychic","WIS",""],
891"zephyr strike|xge":[1,"T","Bonus Action","Self","V","1 min","conc","force","",""],
892"zone of truth|phb":[2,"E","Action","60 ft","VS","10 min","","","CHA",""],
893"zone of truth|xphb":[2,"E","Action","60 ft","VS","10 min","","","CHA",""],
894}
895
896/** Other names the data gives a spell (Firebolt), to the name keyed above. */
897export const SPELL_ALIASES: Record<string, string> = {
898"firebolt":"fire bolt",
899}
900
901export const WEAPONS: Record<string, WeaponRow> = {
902"antimatter rifle|dmg":["martial","ranged","6d8","necrotic","","Ammunition,Reload,Two-Handed","120/360",""],
903"antimatter rifle|xdmg":["martial","ranged","6d8","necrotic","","Ammunition,Reload,Two-Handed","120/360","Sap"],
904"automatic pistol|dmg":["martial","ranged","2d6","piercing","","Ammunition,Reload","50/150",""],
905"automatic rifle|dmg":["martial","ranged","2d8","piercing","","Ammunition,Burst Fire,Reload,Two-Handed","80/240",""],
906"automatic rifle|xdmg":["martial","ranged","2d8","piercing","","Ammunition,Burst Fire,Reload,Two-Handed","80/240","Slow"],
907"battleaxe|phb":["martial","melee","1d8","slashing","1d10","Versatile","",""],
908"battleaxe|xphb":["martial","melee","1d8","slashing","1d10","Versatile","","Topple"],
909"blowgun|phb":["martial","ranged","1","piercing","","Ammunition,Loading","25/100",""],
910"blowgun|xphb":["martial","ranged","1","piercing","","Ammunition,Loading","25/100","Vex"],
911"club|phb":["simple","melee","1d4","bludgeoning","","Light","",""],
912"club|xphb":["simple","melee","1d4","bludgeoning","","Light","","Slow"],
913"dagger|phb":["simple","melee","1d4","piercing","","Finesse,Light,Thrown","20/60",""],
914"dagger|xphb":["simple","melee","1d4","piercing","","Finesse,Light,Thrown","20/60","Nick"],
915"dart|phb":["simple","ranged","1d4","piercing","","Finesse,Thrown","20/60",""],
916"dart|xphb":["simple","ranged","1d4","piercing","","Finesse,Thrown","20/60","Vex"],
917"flail|phb":["martial","melee","1d8","bludgeoning","","","",""],
918"flail|xphb":["martial","melee","1d8","bludgeoning","","","","Sap"],
919"glaive|phb":["martial","melee","1d10","slashing","","Heavy,Reach,Two-Handed","",""],
920"glaive|xphb":["martial","melee","1d10","slashing","","Heavy,Reach,Two-Handed","","Graze"],
921"greataxe|phb":["martial","melee","1d12","slashing","","Heavy,Two-Handed","",""],
922"greataxe|xphb":["martial","melee","1d12","slashing","","Heavy,Two-Handed","","Cleave"],
923"greatclub|phb":["simple","melee","1d8","bludgeoning","","Two-Handed","",""],
924"greatclub|xphb":["simple","melee","1d8","bludgeoning","","Two-Handed","","Push"],
925"greatsword|phb":["martial","melee","2d6","slashing","","Heavy,Two-Handed","",""],
926"greatsword|xphb":["martial","melee","2d6","slashing","","Heavy,Two-Handed","","Graze"],
927"halberd|phb":["martial","melee","1d10","slashing","","Heavy,Reach,Two-Handed","",""],
928"halberd|xphb":["martial","melee","1d10","slashing","","Heavy,Reach,Two-Handed","","Cleave"],
929"hand crossbow|phb":["martial","ranged","1d6","piercing","","Ammunition,Light,Loading","30/120",""],
930"hand crossbow|xphb":["martial","ranged","1d6","piercing","","Ammunition,Light,Loading","30/120","Vex"],
931"handaxe|phb":["simple","melee","1d6","slashing","","Light,Thrown","20/60",""],
932"handaxe|xphb":["simple","melee","1d6","slashing","","Light,Thrown","20/60","Vex"],
933"heavy crossbow|phb":["martial","ranged","1d10","piercing","","Ammunition,Heavy,Loading,Two-Handed","100/400",""],
934"heavy crossbow|xphb":["martial","ranged","1d10","piercing","","Ammunition,Heavy,Loading,Two-Handed","100/400","Push"],
935"hunting rifle|dmg":["martial","ranged","2d10","piercing","","Ammunition,Reload,Two-Handed","80/240",""],
936"hunting rifle|xdmg":["martial","ranged","2d10","piercing","","Ammunition,Reload,Two-Handed","80/240","Slow"],
937"javelin|phb":["simple","melee","1d6","piercing","","Thrown","30/120",""],
938"javelin|xphb":["simple","melee","1d6","piercing","","Thrown","30/120","Slow"],
939"lance|phb":["martial","melee","1d12","piercing","","Reach,Special","",""],
940"lance|xphb":["martial","melee","1d10","piercing","","Heavy,Reach,Two-Handed (unless mounted)","","Topple"],
941"laser pistol|dmg":["martial","ranged","3d6","radiant","","Ammunition,Reload","40/120",""],
942"laser pistol|xdmg":["martial","ranged","3d6","radiant","","Ammunition,Reload","40/120","Vex"],
943"laser rifle|dmg":["martial","ranged","3d8","radiant","","Ammunition,Reload,Two-Handed","100/300",""],
944"laser rifle|xdmg":["martial","ranged","3d8","radiant","","Ammunition,Reload,Two-Handed","100/300","Slow"],
945"light crossbow|phb":["simple","ranged","1d8","piercing","","Ammunition,Loading,Two-Handed","80/320",""],
946"light crossbow|xphb":["simple","ranged","1d8","piercing","","Ammunition,Loading,Two-Handed","80/320","Slow"],
947"light hammer|phb":["simple","melee","1d4","bludgeoning","","Light,Thrown","20/60",""],
948"light hammer|xphb":["simple","melee","1d4","bludgeoning","","Light,Thrown","20/60","Nick"],
949"longbow|phb":["martial","ranged","1d8","piercing","","Ammunition,Heavy,Two-Handed","150/600",""],
950"longbow|xphb":["martial","ranged","1d8","piercing","","Ammunition,Heavy,Two-Handed","150/600","Slow"],
951"longsword|phb":["martial","melee","1d8","slashing","1d10","Versatile","",""],
952"longsword|xphb":["martial","melee","1d8","slashing","1d10","Versatile","","Sap"],
953"mace|phb":["simple","melee","1d6","bludgeoning","","","",""],
954"mace|xphb":["simple","melee","1d6","bludgeoning","","","","Sap"],
955"maul|phb":["martial","melee","2d6","bludgeoning","","Heavy,Two-Handed","",""],
956"maul|xphb":["martial","melee","2d6","bludgeoning","","Heavy,Two-Handed","","Topple"],
957"morningstar|phb":["martial","melee","1d8","piercing","","","",""],
958"morningstar|xphb":["martial","melee","1d8","piercing","","","","Sap"],
959"musket|dmg":["martial","ranged","1d12","piercing","","Ammunition,Loading,Two-Handed","40/120",""],
960"musket|xphb":["martial","ranged","1d12","piercing","","Ammunition,Loading,Two-Handed","40/120","Slow"],
961"net|phb":["martial","ranged","","","","Special,Thrown","5/15",""],
962"pike|phb":["martial","melee","1d10","piercing","","Heavy,Reach,Two-Handed","",""],
963"pike|xphb":["martial","melee","1d10","piercing","","Heavy,Reach,Two-Handed","","Push"],
964"pistol|dmg":["martial","ranged","1d10","piercing","","Ammunition,Loading","30/90",""],
965"pistol|xphb":["martial","ranged","1d10","piercing","","Ammunition,Loading","30/90","Vex"],
966"quarterstaff|phb":["simple","melee","1d6","bludgeoning","1d8","Versatile","",""],
967"quarterstaff|xphb":["simple","melee","1d6","bludgeoning","1d8","Versatile","","Topple"],
968"rapier|phb":["martial","melee","1d8","piercing","","Finesse","",""],
969"rapier|xphb":["martial","melee","1d8","piercing","","Finesse","","Vex"],
970"revolver|dmg":["martial","ranged","2d8","piercing","","Ammunition,Reload","40/120",""],
971"revolver|xdmg":["martial","ranged","2d8","piercing","","Ammunition,Reload","40/120","Sap"],
972"scimitar|phb":["martial","melee","1d6","slashing","","Finesse,Light","",""],
973"scimitar|xphb":["martial","melee","1d6","slashing","","Finesse,Light","","Nick"],
974"semiautomatic pistol|xdmg":["martial","ranged","2d6","piercing","","Ammunition,Reload","50/150","Vex"],
975"shortbow|phb":["simple","ranged","1d6","piercing","","Ammunition,Two-Handed","80/320",""],
976"shortbow|xphb":["simple","ranged","1d6","piercing","","Ammunition,Two-Handed","80/320","Vex"],
977"shortsword|phb":["martial","melee","1d6","piercing","","Finesse,Light","",""],
978"shortsword|xphb":["martial","melee","1d6","piercing","","Finesse,Light","","Vex"],
979"shotgun|dmg":["martial","ranged","2d8","piercing","","Ammunition,Reload,Two-Handed","30/90",""],
980"shotgun|xdmg":["martial","ranged","2d8","piercing","","Ammunition,Reload,Two-Handed","30/90","Push"],
981"sickle|phb":["simple","melee","1d4","slashing","","Light","",""],
982"sickle|xphb":["simple","melee","1d4","slashing","","Light","","Nick"],
983"sling|phb":["simple","ranged","1d4","bludgeoning","","Ammunition","30/120",""],
984"sling|xphb":["simple","ranged","1d4","bludgeoning","","Ammunition","30/120","Slow"],
985"spear|phb":["simple","melee","1d6","piercing","1d8","Thrown,Versatile","20/60",""],
986"spear|xphb":["simple","melee","1d6","piercing","1d8","Thrown,Versatile","20/60","Sap"],
987"trident|phb":["martial","melee","1d6","piercing","1d8","Thrown,Versatile","20/60",""],
988"trident|xphb":["martial","melee","1d8","piercing","1d10","Thrown,Versatile","20/60","Topple"],
989"war pick|phb":["martial","melee","1d8","piercing","","","",""],
990"war pick|xphb":["martial","melee","1d8","piercing","1d10","Versatile","","Sap"],
991"warhammer|phb":["martial","melee","1d8","bludgeoning","1d10","Versatile","",""],
992"warhammer|xphb":["martial","melee","1d8","bludgeoning","1d10","Versatile","","Push"],
993"whip|phb":["martial","melee","1d4","slashing","","Finesse,Reach","",""],
994"whip|xphb":["martial","melee","1d4","slashing","","Finesse,Reach","","Slow"],
995"yklwa|toa":["simple","melee","1d8","piercing","","Thrown","10/30",""],
996}
997