Back to Black Site
SOURCE / PINNED RELEASE

Made of little things.

Black Site

Release
666bc7c74ebd…
Author-recorded commit
05e95af79676…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391

Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

spec/lib/validate-campaign.mjs
// Dependency-free validator for Black Site campaigns (version 1). Mirrors
// spec/schemas/black-site-campaign.schema.json. Usage: validateCampaign(campaign) -> [] when valid,
// otherwise a list of "path: problem" strings. Map editors can copy this file and the schema.

import { MAP_EVENT_KIND } from './map-event.mjs';

export const CAMPAIGN_VERSION = 1;
export const BUILTIN_MAP_IDS = ['town', 'docks', 'desert', 'snow', 'refinery'];
export const DIFFICULTY_IDS = ['easy', 'normal', 'hard'];
export const MAX_STAGES = 50;
/** Rule values used when a campaign leaves them out (the built-in campaign's rules). */
export const DEFAULT_RULES = { completionBonus: 3000, quitPenalty: 400, carryInventory: true, restoreHealth: true };

const HEX64 = /^[0-9a-f]{64}$/;
const MAP_ADDRESS = new RegExp(`^${MAP_EVENT_KIND}:[0-9a-f]{64}:[a-z0-9][a-z0-9-]{0,31}$`);

export function validateCampaign(campaign, { builtinMaps = BUILTIN_MAP_IDS } = {}) {
  const errors = [];
  const err = (path, msg) => errors.push(`${path}: ${msg}`);
  if (!isObj(campaign)) return ['campaign: must be an object'];

  for (const k of Object.keys(campaign)) if (!['version', 'name', 'desc', 'rules', 'stages'].includes(k)) err(k, 'unknown field');
  if (campaign.version !== CAMPAIGN_VERSION) err('version', `must be ${CAMPAIGN_VERSION}`);
  if (typeof campaign.name !== 'string' || !campaign.name.length || campaign.name.length > 32) err('name', 'must be a string of 1-32 chars');
  if (campaign.desc !== undefined && (typeof campaign.desc !== 'string' || campaign.desc.length > 200)) err('desc', 'must be a string of at most 200 chars');

  const r = campaign.rules;
  if (r !== undefined) {
    if (!isObj(r)) err('rules', 'must be an object');
    else {
      for (const k of Object.keys(r)) if (!['completionBonus', 'quitPenalty', 'difficulty', 'carryInventory', 'restoreHealth'].includes(k)) err(`rules.${k}`, 'unknown field');
      if (r.completionBonus !== undefined && !isInt(r.completionBonus, 0, 1000000)) err('rules.completionBonus', 'must be an integer 0-1000000');
      if (r.quitPenalty !== undefined && !isInt(r.quitPenalty, 0, 100000)) err('rules.quitPenalty', 'must be an integer 0-100000');
      if (r.difficulty !== undefined && !DIFFICULTY_IDS.includes(r.difficulty)) err('rules.difficulty', `must be one of ${DIFFICULTY_IDS.join(', ')}`);
      for (const k of ['carryInventory', 'restoreHealth']) if (r[k] !== undefined && typeof r[k] !== 'boolean') err(`rules.${k}`, 'must be a boolean');
    }
  }

  const stages = campaign.stages;
  if (!Array.isArray(stages) || !stages.length || stages.length > MAX_STAGES) err('stages', `must be a list of 1-${MAX_STAGES} stages`);
  else stages.forEach((s, i) => {
    const p = `stages[${i}]`;
    if (!isObj(s)) return err(p, 'must be an object');
    if (s.briefing !== undefined && (typeof s.briefing !== 'string' || s.briefing.length > 500)) err(`${p}.briefing`, 'must be a string of at most 500 chars');
    if ('builtin' in s) {
      for (const k of Object.keys(s)) if (!['builtin', 'briefing'].includes(k)) err(`${p}.${k}`, 'not allowed on a built-in stage');
      if (!builtinMaps.includes(s.builtin)) err(`${p}.builtin`, `must be one of ${builtinMaps.join(', ')}`);
    } else if ('address' in s) {
      for (const k of Object.keys(s)) if (!['address', 'x', 'relays', 'briefing'].includes(k)) err(`${p}.${k}`, 'unknown field');
      if (typeof s.address !== 'string' || !MAP_ADDRESS.test(s.address)) err(`${p}.address`, `must be ${MAP_EVENT_KIND}:<pubkey hex>:<map id>`);
      if (typeof s.x !== 'string' || !HEX64.test(s.x)) err(`${p}.x`, 'must be the 64-char lowercase hex SHA-256 of the map JSON');
      if (s.relays !== undefined && (!Array.isArray(s.relays) || s.relays.length > 5 || !s.relays.every(u => typeof u === 'string' && /^wss:\/\//.test(u)))) err(`${p}.relays`, 'must be up to 5 wss:// URLs');
    } else err(p, 'must have either "builtin" or "address" and "x"');
  });

  return errors;
}

/** The campaign's rules with defaults filled in. */
export function campaignRules(campaign) {
  return { ...DEFAULT_RULES, ...(campaign.rules || {}) };
}

function isObj(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
function isInt(v, lo, hi) { return Number.isInteger(v) && v >= lo && v <= hi; }