SLOPSHOPPER

toa-sheet

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

newpanebandrowsguardcommand
★ 1v?no licenseupdated 2026-10-08NullDefault/toa-sheet/plugins/toa-sheet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · toa-sheet
│ ┃ Party ✕ › fix the failing auth test and add an audit log call │ ┃ NO SESSION · /party S04, or start the Table │ ┃ operation ⏺ Read(src/auth.ts) │ ┃ a: add c: packs i: init ⎿ Read 6 lines │ ┃ ── PARTY ──────────────────────────────────… ⏺ Update(src/auth.ts) │ ┃ AWAITING SHEETS █ ⎿ Added 2 lines, removed 1 line │ ┃ Press e to read every sheet this connector ⏺ Bash(bun test) │ ┃ serves, or ask Claude to. ⎿ 3 pass, 1 fail │ ┃ │ ┃ e: re-read ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sheet │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Party
NO SESSION · /party S04, or start the Table operation a: add c: packs i: init ── PARTY ─────────────────────────────────────────────── AWAITING SHEETS █ Press e to read every sheet this connector serves, or ask Claude to. e: re-read
README

Tomb of Annihilation: the sheet pane

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.

You need

  • Claude Code: the Code tab of the Claude desktop app, or claude in a terminal. It does not work in claude.ai chat, the desktop app's Chat tab or the phone.
  • The rules connector your DM sent you, already set up.

Install it (once)

  1. Open the Claude desktop app, go to the Code tab, and start a session in any folder.
  2. Copy this whole box, paste it as your message, and send it. Say yes when Claude asks to edit the file.
   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.
  1. Quit Claude completely (Cmd+Q on a Mac, not just closing the window) and open it again.

That's it. You never do this again.

Use it

  • The first time, ask Claude: show my sheet. The pane opens with your sheet.
  • After that, type /sheet in any session, any day. It shows the last sheet Claude read on this computer.
  • Jump to a tab: /sheet saves, /sheet gear, /sheet notes. The tabs are Actions, Saves, Features, Gear and Notes.
  • When Claude changes your sheet (damage, a long rest, a spent slot), the pane changes with it.

Updates

They arrive by themselves when you open Claude.

If /sheet isn't there

  • Quit Claude completely and open it once more. The first start downloads the pane.
  • Still nothing? In a terminal, run these two lines, then restart Claude:
  claude plugin marketplace add NullDefault/toa-sheet
  claude plugin install toa-sheet@toa
  • Still nothing? Send your DM a screenshot.

What it can do on your computer

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.

Source 10 files
hooks/register.ts 779 lines
1// 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}
779
hooks/fight.ts 263 lines
1// 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}
263
hooks/look.ts 123 lines
1// 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}
123
hooks/receipt.ts 252 lines
1// 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}
252
hooks/sheet.ts 266 lines
1// 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}
266
hooks/table.ts 73 lines
1// 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
73
hooks/view.ts 871 lines
1// 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}
871
hooks/actions.ts 259 lines
1// 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}
259
hooks/pixel.ts 146 lines
1// 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}
146
hooks/facts.ts 997 lines
1// 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