0265afa054
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
475 lines
17 KiB
JavaScript
475 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* validate-content.js
|
|
* Sysadmin Chronicles content validation tool.
|
|
*
|
|
* Run from project root:
|
|
* node tools/content/validate-content.js
|
|
* node tools/content/validate-content.js --quests-only
|
|
* node tools/content/validate-content.js --verbose
|
|
*
|
|
* Exit code 0 = no errors. Exit code 1 = validation errors found.
|
|
*
|
|
* Checks performed:
|
|
* - All JSON files parse correctly
|
|
* - No duplicate IDs across content domains
|
|
* - Every world flag referenced anywhere exists in world_flags.json
|
|
* - Every required_vms entry maps to a known VM profile
|
|
* - Every blast_radius entry maps to a known incident ID
|
|
* - Every ticket_id in a quest maps to an existing ticket
|
|
* - Every linked_quest in a ticket maps to an existing quest
|
|
* - Branch priorities within a quest are unique (no ties)
|
|
* - Every follow_up_incident maps to an existing incident file
|
|
* - Every series_id has at least 2 members
|
|
* - clue_fingerprint evidence uses valid rule types
|
|
* - file_absent and file_owner_is_not are accepted rule types (OI-010 resolved)
|
|
* - Package-manager-specific paths/commands match the authored VM distro
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CONFIG
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CONTENT_ROOT = path.resolve(__dirname, "../../content");
|
|
const QUESTS_DIR = path.join(CONTENT_ROOT, "quests");
|
|
const TICKETS_DIR = path.join(CONTENT_ROOT, "tickets");
|
|
const INCIDENTS_DIR= path.join(CONTENT_ROOT, "incidents");
|
|
const DIALOGUE_DIR = path.join(CONTENT_ROOT, "dialogue");
|
|
const FLAGS_FILE = path.join(CONTENT_ROOT, "world_flags", "world_flags.json");
|
|
const VM_PROFILES_DIR = path.join(CONTENT_ROOT, "vm_profiles");
|
|
const PRESSURE_PROFILES_DIR = path.join(CONTENT_ROOT, "pressure_profiles");
|
|
|
|
const VALID_RULE_TYPES = new Set([
|
|
// Standard validation rule types (used in objectives and solution branches)
|
|
"file_exists", "file_absent", "file_contains", "file_mode", "file_owner",
|
|
"file_owner_is_not", "directory_exists", "service_state", "service_enabled",
|
|
"process_running", "process_user", "port_listening", "package_installed",
|
|
"mount_present", "disk_usage_below", "disk_usage_above", "command_assert",
|
|
"log_contains", "and", "or", "not",
|
|
// Advisory clue_fingerprint-only types (descriptive evidence, not used in branch validation)
|
|
// These document what evidence EXISTS in the VM baseline — not evaluated at runtime.
|
|
"service_state_is", "service_enabled_is", "file_size_above", "file_size_below",
|
|
]);
|
|
|
|
const VALID_NARRATIVE_PHASES = new Set([
|
|
"normal_work", "unease", "suspicion", "investigation", "conflict", "resolution"
|
|
]);
|
|
|
|
const args = process.argv.slice(2);
|
|
const verbose = args.includes("--verbose");
|
|
const questsOnly = args.includes("--quests-only");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LOAD HELPERS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let errors = 0;
|
|
let warnings = 0;
|
|
|
|
function err(msg) {
|
|
console.error(` ❌ ERROR: ${msg}`);
|
|
errors++;
|
|
}
|
|
|
|
function warn(msg) {
|
|
console.warn(` ⚠ WARN: ${msg}`);
|
|
warnings++;
|
|
}
|
|
|
|
function ok(msg) {
|
|
if (verbose) console.log(` ✓ ${msg}`);
|
|
}
|
|
|
|
function loadJson(filePath) {
|
|
try {
|
|
const text = fs.readFileSync(filePath, "utf8");
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
err(`JSON parse error in ${path.relative(CONTENT_ROOT, filePath)}: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadDir(dirPath, idField = "id") {
|
|
const result = {};
|
|
if (!fs.existsSync(dirPath)) {
|
|
warn(`Directory not found: ${dirPath}`);
|
|
return result;
|
|
}
|
|
for (const fname of fs.readdirSync(dirPath)) {
|
|
if (!fname.endsWith(".json") || fname.startsWith(".")) continue;
|
|
// Skip split artifacts. Pending splits still deserve a warning; completed
|
|
// split archives and backups are intentional and should stay quiet.
|
|
if (fname.includes("SPLIT_PENDING")) {
|
|
warn(`Skipping non-standard file: ${fname}`);
|
|
continue;
|
|
}
|
|
if (fname.includes("SPLIT_DONE") || fname.includes(".bak")) {
|
|
continue;
|
|
}
|
|
const data = loadJson(path.join(dirPath, fname));
|
|
if (!data) continue;
|
|
if (Array.isArray(data)) {
|
|
warn(`${fname} is an array — expected single object with '${idField}' field. Split into individual files.`);
|
|
continue;
|
|
}
|
|
const id = data[idField];
|
|
if (!id) {
|
|
err(`Missing '${idField}' field in ${fname}`);
|
|
continue;
|
|
}
|
|
if (result[id]) {
|
|
err(`Duplicate ID '${id}' in ${fname} (already seen)`);
|
|
}
|
|
result[id] = { data, fname };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RULE TYPE VALIDATOR (recursive)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function validateRuleTypes(rule, context) {
|
|
if (!rule || typeof rule !== "object") return;
|
|
const type = rule.type;
|
|
if (!type) {
|
|
err(`${context}: rule missing 'type' field`);
|
|
return;
|
|
}
|
|
if (!VALID_RULE_TYPES.has(type)) {
|
|
err(`${context}: unknown rule type '${type}'`);
|
|
return;
|
|
}
|
|
// Recurse into composite rules
|
|
if (type === "and" || type === "or") {
|
|
for (const sub of (rule.rules || [])) {
|
|
validateRuleTypes(sub, context);
|
|
}
|
|
}
|
|
if (type === "not") {
|
|
validateRuleTypes(rule.rule, context);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// COLLECT ALL FLAG REFERENCES IN A RULE (recursive)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function collectFlagRefs(obj, refs = new Set()) {
|
|
if (!obj || typeof obj !== "object") return refs;
|
|
if (Array.isArray(obj)) {
|
|
for (const item of obj) collectFlagRefs(item, refs);
|
|
return refs;
|
|
}
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (k === "world_flags" && Array.isArray(v)) {
|
|
for (const f of v) refs.add(f);
|
|
} else if (k === "trigger" && typeof v === "string" && v.startsWith("world_flag:")) {
|
|
refs.add(v.slice("world_flag:".length));
|
|
} else if (typeof v === "object") {
|
|
collectFlagRefs(v, refs);
|
|
}
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function distroForVm(vmId, vmProfiles) {
|
|
return vmProfiles[vmId]?.data?.distro || "";
|
|
}
|
|
|
|
function validateVmSpecificSemantics(rule, context, vmProfiles) {
|
|
if (!rule || typeof rule !== "object") return;
|
|
if (Array.isArray(rule)) {
|
|
for (const item of rule) validateVmSpecificSemantics(item, context, vmProfiles);
|
|
return;
|
|
}
|
|
|
|
const vmId = typeof rule.vm === "string" ? rule.vm : "";
|
|
const distro = distroForVm(vmId, vmProfiles);
|
|
const pathValue = typeof rule.path === "string" ? rule.path : "";
|
|
const commandValue = typeof rule.command === "string" ? rule.command : "";
|
|
|
|
if (vmId && distro) {
|
|
const pathCtx = `${context} (${vmId}/${distro})`;
|
|
if (pathValue === "/etc/pacman.conf" && distro !== "arch") {
|
|
err(`${pathCtx}: /etc/pacman.conf is Arch-specific but VM distro is '${distro}'`);
|
|
}
|
|
if (pathValue.startsWith("/etc/apt/") && distro === "arch") {
|
|
err(`${pathCtx}: /etc/apt/* is Debian/Ubuntu-specific but VM distro is 'arch'`);
|
|
}
|
|
if (commandValue.includes("pacman") && distro !== "arch") {
|
|
err(`${pathCtx}: command references pacman but VM distro is '${distro}'`);
|
|
}
|
|
if ((commandValue.includes("apt ") || commandValue.includes("apt-get") || commandValue.includes("dpkg ")) && distro === "arch") {
|
|
err(`${pathCtx}: command references apt/dpkg but VM distro is 'arch'`);
|
|
}
|
|
}
|
|
|
|
for (const value of Object.values(rule)) {
|
|
if (value && typeof value === "object") {
|
|
validateVmSpecificSemantics(value, context, vmProfiles);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MAIN VALIDATION
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("═══════════════════════════════════════");
|
|
console.log(" Sysadmin Chronicles Content Validator");
|
|
console.log("═══════════════════════════════════════\n");
|
|
|
|
// Load all content
|
|
console.log("Loading content...");
|
|
const quests = loadDir(QUESTS_DIR);
|
|
const tickets = loadDir(TICKETS_DIR);
|
|
const incidents = loadDir(INCIDENTS_DIR);
|
|
const dialogue = loadDir(DIALOGUE_DIR);
|
|
const vmProfiles= loadDir(VM_PROFILES_DIR);
|
|
const pressureProfiles = loadDir(PRESSURE_PROFILES_DIR);
|
|
const flagsRaw = loadJson(FLAGS_FILE);
|
|
// flags field may be an Array (list of {id, ...}) or a Dict (id -> {...})
|
|
// Normalize to Dict keyed by id for validation lookups.
|
|
let flagRegistry = {};
|
|
if (flagsRaw) {
|
|
const raw = flagsRaw.flags || flagsRaw;
|
|
if (Array.isArray(raw)) {
|
|
for (const f of raw) { if (f.id) flagRegistry[f.id] = f; }
|
|
} else if (typeof raw === "object") {
|
|
flagRegistry = raw;
|
|
}
|
|
}
|
|
|
|
console.log(` Quests: ${Object.keys(quests).length}, Tickets: ${Object.keys(tickets).length}, Incidents: ${Object.keys(incidents).length}, Dialogue: ${Object.keys(dialogue).length}, Pressure Profiles: ${Object.keys(pressureProfiles).length}\n`);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// QUEST VALIDATION
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("Validating quests...");
|
|
for (const [qid, { data: quest, fname }] of Object.entries(quests)) {
|
|
const ctx = `Quest ${qid} (${fname})`;
|
|
|
|
// required_vms
|
|
for (const vmId of (quest.required_vms || [])) {
|
|
if (!vmProfiles[vmId]) err(`${ctx}: required_vms references unknown VM profile '${vmId}'`);
|
|
}
|
|
|
|
// baseline_snapshot
|
|
if (!quest.baseline_snapshot || typeof quest.baseline_snapshot !== 'string' || !quest.baseline_snapshot.trim()) {
|
|
err(`${ctx}: missing or empty 'baseline_snapshot' field`);
|
|
}
|
|
|
|
// ticket_id
|
|
if (quest.ticket_id && !tickets[quest.ticket_id]) {
|
|
err(`${ctx}: ticket_id '${quest.ticket_id}' not found in tickets/`);
|
|
}
|
|
|
|
// pressure_profile
|
|
if (quest.pressure_profile && !pressureProfiles[quest.pressure_profile]) {
|
|
err(`${ctx}: pressure_profile '${quest.pressure_profile}' not found in pressure_profiles/`);
|
|
}
|
|
|
|
// blast_radius
|
|
for (const incId of (quest.blast_radius || [])) {
|
|
if (!incidents[incId]) warn(`${ctx}: blast_radius references '${incId}' which doesn't exist yet`);
|
|
}
|
|
|
|
// Branch priority uniqueness
|
|
const branches = quest.solution_branches || [];
|
|
const priorities = new Set();
|
|
for (const branch of branches) {
|
|
const p = branch.priority;
|
|
if (priorities.has(p)) {
|
|
err(`${ctx}: duplicate branch priority ${p} — each branch must have a unique priority`);
|
|
}
|
|
priorities.add(p);
|
|
|
|
// follow_up_incident
|
|
if (branch.follow_up_incident && !incidents[branch.follow_up_incident]) {
|
|
err(`${ctx}: branch '${branch.id}' follow_up_incident '${branch.follow_up_incident}' not found`);
|
|
}
|
|
|
|
// Validate branch validation rules
|
|
validateRuleTypes(branch.validation, `${ctx} branch '${branch.id}'`);
|
|
}
|
|
|
|
// clue_fingerprint evidence rule types
|
|
const evidence = (quest.clue_fingerprint || {}).evidence || [];
|
|
for (const ev of evidence) {
|
|
if (!VALID_RULE_TYPES.has(ev.type)) {
|
|
err(`${ctx}: clue_fingerprint evidence uses unknown rule type '${ev.type}'`);
|
|
}
|
|
validateVmSpecificSemantics(ev, `${ctx} clue_fingerprint`, vmProfiles);
|
|
}
|
|
|
|
// Objective rule types
|
|
for (const obj of (quest.objectives || [])) {
|
|
validateRuleTypes(obj.validation, `${ctx} objective '${obj.id}'`);
|
|
validateVmSpecificSemantics(obj.validation, `${ctx} objective '${obj.id}'`, vmProfiles);
|
|
}
|
|
|
|
for (const branch of branches) {
|
|
validateVmSpecificSemantics(branch.validation, `${ctx} branch '${branch.id}'`, vmProfiles);
|
|
}
|
|
|
|
// narrative_phase
|
|
if (!quest.narrative_phase) {
|
|
warn(`${ctx}: missing 'narrative_phase' field`);
|
|
} else if (!VALID_NARRATIVE_PHASES.has(quest.narrative_phase)) {
|
|
err(`${ctx}: unknown narrative_phase '${quest.narrative_phase}'`);
|
|
}
|
|
|
|
// behavior_impact
|
|
if (quest.behavior_impact !== undefined) {
|
|
for (const [branchKey, impact] of Object.entries(quest.behavior_impact)) {
|
|
for (const field of ['curiosity_delta', 'obedience_delta', 'risk_delta', 'suspicion_delta']) {
|
|
if (impact[field] !== undefined && typeof impact[field] !== 'number') {
|
|
err(`${ctx}: behavior_impact[${branchKey}].${field} must be a number`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// hidden_hook shape
|
|
if (quest.hidden_hook !== undefined && quest.hidden_hook !== null) {
|
|
if (typeof quest.hidden_hook.id !== 'string') {
|
|
err(`${ctx}: hidden_hook.id must be a string`);
|
|
}
|
|
}
|
|
|
|
// access_requirements
|
|
if (quest.access_requirements?.minimum_access) {
|
|
for (const [vmId] of Object.entries(quest.access_requirements.minimum_access)) {
|
|
if (!vmProfiles[vmId]) {
|
|
err(`${ctx}: access_requirements.minimum_access references unknown VM '${vmId}'`);
|
|
}
|
|
}
|
|
}
|
|
|
|
ok(`${ctx}: OK`);
|
|
}
|
|
|
|
if (!questsOnly) {
|
|
// ---------------------------------------------------------------------------
|
|
// TICKET VALIDATION
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\nValidating tickets...");
|
|
for (const [tid, { data: ticket, fname }] of Object.entries(tickets)) {
|
|
const ctx = `Ticket ${tid} (${fname})`;
|
|
|
|
if (ticket.linked_quest && !quests[ticket.linked_quest]) {
|
|
warn(`${ctx}: linked_quest '${ticket.linked_quest}' not found`);
|
|
}
|
|
|
|
if (ticket.initial_priority === undefined) {
|
|
err(`${ctx}: missing 'initial_priority'`);
|
|
}
|
|
if (ticket.current_priority === undefined) {
|
|
err(`${ctx}: missing 'current_priority'`);
|
|
}
|
|
if (ticket.initial_priority !== ticket.current_priority) {
|
|
warn(`${ctx}: initial_priority != current_priority at authoring time`);
|
|
}
|
|
ok(`${ctx}: OK`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INCIDENT VALIDATION
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\nValidating incidents...");
|
|
for (const [iid, { data: incident, fname }] of Object.entries(incidents)) {
|
|
const ctx = `Incident ${iid} (${fname})`;
|
|
|
|
if (!Array.isArray(incident.blast_radius_quests)) {
|
|
err(`${ctx}: missing 'blast_radius_quests' (must be present, can be [])`);
|
|
}
|
|
if (!Array.isArray(incident.blast_radius_incidents)) {
|
|
err(`${ctx}: missing 'blast_radius_incidents' (must be present, can be [])`);
|
|
}
|
|
if (incident.resolution_requirements?.validation) {
|
|
validateRuleTypes(incident.resolution_requirements.validation, `${ctx} resolution_requirements`);
|
|
validateVmSpecificSemantics(incident.resolution_requirements.validation, `${ctx} resolution_requirements`, vmProfiles);
|
|
}
|
|
ok(`${ctx}: OK`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DIALOGUE VALIDATION
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\nValidating dialogue...");
|
|
const seriesMembers = {};
|
|
for (const [did, { data: dlg, fname }] of Object.entries(dialogue)) {
|
|
const ctx = `Dialogue ${did} (${fname})`;
|
|
|
|
if (dlg.series_id) {
|
|
if (dlg.series_position === undefined) {
|
|
err(`${ctx}: has series_id '${dlg.series_id}' but missing series_position`);
|
|
}
|
|
if (!seriesMembers[dlg.series_id]) seriesMembers[dlg.series_id] = [];
|
|
seriesMembers[dlg.series_id].push(did);
|
|
}
|
|
|
|
// world_flag trigger
|
|
if (dlg.trigger && dlg.trigger.startsWith("world_flag:")) {
|
|
const flagId = dlg.trigger.slice("world_flag:".length);
|
|
if (!flagRegistry[flagId]) {
|
|
err(`${ctx}: trigger flag '${flagId}' not in world_flags registry`);
|
|
}
|
|
}
|
|
ok(`${ctx}: OK`);
|
|
}
|
|
|
|
// series membership count check
|
|
for (const [sid, members] of Object.entries(seriesMembers)) {
|
|
if (members.length < 2) {
|
|
warn(`Series '${sid}' has only 1 member (${members[0]}) — needs 2+ for series tracking`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WORLD FLAG CROSS-REFERENCE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\nValidating world flag references...");
|
|
const allContent = [
|
|
...Object.values(quests).map(v => v.data),
|
|
...Object.values(tickets).map(v => v.data),
|
|
...Object.values(incidents).map(v => v.data),
|
|
...Object.values(dialogue).map(v => v.data),
|
|
];
|
|
|
|
const referencedFlags = new Set();
|
|
for (const item of allContent) {
|
|
collectFlagRefs(item, referencedFlags);
|
|
}
|
|
|
|
for (const flagId of referencedFlags) {
|
|
if (!flagRegistry[flagId]) {
|
|
err(`World flag '${flagId}' is referenced in content but not declared in world_flags.json`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SUMMARY
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n═══════════════════════════════════════");
|
|
if (errors === 0 && warnings === 0) {
|
|
console.log(" ✅ All content valid. Zero errors, zero warnings.");
|
|
} else {
|
|
if (errors > 0) console.log(` ❌ ${errors} error(s) found.`);
|
|
if (warnings > 0) console.log(` ⚠ ${warnings} warning(s) found.`);
|
|
}
|
|
console.log("═══════════════════════════════════════\n");
|
|
process.exit(errors > 0 ? 1 : 0);
|