14 lines
3.9 KiB
JavaScript

import {readFileSync} from 'node:fs';import {gunzipSync} from 'node:zlib';import {createHash} from 'node:crypto';import {z} from 'zod';
const bytes=readFileSync(new URL('./data/srd-catalogue.json.gz',import.meta.url));
export const seed=JSON.parse(gunzipSync(bytes).toString());
const checksum=createHash('sha256').update(bytes).digest('hex');
export const catalogueQuery=z.object({edition:z.enum(['2014','2024']).default('2024'),kind:z.enum(['action','spell','item']).default('spell'),q:z.string().max(120).default(''),category:z.string().max(60).default(''),level:z.coerce.number().int().min(0).max(9).optional(),offset:z.coerce.number().int().min(0).max(10000).default(0)});
export class Catalogue{
constructor(storage){this.pool=storage.pool;this.summary={seedVersion:seed.seedVersion,counts:Object.fromEntries(['2014','2024'].map(ed=>[ed,Object.fromEntries(['action','spell','item'].map(kind=>[kind,seed.entries.filter(e=>e.edition===ed&&e.kind===kind).length]))]))}}
async init(){await this.pool.query('CREATE TABLE IF NOT EXISTS rules_catalogue(id text PRIMARY KEY,edition text NOT NULL,kind text NOT NULL,name text NOT NULL,entry jsonb NOT NULL); CREATE TABLE IF NOT EXISTS rules_catalogue_seed(id text PRIMARY KEY,checksum text NOT NULL)');const client=await this.pool.connect();try{await client.query('BEGIN');const prior=(await client.query('SELECT checksum FROM rules_catalogue_seed WHERE id=$1',['official-srd'])).rows[0];if(prior?.checksum!==checksum){for(let i=0;i<seed.entries.length;i+=100){const batch=seed.entries.slice(i,i+100),params=batch.flatMap(e=>[e.id,e.edition,e.kind,e.name,e]),values=batch.map((_,j)=>`($${j*5+1},$${j*5+2},$${j*5+3},$${j*5+4},$${j*5+5})`).join(',');await client.query(`INSERT INTO rules_catalogue(id,edition,kind,name,entry) VALUES ${values} ON CONFLICT(id) DO UPDATE SET edition=EXCLUDED.edition,kind=EXCLUDED.kind,name=EXCLUDED.name,entry=EXCLUDED.entry`,params)}await client.query('INSERT INTO rules_catalogue_seed(id,checksum) VALUES($1,$2) ON CONFLICT(id) DO UPDATE SET checksum=EXCLUDED.checksum',['official-srd',checksum])}await client.query('COMMIT')}catch(e){await client.query('ROLLBACK');throw e}finally{client.release()}}
async list(input){const p=catalogueQuery.parse(input),rows=(await this.pool.query('SELECT entry FROM rules_catalogue WHERE edition=$1 AND kind=$2 ORDER BY name',[p.edition,p.kind])).rows;const term=p.q.toLocaleLowerCase(),all=rows.map(r=>r.entry).filter(e=>(!term||e.name.toLocaleLowerCase().includes(term))&&(!p.category||e.category===p.category)&&(p.level===undefined||e.level===p.level));return {entries:all.slice(p.offset,p.offset+40).map(({description,...e})=>e),total:all.length,offset:p.offset,sources:seed.sources}}
async get(id){return (await this.pool.query('SELECT entry FROM rules_catalogue WHERE id=$1',[id])).rows[0]?.entry}
}
export function installCatalogue(app,{catalogue}){app.use('/api/catalogue',(req,res,next)=>req.identity?next():res.status(401).json({error:'Sign in required'}));app.get('/api/catalogue',async(req,res,next)=>{try{res.json(await catalogue.list(req.query))}catch(e){if(e.name==='ZodError')return res.status(400).json({error:'Invalid catalogue filters'});next(e)}});app.get('/api/catalogue/:id',async(req,res,next)=>{try{const entry=await catalogue.get(req.params.id);if(!entry)return res.status(404).json({error:'Entry not found'});res.json({entry,sources:seed.sources})}catch(e){next(e)}})}
export function registerCatalogueTools({tool,catalogue,read}){tool('search_rules_catalogue','Search official SRD actions, spells and items by edition (2014 or 2024). Results include provenance; the editions must not be mixed implicitly.',catalogueQuery.shape,read,async a=>catalogue.list(a));tool('get_rules_entry','Read a full official SRD entry by its catalogue id. Reference content only; does not cast spells or apply rules to sheets.',{id:z.string().min(1).max(300)},read,async a=>{const entry=await catalogue.get(a.id);if(!entry)throw new Error('Entry not found');return {entry,sources:seed.sources}})}