From 55489d0b98e882fb5fde33c70a7ecaed45cbb651 Mon Sep 17 00:00:00 2001 From: Dan-314 Date: Sat, 11 Apr 2026 01:40:21 +0100 Subject: [PATCH] inital commit --- .github/workflows/sync.yml | 156 +++++++++++++++++++++++++++++++++ .github/workflows/validate.yml | 117 +++++++++++++++++++++++++ CONTRIBUTING.md | 130 +++++++++++++++++++++++++++ LICENSE | 116 ++++++++++++++++++++++++ README.md | 61 +++++++++++++ index.json | 1 + schema/script.schema.json | 64 ++++++++++++++ scripts/.gitkeep | 0 sources/ravenswoodarchive.json | 8 ++ 9 files changed, 653 insertions(+) create mode 100644 .github/workflows/sync.yml create mode 100644 .github/workflows/validate.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.json create mode 100644 schema/script.schema.json create mode 100644 scripts/.gitkeep create mode 100644 sources/ravenswoodarchive.json diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 0000000..23c8427 --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,156 @@ +name: Sync Scripts + +on: + schedule: + - cron: '0 3 * * *' # Daily at 3am UTC + workflow_dispatch: # Manual trigger + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Sync scripts from all sources + run: | + node -e " + const fs = require('fs'); + const path = require('path'); + const https = require('https'); + const http = require('http'); + + function fetch(url) { + return new Promise((resolve, reject) => { + const mod = url.startsWith('https') ? https : http; + mod.get(url, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode !== 200) return reject(new Error('HTTP ' + res.statusCode)); + resolve(JSON.parse(data)); + }); + }).on('error', reject); + }); + } + + async function syncSource(slug, manifest) { + if (manifest.sync_method !== 'github_action' || !manifest.api_endpoint) { + console.log('Skipping ' + slug + ' (sync_method=' + manifest.sync_method + ')'); + return; + } + + console.log('Syncing from ' + slug + ' (' + manifest.api_endpoint + ')...'); + let cursor = null; + let total = 0; + + while (true) { + let url = manifest.api_endpoint + '?limit=100'; + if (cursor) url += '&cursor=' + cursor; + + let response; + try { response = await fetch(url); } + catch (e) { console.error('Failed to fetch from ' + slug + ': ' + e.message); break; } + + for (const script of response.scripts) { + // Write script file using JSON.stringify (matches Postgres jsonb::text format) + const content = JSON.stringify(script.raw_json); + fs.writeFileSync(path.join('scripts', script.hash + '.json'), content); + + // Update source manifest + manifest.scripts[script.hash] = { + source_id: script.source_id, + source_url: script.source_url, + synced_at: new Date().toISOString() + }; + total++; + } + + cursor = response.next_cursor; + if (!cursor) break; + } + + // Write updated manifest + fs.writeFileSync( + path.join('sources', slug + '.json'), + JSON.stringify(manifest, null, 2) + ); + console.log('Done syncing ' + slug + ': ' + total + ' script(s)'); + } + + async function main() { + const sourcesDir = 'sources'; + for (const f of fs.readdirSync(sourcesDir)) { + if (!f.endsWith('.json')) continue; + const slug = f.replace('.json', ''); + const manifest = JSON.parse(fs.readFileSync(path.join(sourcesDir, f), 'utf8')); + await syncSource(slug, manifest); + } + } + + main().catch(e => { console.error(e); process.exit(1); }); + " + + - name: Rebuild index + run: | + node -e " + const fs = require('fs'); + const path = require('path'); + + const scriptsDir = 'scripts'; + const sourcesDir = 'sources'; + const index = []; + + // Load all source manifests + const sources = {}; + for (const f of fs.readdirSync(sourcesDir)) { + if (!f.endsWith('.json')) continue; + const slug = f.replace('.json', ''); + sources[slug] = JSON.parse(fs.readFileSync(path.join(sourcesDir, f), 'utf8')); + } + + // Build index from script files + for (const f of fs.readdirSync(scriptsDir)) { + if (!f.endsWith('.json')) continue; + const hash = f.replace('.json', ''); + const script = JSON.parse(fs.readFileSync(path.join(scriptsDir, f), 'utf8')); + const meta = script.find(e => e && e.id === '_meta') || {}; + const chars = script.filter(e => typeof e === 'string' || (e && e.id !== '_meta')); + + // Find which sources have this hash + const scriptSources = Object.entries(sources) + .filter(([, s]) => s.scripts && s.scripts[hash]) + .map(([slug]) => slug); + + index.push({ + hash, + name: meta.name || 'Untitled', + author: meta.author || null, + character_count: chars.length, + sources: scriptSources + }); + } + + index.sort((a, b) => a.name.localeCompare(b.name)); + fs.writeFileSync('index.json', JSON.stringify(index, null, 2)); + console.log('Index rebuilt with ' + index.length + ' scripts'); + " + + - name: Commit and push + run: | + git config user.name "archive-bot" + git config user.email "bot@ravenswoodarchive.com" + git add scripts/ sources/ index.json + if git diff --cached --quiet; then + echo "No changes to commit" + else + count=$(git diff --cached --name-only -- scripts/ | wc -l) + git commit -m "sync: ${count} script(s) updated" + git push + fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3caba60 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,117 @@ +name: Validate PR + +on: + pull_request: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install ajv ajv-formats + + - name: Validate changed scripts + run: | + node -e " + const fs = require('fs'); + const crypto = require('crypto'); + const Ajv = require('ajv'); + + const ajv = new Ajv({ allErrors: true }); + const schema = JSON.parse(fs.readFileSync('schema/script.schema.json', 'utf8')); + const validate = ajv.compile(schema); + + // Get changed files from git + const { execSync } = require('child_process'); + const changed = execSync('git diff --name-only --diff-filter=ACMR origin/main...HEAD -- scripts/') + .toString().trim().split('\n').filter(Boolean); + + if (changed.length === 0) { + console.log('No script files changed'); + process.exit(0); + } + + let errors = 0; + + for (const file of changed) { + const hash = file.replace('scripts/', '').replace('.json', ''); + const content = fs.readFileSync(file, 'utf8'); + + let parsed; + try { + parsed = JSON.parse(content); + } catch (e) { + console.error('FAIL [' + file + ']: invalid JSON'); + errors++; + continue; + } + + // Re-serialize through JSON.stringify to match DB hash format (Postgres jsonb::text) + const canonical = JSON.stringify(parsed); + const actualHash = crypto.createHash('md5').update(canonical).digest('hex'); + + // Check filename matches content hash + if (hash !== actualHash) { + console.error('FAIL [' + file + ']: filename hash mismatch. Expected ' + actualHash + ', got ' + hash); + errors++; + continue; + } + + const script = parsed; + + if (!validate(script)) { + console.error('FAIL [' + file + ']: schema validation failed'); + for (const err of validate.errors) { + console.error(' ' + err.instancePath + ' ' + err.message); + } + errors++; + continue; + } + + // Check _meta block exists + const meta = script.find(e => e && e.id === '_meta'); + if (!meta) { + console.error('FAIL [' + file + ']: missing _meta block'); + errors++; + continue; + } + + console.log('PASS [' + file + ']: ' + meta.name); + } + + if (errors > 0) { + console.error(errors + ' file(s) failed validation'); + process.exit(1); + } + console.log('All ' + changed.length + ' file(s) passed validation'); + " + + - name: Check source manifest integrity + run: | + node -e " + const { execSync } = require('child_process'); + const changed = execSync('git diff --name-only --diff-filter=ACMR origin/main...HEAD -- sources/') + .toString().trim().split('\n').filter(Boolean); + + if (changed.length === 0) { + console.log('No source manifests changed'); + process.exit(0); + } + + // PR author should only modify their own source manifest + if (changed.length > 1) { + console.error('FAIL: PR modifies multiple source manifests: ' + changed.join(', ')); + console.error('Each contributor should only modify their own sources/{site}.json'); + process.exit(1); + } + + console.log('Source manifest check passed: ' + changed[0]); + " diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f6cb3bf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,130 @@ +# Contributing to Ravenswood Archive Stacks + +Thanks for helping preserve BotC scripts! This guide covers how to add scripts to the archive. + +## Script File Format + +Each script file in `scripts/` is a **standard BotC JSON array** — the same format used by clocktower.online and other BotC tools: + +```json +[ + { "id": "_meta", "name": "My Script", "author": "Author Name" }, + { "id": "washerwoman" }, + { "id": "librarian" }, + "empath", + { "id": "custom_char", "name": "Custom", "team": "townsfolk", "ability": "..." } +] +``` + +### Rules + +- First element must have `"id": "_meta"` with at least a `"name"` field +- Remaining elements are character references (strings or objects with `"id"`) +- File must validate against `schema/script.schema.json` + +## Naming Convention + +**Filenames are the MD5 hash of the file contents.** This ensures identical scripts are never duplicated. + +To compute the correct filename: + +```bash +# The hash of the file content becomes the filename +md5sum my-script.json +# Output: abc123... my-script.json +# Rename to: scripts/abc123... .json +``` + +Or in Node.js: + +```js +const crypto = require('crypto'); +const fs = require('fs'); +const content = fs.readFileSync('my-script.json', 'utf8').trim(); +const hash = crypto.createHash('md5').update(content).digest('hex'); +// Filename should be: scripts/${hash}.json +``` + +## Method 1: Pull Request (Manual) + +Best for one-off contributions or sites without an API. + +1. Fork this repo +2. Add your script files to `scripts/` (named by content hash) +3. Create or update your source manifest at `sources/{your-site}.json`: + +```json +{ + "name": "Your Site Name", + "url": "https://your-site.com", + "contact": "you@example.com", + "sync_method": "pull_request", + "scripts": { + "abc123...": { + "source_id": "your-internal-id", + "source_url": "https://your-site.com/scripts/your-internal-id", + "synced_at": "2026-01-15T00:00:00Z" + } + } +} +``` + +4. Open a PR - CI will validate your scripts automatically + +### What CI checks + +- Filename matches MD5 hash of file contents +- Script validates against the JSON schema +- `_meta` block exists with a `name` +- Only your own `sources/{site}.json` is modified + +## Method 2: Automated Sync (GitHub Action) + +Best for sites with an export API endpoint. + +1. Implement an export API on your site that returns paginated scripts: + +``` +GET /api/export-scripts?cursor={id}&limit=100 + +Response: +{ + "scripts": [ + { + "hash": "md5-of-raw-json", + "raw_json": [...], + "source_id": "your-internal-id", + "source_url": "https://your-site.com/scripts/id" + } + ], + "next_cursor": "next-id-or-null" +} +``` + +2. Open a PR adding your `sources/{your-site}.json` with `"sync_method": "github_action"` and your `api_endpoint` +3. Once merged, the daily sync action will automatically pull your scripts + +## Method 3: Direct Push (Trusted Contributors) + +For high-frequency contributors. Request a deploy key by opening an issue. You'll get write access scoped to your `sources/{site}.json` and `scripts/`. + +## Source Manifest Schema + +Each source manifest (`sources/{site}.json`) tracks provenance: + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Display name of your site | +| `url` | Yes | URL of your site | +| `contact` | Yes | Contact email | +| `sync_method` | Yes | `"github_action"`, `"pull_request"`, or `"manual"` | +| `api_endpoint` | If sync_method=github_action | Your export API URL | +| `scripts` | Yes | Map of content hash to source metadata | + +## Duplicate Scripts + +If a script already exists in `scripts/` (same content hash from another source), that's fine — just add a reference in your source manifest. The `index.json` will list all sources for each script. + +## Questions? + +Open an issue on this repo. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..22e2483 --- /dev/null +++ b/LICENSE @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescinding, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not invalidate +the remainder of the License, and in such case Affirmer hereby affirms that he +or she will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of action +with respect to the Work, in either case contrary to Affirmer's express +Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions or + other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this CC0 + or use of the Work. + +For more information, please see + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0f1c09 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Ravenswood Archive Stacks + +A community-owned, content-addressed archive of [Blood on the Clocktower](https://bloodontheclocktower.com/) scripts. Any BotC script site can contribute scripts here, ensuring no scripts are lost if any single service goes down. + +## How It Works + +- Each script is stored as a standard BotC JSON file in `scripts/` +- Filenames are the MD5 hash of the file contents (content-addressed = automatic dedup) +- Scripts are pure BotC JSON — usable directly in any BotC tool (clocktower.online, etc.) +- `index.json` provides a searchable index of all scripts with name, author, and source info + +## Using the Archive + +### Download everything + +```bash +git clone https://github.com/Dan-314/ravenswood-archive-stacks.git +``` + +### Browse the index + +[`index.json`](index.json) contains metadata for every script: + +```json +[ + { + "hash": "abc123...", + "name": "Trouble Brewing Modified", + "author": "Steven Medway", + "character_count": 25, + "sources": ["ravenswoodarchive"] + } +] +``` + +### Use a single script + +Each file in `scripts/` is a standard BotC script JSON array. Download any `scripts/{hash}.json` and import it directly into your favourite BotC tool. + +## Contributing Scripts + +See [CONTRIBUTING.md](CONTRIBUTING.md) for full details. Three ways to contribute: + +1. **Automated sync** — Expose an export API, register as a source, and scripts sync daily via GitHub Action +2. **Pull request** — Add script files to `scripts/` and update your `sources/{site}.json` manifest +3. **Direct push** — For trusted, high-frequency contributors with deploy key access + +## Structure + +``` +scripts/ Raw BotC script JSON files, named by content hash +sources/ Source manifests — which scripts came from which site +schema/ JSON Schema for BotC script validation +index.json Auto-generated searchable index +``` + +## License + +[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain. See [LICENSE](LICENSE). + +Blood on the Clocktower is created and owned by [The Pandemonium Institute](https://www.thepandemoniumInstitute.com/). diff --git a/index.json b/index.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/index.json @@ -0,0 +1 @@ +[] diff --git a/schema/script.schema.json b/schema/script.schema.json new file mode 100644 index 0000000..d76ce05 --- /dev/null +++ b/schema/script.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "BotC Script", + "description": "Blood on the Clocktower script in standard JSON format", + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "type": "object", + "description": "Script metadata (_meta block)", + "properties": { + "id": { "type": "string", "const": "_meta" }, + "name": { "type": "string", "minLength": 1 } + }, + "required": ["id", "name"] + } + ], + "items": { + "oneOf": [ + { + "type": "string", + "description": "Official character ID reference" + }, + { + "type": "object", + "description": "Character entry (official reference or custom definition)", + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string" }, + "team": { + "type": "string", + "enum": ["townsfolk", "outsider", "minion", "demon", "traveller", "fabled", "loric"] + }, + "ability": { "type": "string" }, + "image": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "firstNight": { "type": "number" }, + "firstNightReminder": { "type": "string" }, + "otherNight": { "type": "number" }, + "otherNightReminder": { "type": "string" }, + "reminders": { "type": "array", "items": { "type": "string" } }, + "remindersGlobal": { "type": "array", "items": { "type": "string" } }, + "setup": { "type": "boolean" }, + "jinxes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["id", "reason"] + } + } + }, + "required": ["id"] + } + ] + } +} diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sources/ravenswoodarchive.json b/sources/ravenswoodarchive.json new file mode 100644 index 0000000..e07263b --- /dev/null +++ b/sources/ravenswoodarchive.json @@ -0,0 +1,8 @@ +{ + "name": "Ravenswood Archive", + "url": "https://ravenswoodarchive.com", + "contact": "dan@ravenswoodarchive.com", + "sync_method": "github_action", + "api_endpoint": "https://ravenswoodarchive.com/api/export-scripts", + "scripts": {} +}