bigtech.sucks: living database + first assessment

The database is the source of truth; the site is a view over it.

- packages/core/db/schema.sql: vendors, products, categories, the 8-dimension
  instrument, assessments, scores, evidence, findings, off-ramp, plus the
  derived v_product_score view the game reads.
- packages/core/db/build.mjs: rebuilds bigtech.db, absorbs the legacy 43-product
  roster as unassessed, applies seeds, then verifies the instrument's own rules
  (weights sum to 100, totals recompute, grades match bands, every score has a
  rationale, no tier-4 evidence on a scored dimension, reproducible-build caps
  actually enforced).
- packages/core/db/seed/0001-signal.sql: Signal Desktop 8.36.0-alpha.1 on linux,
  75.9/100 grade B, weakest dimension local_data_exposure. Evidence points at
  the re-runnable audit artifacts in the Servetus vault.
- packages/core/src/data/generated/catalog.{ts,json}: generated export.

Verified: 44 products, 1 assessed, all checks pass.
This commit is contained in:
CLU 2026-10-29 13:16:29 -05:00
commit de53ac41a5
47 changed files with 8553 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
node_modules/
dist/
packages/server/data/
*.log
.DS_Store
.env
# the database is built, not committed. schema + seed are the source.
packages/core/db/bigtech.db
packages/core/db/bigtech.db-wal
packages/core/db/bigtech.db-shm

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# bigtech.sucks — Draw the Line
V0.1 scaffold per master spec. A retro frontier game that scopes Big Tech exposure and sequences the off-ramp.
## Structure
```
packages/
core/ Render-agnostic game logic, seed data, scoring
web/ React + Vite SPA (V1 funnel)
server/ Minimal waitlist API (email + channel only)
```
## Quick start
```bash
npm install
npm run build -w @bigtech-sucks/core
npm run dev # web on :5173
npm run dev:server # waitlist API on :3001
```
## V1 flow
Welcome → Meet Red → Draw the Line → Overworld → Showdown → Homestead → Reveal (Binary Ranch) → Waitlist
## Governance
- Creative assets are **candidates** — director approval required before ship
- Company name revealed **only** at homestead/reveal phase
- §13 open decisions are **not** silently resolved (see `packages/core/src/data/config.ts`)
- Client-side sovereignty report; server stores waitlist fields only
## Roadmap
- **V1.5:** Terminal/SSH render (Go + Charm)
- **V2:** Concierge migration, journey chronicle, physical fulfillment (stubs in `v2-stubs.ts`)

2344
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "bigtech-sucks",
"version": "0.1.0",
"private": true,
"description": "Draw the Line — a planning site disguised as a retro frontier game",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "npm run dev -w @bigtech-sucks/web",
"dev:server": "npm run dev -w @bigtech-sucks/server",
"build": "npm run build -w @bigtech-sucks/core && npm run build -w @bigtech-sucks/web",
"build:server": "npm run build -w @bigtech-sucks/server",
"preview": "npm run preview -w @bigtech-sucks/web",
"test": "npm run test -w @bigtech-sucks/core",
"db:build": "node packages/core/db/build.mjs",
"db:verify": "node packages/core/db/build.mjs verify"
},
"license": "UNLICENSED"
}

320
packages/core/db/build.mjs Normal file
View file

@ -0,0 +1,320 @@
#!/usr/bin/env node
/**
* bigtech.sucks — database build
*
* The database is the source of truth. This script:
* 1. rebuilds bigtech.db from schema + seed
* 2. absorbs the legacy hand-authored outlaw roster as UNASSESSED products
* 3. applies every db/seed/*.sql in name order
* 4. verifies the result against the instrument's own rules
* 5. exports a generated module the website imports
*
* Run: npm run db:build
* Verify only: npm run db:verify
*/
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const CORE = join(HERE, '..');
const DB_PATH = join(HERE, 'bigtech.db');
const SEED_DIR = join(HERE, 'seed');
const GEN_DIR = join(CORE, 'src', 'data', 'generated');
const GRADES = (n) => (n >= 90 ? 'A' : n >= 75 ? 'B' : n >= 60 ? 'C' : n >= 40 ? 'D' : 'F');
// ---------------------------------------------------------------- vendor map
// Outlaw id -> vendor. Everything here is checkable public record.
const VENDORS = {
google: ['Google LLC', 'private', 'US', 'advertising'],
microsoft: ['Microsoft Corporation', 'public', 'US', 'mixed'],
apple: ['Apple Inc.', 'public', 'US', 'hardware'],
meta: ['Meta Platforms', 'public', 'US', 'advertising'],
amazon: ['Amazon.com, Inc.', 'public', 'US', 'mixed'],
salesforce: ['Salesforce, Inc.', 'public', 'US', 'subscription'],
atlassian: ['Atlassian Corporation', 'public', 'AU', 'subscription'],
zoom: ['Zoom Communications', 'public', 'US', 'subscription'],
discord: ['Discord Inc.', 'private', 'US', 'subscription'],
otter: ['Otter.ai', 'private', 'US', 'subscription'],
notion: ['Notion Labs', 'private', 'US', 'subscription'],
evernote: ['Evernote (Bending Spoons)', 'private', 'IT', 'subscription'],
obsidian: ['Dynalist Inc. (Obsidian)', 'private', 'CA', 'license'],
openai: ['OpenAI', 'private', 'US', 'mixed'],
lastpass: ['LastPass (GoTo)', 'private', 'US', 'subscription'],
'1password': ['AgileBits Inc. (1Password)', 'private', 'CA', 'subscription'],
proton: ['Proton AG', 'private', 'CH', 'subscription'],
dropbox: ['Dropbox, Inc.', 'public', 'US', 'subscription'],
'x-corp': ['X Corp.', 'private', 'US', 'advertising'],
intuit: ['Intuit Inc.', 'public', 'US', 'subscription'],
plaid: ['Plaid Inc.', 'private', 'US', 'mixed'],
asana: ['Asana, Inc.', 'public', 'US', 'subscription'],
'23andme': ['23andMe Holding Co.', 'public', 'US', 'mixed'],
vercel: ['Vercel Inc.', 'private', 'US', 'subscription'],
'nord-security': ['Nord Security', 'private', 'PA', 'subscription'],
netflix: ['Netflix, Inc.', 'public', 'US', 'subscription'],
'nextcloud-gmbh': ['Nextcloud GmbH', 'private', 'DE', 'subscription'],
'signal-foundation': ['Signal Technology Foundation', 'nonprofit', 'US', 'none'],
};
const OUTLAW_VENDOR = {
gmail: 'google', outlook: 'microsoft', protonmail: 'proton',
google_drive: 'google', dropbox: 'dropbox', onedrive: 'microsoft',
google_workspace: 'google', microsoft_365: 'microsoft',
slack: 'salesforce', discord: 'discord', zoom: 'zoom', otter: 'otter',
teams: 'microsoft', notion: 'notion', evernote: 'evernote',
obsidian_sync: 'obsidian', whisper_api: 'openai', lastpass: 'lastpass',
'1password': '1password', google_calendar: 'google',
google_photos: 'google', icloud_photos: 'apple', chrome: 'google',
chatgpt: 'openai', gemini: 'google', twitter: 'x-corp',
facebook: 'meta', mint: 'intuit', plaid: 'plaid',
salesforce: 'salesforce', asana: 'asana', trello: 'atlassian',
'23andme': '23andme', fitbit: 'google', ring: 'amazon', nest: 'google',
github: 'microsoft', vercel: 'vercel', nordvpn: 'nord-security',
netflix: 'netflix', windows: 'microsoft', android: 'google',
nextcloud: 'nextcloud-gmbh',
};
const ID_OVERRIDES = { '1password': 'onepassword', '23andme': 'twentythreeandme' };
const slug = (s) => ID_OVERRIDES[s] ?? s.replace(/[^a-z0-9]+/gi, '-').toLowerCase();
// ---------------------------------------------------------------- build
async function build() {
if (existsSync(DB_PATH)) unlinkSync(DB_PATH);
const db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA foreign_keys = ON');
db.exec(readFileSync(join(HERE, 'schema.sql'), 'utf8'));
// vendors
const insVendor = db.prepare(`INSERT OR REPLACE INTO vendor
(id, name, legal_entity, entity_type, jurisdiction, business_model) VALUES (?,?,?,?,?,?)`);
for (const [id, [name, type, juris, model]] of Object.entries(VENDORS)) {
insVendor.run(id, name.replace(/\s*\(.*\)$/, ''), name, type, juris, model);
}
// categories and legacy roster, imported from the built core package so the
// database is guaranteed to match what the site shipped with.
const { categories } = await import(join(CORE, 'dist', 'data', 'categories.js'));
const { outlaws } = await import(join(CORE, 'dist', 'data', 'outlaws.js'));
const insCat = db.prepare(`INSERT OR REPLACE INTO category (id,label,sensitivity,nextcloud) VALUES (?,?,?,?)`);
for (const c of categories) insCat.run(c.id, c.label, c.sensitivity, c.nc);
const insProd = db.prepare(`INSERT OR IGNORE INTO product
(id,vendor_id,name,category_id,platform,sovereign,boss,art_slot,monthly_cost,notes)
VALUES (?,?,?,?,?,?,?,?,?,?)`);
let migrated = 0;
for (const o of outlaws) {
const vendorId = OUTLAW_VENDOR[o.id];
if (!vendorId) { console.warn(` ! no vendor mapped for outlaw '${o.id}'`); continue; }
insProd.run(slug(o.id), vendorId, o.name, o.category, 'web', o.sovereign ? 1 : 0,
o.boss ? 1 : 0, o.faceArtSlot, o.monthlyCost ?? null,
`Legacy editorial placeholder. Not yet assessed. Original policy=${o.policy} infra=${o.infra}.`);
migrated++;
}
// seeds
const seeds = readdirSync(SEED_DIR).filter((f) => f.endsWith('.sql')).sort();
for (const f of seeds) db.exec(readFileSync(join(SEED_DIR, f), 'utf8'));
db.close();
return { migrated, seeds };
}
// ---------------------------------------------------------------- verify
function verify() {
const db = new DatabaseSync(DB_PATH, { readOnly: true });
const fails = [];
const ok = (cond, msg) => { if (!cond) fails.push(msg); };
const dims = db.prepare('SELECT id, weight FROM dimension').all();
const weightSum = dims.reduce((a, d) => a + d.weight, 0);
ok(weightSum === 100, `dimension weights sum to ${weightSum}, expected 100`);
const assessments = db.prepare('SELECT * FROM assessment').all();
ok(assessments.length > 0, 'no assessments present');
for (const a of assessments) {
const scores = db.prepare('SELECT s.*, d.weight FROM score s JOIN dimension d ON d.id = s.dimension_id WHERE s.assessment_id = ?').all(a.id);
ok(scores.length === dims.length, `${a.id}: ${scores.length} scores, expected ${dims.length}`);
const recomputed = scores.reduce((acc, s) => acc + s.value * s.weight, 0) / 10;
ok(Math.abs(recomputed - a.weighted_total) < 0.05,
`${a.id}: stored total ${a.weighted_total} but recomputed ${recomputed.toFixed(2)}`);
ok(GRADES(a.weighted_total) === a.grade,
`${a.id}: grade ${a.grade} does not match band for ${a.weighted_total}`);
for (const s of scores) ok(!!s.rationale && s.rationale.length > 20,
`${a.id}/${s.dimension_id}: score has no written rationale`);
const t12 = db.prepare('SELECT COUNT(*) AS n FROM evidence WHERE assessment_id = ? AND tier <= 2').get(a.id).n;
ok(t12 >= 3, `${a.id}: only ${t12} tier-1/2 evidence rows, need 3`);
const t4 = db.prepare('SELECT COUNT(*) AS n FROM evidence WHERE assessment_id = ? AND tier = 4 AND dimension_id IS NOT NULL').get(a.id).n;
ok(t4 === 0, `${a.id}: ${t4} tier-4 claims attached to a scored dimension`);
const lowest = scores.reduce((m, s) => (s.value < m.value ? s : m), scores[0]);
ok(lowest.dimension_id === a.weakest_dimension,
`${a.id}: weakest_dimension is ${a.weakest_dimension} but lowest score is ${lowest.dimension_id}`);
}
// The instrument's caps must be real, not documentation.
for (const a of assessments) {
const v = db.prepare("SELECT value FROM score WHERE assessment_id = ? AND dimension_id = 'verifiability'").get(a.id)?.value;
if (a.reproducible_build === 0) ok(v <= 3, `${a.id}: no reproducible build but verifiability ${v} > 3`);
if (a.reproducible_build === 1) ok(v <= 6, `${a.id}: experimental reproducible build but verifiability ${v} > 6`);
}
const scores = db.prepare('SELECT * FROM v_product_score').all();
for (const s of scores) {
if (s.weighted_total === null) continue;
ok(s.policy >= 0 && s.policy <= 100, `${s.product}: policy ${s.policy} out of range`);
ok(s.infra >= 0 && s.infra <= 100, `${s.product}: infra ${s.infra} out of range`);
}
const cov = db.prepare('SELECT * FROM v_coverage').all();
const assessed = cov.filter((c) => c.status !== 'unassessed').length;
const total = cov.length;
db.close();
return { fails, scores, cov, assessed, total };
}
// ---------------------------------------------------------------- export
function exportSite() {
const db = new DatabaseSync(DB_PATH, { readOnly: true });
mkdirSync(GEN_DIR, { recursive: true });
const products = db.prepare(`
SELECT p.id, p.name, p.platform, p.category_id, p.vendor_id, p.sovereign, p.boss, p.art_slot,
p.monthly_cost, v.name AS vendor, v.entity_type, v.jurisdiction, v.business_model,
s.weighted_total, s.grade, s.policy, s.infra, s.weakest_dimension, s.headline, s.assessed_on
FROM product p
JOIN vendor v ON v.id = p.vendor_id
LEFT JOIN v_product_score s ON s.product_id = p.id
ORDER BY p.category_id, p.name`).all();
const assessments = db.prepare(`
SELECT a.id, a.product_id, a.version, a.source_commit, a.assessed_on, a.method_version,
a.reproducible_build, a.weighted_total, a.grade, a.weakest_dimension, a.headline, a.not_tested
FROM assessment a ORDER BY a.assessed_on DESC`).all();
const scores = db.prepare(`
SELECT s.assessment_id, s.dimension_id, s.value, s.rationale, d.label, d.weight
FROM score s JOIN dimension d ON d.id = s.dimension_id
ORDER BY d.weight DESC`).all();
const evidence = db.prepare(`
SELECT id, assessment_id, dimension_id, claim, tier, kind, artifact, sha256, observed_at
FROM evidence ORDER BY assessment_id, tier, id`).all();
const findings = db.prepare(`
SELECT id, assessment_id, severity, title, detail, status, cve
FROM finding ORDER BY assessment_id,
CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2
WHEN 'low' THEN 3 ELSE 4 END`).all();
const dimensions = db.prepare('SELECT * FROM dimension ORDER BY weight DESC').all();
db.close();
const payload = {
generated_at: new Date().toISOString(),
method_version: 'btr-0.1',
dimensions, products, assessments, scores, evidence, findings,
};
writeFileSync(join(GEN_DIR, 'catalog.json'), JSON.stringify(payload, null, 2));
const ts = `// GENERATED FILE - do not edit by hand.
// Source of truth: packages/core/db/bigtech.db
// Rebuild: npm run db:build
export interface CatalogProduct {
id: string; name: string; platform: string; category_id: string; vendor_id: string;
vendor: string; entity_type: string; jurisdiction: string; business_model: string;
sovereign: number; boss: number; art_slot: string | null; monthly_cost: number | null;
weighted_total: number | null; grade: string | null; policy: number | null;
infra: number | null; weakest_dimension: string | null; headline: string | null;
assessed_on: string | null;
}
export interface CatalogAssessment {
id: string; product_id: string; version: string; source_commit: string | null;
assessed_on: string; method_version: string; reproducible_build: number;
weighted_total: number; grade: string; weakest_dimension: string; headline: string;
not_tested: string | null;
}
export interface CatalogScore {
assessment_id: string; dimension_id: string; value: number;
rationale: string; label: string; weight: number;
}
export interface CatalogEvidence {
id: number; assessment_id: string; dimension_id: string | null; claim: string;
tier: number; kind: string | null; artifact: string | null;
sha256: string | null; observed_at: string | null;
}
export interface CatalogFinding {
id: number; assessment_id: string; severity: string; title: string;
detail: string | null; status: string; cve: string | null;
}
export interface CatalogDimension {
id: string; label: string; weight: number; anchor_10: string; anchor_0: string;
}
export const catalog = ${JSON.stringify(payload, null, 2)} as {
generated_at: string;
method_version: string;
dimensions: CatalogDimension[];
products: CatalogProduct[];
assessments: CatalogAssessment[];
scores: CatalogScore[];
evidence: CatalogEvidence[];
findings: CatalogFinding[];
};
/** Products with a real assessment. Everything else is a placeholder. */
export const assessed = catalog.products.filter((p) => p.weighted_total !== null);
/** The full roster the game renders, measured where we have measured it. */
export const products = catalog.products;
export function productById(id: string): CatalogProduct | undefined {
return catalog.products.find((p) => p.id === id);
}
`;
writeFileSync(join(GEN_DIR, 'catalog.ts'), ts);
return payload;
}
// ---------------------------------------------------------------- run
const mode = process.argv[2] ?? 'all';
if (mode === 'verify') {
const r = verify();
report(r);
} else {
const { migrated, seeds } = await build();
console.log(`built ${DB_PATH}`);
console.log(` legacy roster absorbed: ${migrated} products (unassessed)`);
console.log(` seeds applied: ${seeds.join(', ')}`);
const r = verify();
const payload = exportSite();
console.log(` exported: src/data/generated/catalog.{ts,json} (${payload.products.length} products)`);
report(r);
}
function report({ fails, cov, assessed, total }) {
console.log('');
console.log('VERIFY');
if (fails.length === 0) console.log(' all instrument checks passed');
else for (const f of fails) console.log(` FAIL ${f}`);
console.log(` coverage: ${assessed} assessed / ${total} products (${((assessed / total) * 100).toFixed(1)}%)`);
console.log('');
console.log('SCORED');
for (const c of cov.filter((x) => x.status !== 'unassessed')) {
console.log(` ${c.name} [${c.platform}] ${c.weighted_total}/100 grade ${c.status}`);
}
if (fails.length) process.exitCode = 1;
}

163
packages/core/db/schema.sql Normal file
View file

@ -0,0 +1,163 @@
-- bigtech.sucks — living database schema
-- The website is a view over this. Nothing in the site may hardcode a number
-- that lives here.
--
-- Design rule: an assessment is an ARTIFACT with a date, a version, a method and
-- evidence. A score without an assessment is a placeholder and is stored as NULL,
-- never as a guess.
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
-- ---------------------------------------------------------------- who owns it
CREATE TABLE IF NOT EXISTS vendor (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
legal_entity TEXT,
entity_type TEXT CHECK (entity_type IN ('nonprofit','private','public','state','unknown')),
jurisdiction TEXT, -- ISO 3166-1 alpha-2
business_model TEXT CHECK (business_model IN ('advertising','subscription','hardware','license','mixed','none','unknown')),
parent_id TEXT REFERENCES vendor(id),
founded TEXT,
notes TEXT
);
-- ---------------------------------------------------------------- what it is
CREATE TABLE IF NOT EXISTS category (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
sensitivity REAL NOT NULL CHECK (sensitivity BETWEEN 0 AND 1),
nextcloud TEXT
);
CREATE TABLE IF NOT EXISTS product (
id TEXT PRIMARY KEY,
vendor_id TEXT NOT NULL REFERENCES vendor(id),
name TEXT NOT NULL,
category_id TEXT REFERENCES category(id),
-- platform is part of the identity: Signal iOS != Signal Desktop
platform TEXT NOT NULL,
source_url TEXT,
license TEXT,
sovereign INTEGER NOT NULL DEFAULT 0 CHECK (sovereign IN (0,1)),
boss INTEGER NOT NULL DEFAULT 0 CHECK (boss IN (0,1)),
art_slot TEXT,
monthly_cost REAL,
notes TEXT,
UNIQUE(id, platform)
);
-- ---------------------------------------------------------------- the instrument
CREATE TABLE IF NOT EXISTS dimension (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
weight REAL NOT NULL CHECK (weight > 0),
anchor_10 TEXT NOT NULL,
anchor_0 TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assessment (
id TEXT PRIMARY KEY,
product_id TEXT NOT NULL REFERENCES product(id),
version TEXT NOT NULL,
source_commit TEXT,
assessed_on TEXT NOT NULL,
method_version TEXT NOT NULL,
-- 0 = none, 1 = available but experimental/unverified, 2 = verified
reproducible_build INTEGER NOT NULL DEFAULT 0 CHECK (reproducible_build IN (0,1,2)),
weighted_total REAL NOT NULL CHECK (weighted_total BETWEEN 0 AND 100),
grade TEXT NOT NULL,
weakest_dimension TEXT NOT NULL REFERENCES dimension(id),
headline TEXT NOT NULL, -- the caveat, in one sentence
not_tested TEXT,
UNIQUE(product_id, version, assessed_on)
);
CREATE TABLE IF NOT EXISTS score (
assessment_id TEXT NOT NULL REFERENCES assessment(id) ON DELETE CASCADE,
dimension_id TEXT NOT NULL REFERENCES dimension(id),
value REAL NOT NULL CHECK (value BETWEEN 0 AND 10),
rationale TEXT,
PRIMARY KEY (assessment_id, dimension_id)
);
-- evidence is the only thing that makes a score legitimate
CREATE TABLE IF NOT EXISTS evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assessment_id TEXT NOT NULL REFERENCES assessment(id) ON DELETE CASCADE,
dimension_id TEXT REFERENCES dimension(id),
claim TEXT NOT NULL,
tier INTEGER NOT NULL CHECK (tier IN (1,2,3,4)),
kind TEXT CHECK (kind IN ('source','capture','record','filing','advisory','paper','claim')),
artifact TEXT, -- path inside the vault, or URL
sha256 TEXT,
observed_at TEXT
);
CREATE TABLE IF NOT EXISTS finding (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assessment_id TEXT NOT NULL REFERENCES assessment(id) ON DELETE CASCADE,
severity TEXT NOT NULL CHECK (severity IN ('critical','high','medium','low','info')),
title TEXT NOT NULL,
detail TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','fixed','by_design','disputed','accepted')),
disclosed_on TEXT,
cve TEXT
);
CREATE TABLE IF NOT EXISTS offramp (
product_id TEXT NOT NULL REFERENCES product(id),
replacement TEXT NOT NULL,
nextcloud TEXT,
notes TEXT,
PRIMARY KEY (product_id, replacement)
);
-- ---------------------------------------------------------------- derived views
-- The single number the game and the report both use.
CREATE VIEW IF NOT EXISTS v_product_score AS
SELECT
p.id AS product_id,
p.name AS product,
p.platform,
p.category_id,
v.id AS vendor_id,
v.name AS vendor,
v.business_model,
v.jurisdiction,
a.id AS assessment_id,
a.assessed_on,
a.version,
a.weighted_total,
a.grade,
a.weakest_dimension,
a.headline,
-- policy axis: who holds your data, and what can they be made to hand over
CAST(ROUND(10 * (
0.40 * (SELECT value FROM score WHERE assessment_id = a.id AND dimension_id = 'server_retention')
+ 0.30 * (SELECT value FROM score WHERE assessment_id = a.id AND dimension_id = 'ownership_jurisdiction')
+ 0.30 * (SELECT value FROM score WHERE assessment_id = a.id AND dimension_id = 'user_control_exit')
)) AS INTEGER) AS policy,
-- infra axis: can you verify it, and what does it leave on your disk
CAST(ROUND(10 * (
0.50 * (SELECT value FROM score WHERE assessment_id = a.id AND dimension_id = 'verifiability')
+ 0.30 * (SELECT value FROM score WHERE assessment_id = a.id AND dimension_id = 'dependency_surface')
+ 0.20 * (SELECT value FROM score WHERE assessment_id = a.id AND dimension_id = 'local_data_exposure')
)) AS INTEGER) AS infra
FROM product p
JOIN vendor v ON v.id = p.vendor_id
LEFT JOIN assessment a
ON a.product_id = p.id
AND a.assessed_on = (SELECT MAX(assessed_on) FROM assessment WHERE product_id = p.id);
-- A product with no assessment must surface as unassessed, not as a number.
CREATE VIEW IF NOT EXISTS v_coverage AS
SELECT
p.id AS product_id,
p.name,
p.platform,
CASE WHEN a.id IS NULL THEN 'unassessed' ELSE a.grade END AS status,
a.weighted_total
FROM product p
LEFT JOIN assessment a ON a.product_id = p.id
AND a.assessed_on = (SELECT MAX(assessed_on) FROM assessment WHERE product_id = p.id);

View file

@ -0,0 +1,135 @@
-- =====================================================================
-- Assessment 0001 — Signal Desktop, Linux x86_64
-- Assessed 2026-10-29 by CLU. Source commit 5dad34a (v8.36.0-alpha.1).
-- Evidence artifacts: Servetus vault
-- 00-Artifacts/2026/10-Oct/29/clu-big-tech-report/
-- Narrative report: 04-Projects/big-tech-report/2026-10-29-signal-desktop-assessment.md
-- =====================================================================
INSERT OR REPLACE INTO vendor
(id, name, legal_entity, entity_type, jurisdiction, business_model, founded, notes)
VALUES
('signal-foundation', 'Signal', 'Signal Technology Foundation / Signal Messenger LLC',
'nonprofit', 'US', 'none', '2018',
'US 501(c)(3), EIN 82-4506840. Operated by Signal Messenger LLC. No ad model, no data sale. US jurisdiction means US legal process reaches it, but there is almost nothing to reach.');
-- ------------------------------------------------------------------ dimension set v0.1
INSERT OR REPLACE INTO dimension (id, label, weight, anchor_10, anchor_0) VALUES
('telemetry_egress', 'Telemetry and egress', 25,
'Published destination allowlist; cold idle produces zero unlisted packets, independently confirmed.',
'Covert collection, or egress that cannot be enumerated.'),
('verifiability', 'Verifiability', 20,
'Reproducible build verified on the scored platform.',
'Closed source, or no way to prove the binary matches the source.'),
('server_retention', 'Server-side retention', 15,
'Subpoena answer is two timestamps or an empty set.',
'Content readable by the vendor.'),
('ownership_jurisdiction', 'Ownership, jurisdiction, business model', 12,
'Nonprofit or cooperative, no ad model, strong process protections.',
'State-controlled.'),
('local_data_exposure', 'Local data exposure', 10,
'All artifacts encrypted at rest under a user-presence key.',
'Everything plaintext on disk.'),
('dependency_surface', 'Dependency surface', 8,
'Small audited dependency tree, signed and pinned update channel.',
'Huge unaudited tree, unsigned updates.'),
('user_control_exit', 'User control and exit', 5,
'Usable export, real deletion, no identity hostage-taking.',
'No export, deletion that does not delete.'),
('incident_record', 'Incident record', 5,
'Flaws found, disclosed honestly, fixed fast.',
'Unblemished only because nobody is allowed to test it.');
-- ------------------------------------------------------------------ the product
INSERT OR REPLACE INTO product
(id, vendor_id, name, category_id, platform, source_url, license, sovereign, boss, art_slot, monthly_cost, notes)
VALUES
('signal-desktop', 'signal-foundation', 'Signal Desktop', 'messaging', 'linux-x86_64',
'https://github.com/signalapp/Signal-Desktop', 'AGPL-3.0', 0, 0, 'outlaw_signal', 0,
'Linked-device client. Requires a phone to pair. Electron 45.0.0.');
-- ------------------------------------------------------------------ the assessment
INSERT OR REPLACE INTO assessment
(id, product_id, version, source_commit, assessed_on, method_version,
reproducible_build, weighted_total, grade, weakest_dimension, headline, not_tested)
VALUES
('A-2026-10-29-SIGNAL-DESKTOP', 'signal-desktop', '8.36.0-alpha.1',
'5dad34ad5ea1a5b6229412e5c8ac0a696ff7532d', '2026-10-29', 'btr-0.1',
1, 75.9, 'B', 'local_data_exposure',
'The strongest transport on the internet, sitting behind the weakest desktop client we have scored.',
'No live account was used. No packet capture was run (D1 to D4 not executed, this is a source audit only). Android and iOS clients not assessed. No attempt to verify the shipped .deb against a local rebuild.');
INSERT OR REPLACE INTO score (assessment_id, dimension_id, value, rationale) VALUES
('A-2026-10-29-SIGNAL-DESKTOP', 'telemetry_egress', 9,
'Zero analytics packages across 224 dependencies. Crash reporter started with uploadToServer:false. Only signal.org, support.signal.org, updates*.signal.org and sfu.voip.signal.org appear in source. Docked one point for update checks on launch and the megaphone channel, neither of which is telemetry but both of which are vendor-initiated contact.'),
('A-2026-10-29-SIGNAL-DESKTOP', 'verifiability', 6,
'Fully open source. Reproducible builds exist for Linux only, via Docker, and Signal labels them experimental and possibly non-functioning on public releases. macOS and Windows have none. Capped at 6 by the instrument rule.'),
('A-2026-10-29-SIGNAL-DESKTOP', 'server_retention', 10,
'The published answer to a 2021 federal grand jury subpoena was registration date and last connection date. No messages, contacts, groups, profile or avatar exist server-side to hand over.'),
('A-2026-10-29-SIGNAL-DESKTOP', 'ownership_jurisdiction', 8,
'501(c)(3) nonprofit, EIN 82-4506840, no advertising and no data sale, so the incentive to surveil is absent rather than merely restrained. Docked for US jurisdiction and for early Open Technology Fund grant funding.'),
('A-2026-10-29-SIGNAL-DESKTOP', 'local_data_exposure', 5,
'Received attachments are stored unencrypted at ~/.config/Signal/attachments.noindex (CVE-2023-24069). The SQLCipher key lives in gnome-keyring, so anything running as the user can read db.sqlite. On this operator machine that means every agent process.'),
('A-2026-10-29-SIGNAL-DESKTOP', 'dependency_surface', 6,
'224 dependencies, Electron 45.0.0 pinned. Electron has produced CVEs enabling persistent backdoors in exactly this class of app. Update channel is a signed apt repo, which is the good part.'),
('A-2026-10-29-SIGNAL-DESKTOP', 'user_control_exit', 7,
'Registration still requires a phone number, though usernames and phone-number privacy now reduce the exposure. No practical encrypted export of message history in a portable format.'),
('A-2026-10-29-SIGNAL-DESKTOP', 'incident_record', 7,
'Roughly 15 CVEs under the signal vendor entry. The 2023 local-storage flaws were acknowledged and fixed in 6.2.0. Signal is under continuous scrutiny, which is a better position than an untested clean record.');
-- ------------------------------------------------------------------ evidence (T1/T2 only)
INSERT INTO evidence (assessment_id, dimension_id, claim, tier, kind, artifact, sha256, observed_at) VALUES
('A-2026-10-29-SIGNAL-DESKTOP', 'local_data_exposure',
'Attachments are stored unencrypted on the filesystem; the SQLCipher key is held by the OS keyring.',
1, 'source', '00-Artifacts/2026/10-Oct/29/clu-big-tech-report/01-capture-sites.txt',
NULL, '2026-10-29T13:13:56-05:00'),
('A-2026-10-29-SIGNAL-DESKTOP', 'telemetry_egress',
'Electron media permission defaults to false and audio is denied unless the user has enabled mediaPermissions.',
1, 'source', '00-Artifacts/2026/10-Oct/29/clu-big-tech-report/02-permissions.txt',
'0a64f213f3c241c1211dec56c0018d9220651c2550a7cf44609d586e74369cf2', '2026-10-29T13:13:56-05:00'),
('A-2026-10-29-SIGNAL-DESKTOP', 'telemetry_egress',
'Zero analytics, tracker or crash-upload dependencies across 224 packages.',
1, 'source', '00-Artifacts/2026/10-Oct/29/clu-big-tech-report/05-dependencies.txt',
NULL, '2026-10-29T13:13:56-05:00'),
('A-2026-10-29-SIGNAL-DESKTOP', 'telemetry_egress',
'Only two audio capture call sites exist and both are reachable only from an explicit UI action.',
1, 'source', '00-Artifacts/2026/10-Oct/29/clu-big-tech-report/03-call-path.txt',
NULL, '2026-10-29T13:13:56-05:00'),
('A-2026-10-29-SIGNAL-DESKTOP', 'verifiability',
'Signal states reproducible builds for macOS and Windows are not available, and the Linux build is experimental.',
2, 'record', 'https://github.com/signalapp/Signal-Desktop/blob/main/reproducible-builds/README.md',
NULL, '2026-10-29T13:13:56-05:00'),
('A-2026-10-29-SIGNAL-DESKTOP', 'server_retention',
'Grand jury subpoena response returned two timestamps and nothing else.',
2, 'filing', 'https://signal.org/bigbrother/cd-california-grand-jury/',
NULL, '2026-10-29T13:13:56-05:00'),
('A-2026-10-29-SIGNAL-DESKTOP', 'ownership_jurisdiction',
'Signal Technology Foundation is a 501(c)(3) with EIN 82-4506840.',
2, 'record', 'https://projects.propublica.org/nonprofits/organizations/824506840',
NULL, '2026-10-29T13:13:56-05:00');
-- ------------------------------------------------------------------ findings
INSERT INTO finding (assessment_id, severity, title, detail, status, cve) VALUES
('A-2026-10-29-SIGNAL-DESKTOP', 'high',
'Received attachments are stored unencrypted on disk',
'Every image and file received is readable by any process running as the user. On a machine running agent software under the same account, this is the most likely real-world exposure.',
'by_design', 'CVE-2023-24069'),
('A-2026-10-29-SIGNAL-DESKTOP', 'medium',
'No verified reproducible build for the Linux package',
'The source is auditable; the shipped binary is not yet provably the same artifact. Until this closes, a source audit is evidence about the repository, not about the running process.',
'open', NULL),
('A-2026-10-29-SIGNAL-DESKTOP', 'medium',
'Local database key is held in the OS keyring',
'Any process running in the unlocked session can decrypt db.sqlite. There is no user-presence gate.',
'accepted', NULL),
('A-2026-10-29-SIGNAL-DESKTOP', 'low',
'Registration requires a phone number',
'Mitigated by usernames and phone-number privacy, but the account root remains a phone number.',
'accepted', NULL);
-- ------------------------------------------------------------------ off-ramp
INSERT OR REPLACE INTO offramp (product_id, replacement, nextcloud, notes) VALUES
('signal-desktop', 'Signal (keep it)', NULL,
'Signal is not a thing to migrate off. It is the recommended landing place for SMS and WhatsApp, with the local-attachment caveat stated plainly.'),
('signal-desktop', 'Nextcloud Talk', 'Talk',
'For household and business groups where the admin wants the contact graph to stay on ranch hardware.');

View file

@ -0,0 +1,23 @@
{
"name": "@bigtech-sucks/core",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}

View file

@ -0,0 +1,113 @@
import { outlaws } from './data/outlaws.js';
import { trophies } from './data/trophies.js';
import type { CaptureStatus, Outlaw, PlayerJourneyState } from './types.js';
export function getCaptureStatus(
state: PlayerJourneyState,
outlawId: string,
): CaptureStatus {
return state.captured[outlawId] ?? 'at_large';
}
export function isBossOutlaw(outlaw: Outlaw): boolean {
return !!outlaw.boss;
}
export function nonBossCaptured(state: PlayerJourneyState): boolean {
const selected = state.selectedOutlaws
.map((id) => outlaws.find((o) => o.id === id))
.filter((o): o is Outlaw => !!o && !o.sovereign && !o.boss);
return selected.length > 0 && selected.every((o) => getCaptureStatus(state, o.id) === 'captured');
}
export function canHuntOutlaw(state: PlayerJourneyState, outlawId: string): boolean {
const outlaw = outlaws.find((o) => o.id === outlawId);
if (!outlaw || outlaw.sovereign) return false;
if (!state.selectedOutlaws.includes(outlawId)) return false;
if (isBossOutlaw(outlaw)) {
return nonBossCaptured(state);
}
return getCaptureStatus(state, outlawId) !== 'captured';
}
export function canAdvanceShowdown(
state: PlayerJourneyState,
outlawId: string,
step: 'round_up' | 'validate' | 'cut',
): boolean {
const status = getCaptureStatus(state, outlawId);
switch (step) {
case 'round_up':
return status === 'at_large';
case 'validate':
return status === 'in_custody';
case 'cut':
return status === 'in_custody';
default:
return false;
}
}
export function advanceCapture(
state: PlayerJourneyState,
outlawId: string,
step: 'round_up' | 'validate' | 'cut',
): PlayerJourneyState {
const status = getCaptureStatus(state, outlawId);
const next = { ...state, captured: { ...state.captured } };
if (step === 'round_up' && status === 'at_large') {
next.captured[outlawId] = 'in_custody';
} else if (step === 'cut' && status === 'in_custody') {
next.captured[outlawId] = 'captured';
next.bountyReclaimed = computeBountyReclaimed(next);
next.trophies = computeEarnedTrophies(next);
}
return next;
}
export function computeBountyReclaimed(state: PlayerJourneyState): number {
return state.selectedOutlaws.reduce((sum, id) => {
if (getCaptureStatus(state, id) !== 'captured') return sum;
const outlaw = outlaws.find((o) => o.id === id);
return sum + (outlaw?.bounty ?? 0);
}, 0);
}
export function computeEarnedTrophies(state: PlayerJourneyState): string[] {
const capturedCount = state.selectedOutlaws.filter(
(id) => getCaptureStatus(state, id) === 'captured',
).length;
return trophies
.filter((t) => capturedCount >= t.threshold)
.map((t) => t.id);
}
export function allSelectedCaptured(state: PlayerJourneyState): boolean {
return state.selectedOutlaws
.filter((id) => {
const o = outlaws.find((x) => x.id === id);
return o && !o.sovereign;
})
.every((id) => getCaptureStatus(state, id) === 'captured');
}
export function getHuntableOutlaws(state: PlayerJourneyState): Outlaw[] {
return state.selectedOutlaws
.map((id) => outlaws.find((o) => o.id === id))
.filter((o): o is Outlaw => !!o && !o.sovereign && canHuntOutlaw(state, o.id));
}
export function getCapturedOutlaws(state: PlayerJourneyState): Outlaw[] {
return state.selectedOutlaws
.map((id) => outlaws.find((o) => o.id === id))
.filter(
(o): o is Outlaw => !!o && !o.sovereign && getCaptureStatus(state, o.id) === 'captured',
);
}

View file

@ -0,0 +1,63 @@
import type { ArtSlot } from '../types.js';
/** §8.5 — swappable art slots; placeholders until director approves final assets */
export const artSlots: ArtSlot[] = [
{
id: 'title_illustration',
label: 'Title screen',
placeholder: `
╔══════════════════════════════════════╗
║ ★ DRAW THE LINE ★ ║
║ ╭───╮ ║
║ │RED│ wagon boss awaits... ║
║ ╰───╯ ║
╚══════════════════════════════════════╝`,
approved: false,
},
{
id: 'homestead',
label: 'Homestead vista',
placeholder: `
☀
~~~|~~~ Mail River
════════════════
/\\ /\\
/ \\ / \\ YOUR LAND
▓▓▓▓▓▓▓▓▓▓▓▓▓`,
approved: false,
},
{
id: 'overworld_backdrop',
label: 'Territory map',
placeholder: `
WEST ←────────────────────────→ EAST
[HOME] Dusty Gulch Mail River Data Flats`,
approved: false,
},
{
id: 'red_color',
label: 'Red (full color)',
placeholder: `
╭─────────╮
│ ◕ ◕ │ RED
│ ∪ │ wagon boss
╰─────────╯`,
approved: false,
},
{
id: 'red_crt',
label: 'Red (CRT terminal)',
placeholder: `
┌─────────┐
│ ▓▓ ▓▓ │ RED
│ ▓▓▓▓ │
└─────────┘`,
approved: false,
},
{
id: 'captured_stamp',
label: 'CAPTURED stamp',
placeholder: '╔══ CAPTURED ══╗',
approved: false,
},
];

View file

@ -0,0 +1,27 @@
import type { Category } from '../types.js';
/** §5.1 trust-vector taxonomy — editorial placeholders, director to audit */
export const categories: Category[] = [
{ id: 'productivity', label: 'Productivity suites', sensitivity: 0.9, nc: 'Nextcloud Office' },
{ id: 'files', label: 'File storage & sync', sensitivity: 1.0, nc: 'Nextcloud Files' },
{ id: 'email', label: 'Email', sensitivity: 1.0, nc: 'Nextcloud Mail' },
{ id: 'messaging', label: 'Messaging', sensitivity: 0.85, nc: 'Nextcloud Talk' },
{ id: 'video', label: 'Video meetings & AI notetakers', sensitivity: 0.8, nc: 'Nextcloud Talk' },
{ id: 'notes', label: 'Notes & second brain', sensitivity: 0.95, nc: 'Nextcloud Notes / Collectives' },
{ id: 'voice', label: 'Voice & transcription', sensitivity: 0.75, nc: 'Nextcloud + local tools' },
{ id: 'passwords', label: 'Password managers', sensitivity: 1.0, nc: 'Nextcloud Passwords' },
{ id: 'calendar', label: 'Calendar & contacts', sensitivity: 0.9, nc: 'Nextcloud Calendar & Contacts' },
{ id: 'photos', label: 'Photos', sensitivity: 0.85, nc: 'Nextcloud Photos' },
{ id: 'browser', label: 'Browsers & search', sensitivity: 0.7, nc: 'Self-hosted / privacy browsers' },
{ id: 'ai', label: 'AI assistants', sensitivity: 0.8, nc: 'Local / self-hosted AI' },
{ id: 'social', label: 'Social & publishing', sensitivity: 0.6, nc: 'Nextcloud + federated tools' },
{ id: 'project', label: 'Project & task management', sensitivity: 0.75, nc: 'Nextcloud Deck' },
{ id: 'crm', label: 'CRM & business', sensitivity: 0.85, nc: 'Nextcloud + CRM apps' },
{ id: 'money', label: 'Money & finance', sensitivity: 1.0, nc: 'Self-hosted / local ledger' },
{ id: 'health', label: 'Health, fitness & genetic', sensitivity: 1.0, nc: 'Local-first health vault' },
{ id: 'smarthome', label: 'Smart home & IoT', sensitivity: 0.8, nc: 'Local Home Assistant' },
{ id: 'dev', label: 'Dev, code & cloud infra', sensitivity: 0.9, nc: 'Self-hosted Git / CI' },
{ id: 'vpn', label: 'VPN & security', sensitivity: 0.95, nc: 'Self-hosted VPN' },
{ id: 'streaming', label: 'Streaming & commerce', sensitivity: 0.5, nc: 'Owned media library' },
{ id: 'os', label: 'OS & device substrate', sensitivity: 0.85, nc: 'Linux / open firmware' },
];

View file

@ -0,0 +1,35 @@
import type { ScoringWeights, ScoreModel } from '../types.js';
export const SOVEREIGNTY_LINE = 70;
export const defaultWeights: ScoringWeights = {
wPolicy: 0.6,
wInfra: 0.4,
};
/** §13 open decision — default weighted_average; director may switch to weakest_link */
export const defaultScoreModel: ScoreModel = 'weighted_average';
export const consolidatedMonthlyCost = 12;
export const loginExposureScores: Record<string, number> = {
passkey: 85,
email: 75,
proton: 80,
github: 70,
apple: 55,
google: 35,
facebook: 25,
anonymous: 50,
};
export const loginChannelMap: Record<string, string> = {
passkey: 'direct',
email: 'email',
proton: 'proton',
google: 'google',
facebook: 'facebook',
apple: 'apple',
github: 'github',
anonymous: 'direct',
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,689 @@
import type { Outlaw } from '../types.js';
/**
* Seed outlaw roster — §7
* Numeric scores are editorial placeholders (director to audit before launch).
* Crimes: documented fact or satirical commentary; faces are original art slots.
*/
export const outlaws: Outlaw[] = [
// Mail River (email boss territory)
{
id: 'gmail',
name: 'Gmail',
category: 'email',
region: 'Mail River',
crime: 'reads your mail for coin',
bounty: 6,
policy: 30,
infra: 45,
cloud: 'Google',
ncReplacement: 'Nextcloud Mail',
sovereign: false,
boss: true,
faceArtSlot: 'outlaw_gmail',
monthlyCost: 0,
},
{
id: 'outlook',
name: 'Outlook',
category: 'email',
region: 'Mail River',
crime: 'scans attachments for training data',
bounty: 5,
policy: 35,
infra: 40,
cloud: 'Microsoft',
ncReplacement: 'Nextcloud Mail',
sovereign: false,
faceArtSlot: 'outlaw_outlook',
monthlyCost: 0,
},
{
id: 'protonmail',
name: 'Proton Mail',
category: 'email',
region: 'Mail River',
crime: 'rents Swiss ground you do not own',
bounty: 4,
policy: 75,
infra: 55,
cloud: 'Proton AG',
ncReplacement: 'Nextcloud Mail',
sovereign: false,
faceArtSlot: 'outlaw_protonmail',
monthlyCost: 5,
},
// Data Flats (files & productivity)
{
id: 'google_drive',
name: 'Google Drive',
category: 'files',
region: 'Data Flats',
crime: 'indexes every file you upload',
bounty: 3,
policy: 28,
infra: 45,
cloud: 'Google',
ncReplacement: 'Nextcloud Files',
sovereign: false,
faceArtSlot: 'outlaw_google_drive',
monthlyCost: 3,
},
{
id: 'dropbox',
name: 'Dropbox',
category: 'files',
region: 'Data Flats',
crime: 'trained AI on customer files without consent',
bounty: 12,
policy: 32,
infra: 38,
cloud: 'Dropbox / AWS',
ncReplacement: 'Nextcloud Files',
sovereign: false,
faceArtSlot: 'outlaw_dropbox',
monthlyCost: 12,
},
{
id: 'onedrive',
name: 'OneDrive',
category: 'files',
region: 'Data Flats',
crime: 'syncs your desktop to their vault',
bounty: 7,
policy: 33,
infra: 40,
cloud: 'Microsoft',
ncReplacement: 'Nextcloud Files',
sovereign: false,
faceArtSlot: 'outlaw_onedrive',
monthlyCost: 7,
},
{
id: 'google_workspace',
name: 'Google Workspace',
category: 'productivity',
region: 'Data Flats',
crime: 'one login, total visibility',
bounty: 14,
policy: 25,
infra: 45,
cloud: 'Google',
ncReplacement: 'Nextcloud Office',
sovereign: false,
faceArtSlot: 'outlaw_google_workspace',
monthlyCost: 14,
},
{
id: 'microsoft_365',
name: 'Microsoft 365',
category: 'productivity',
region: 'Data Flats',
crime: 'productivity suite with a surveillance rider',
bounty: 13,
policy: 30,
infra: 40,
cloud: 'Microsoft',
ncReplacement: 'Nextcloud Office',
sovereign: false,
faceArtSlot: 'outlaw_microsoft_365',
monthlyCost: 13,
},
// Telegraph Canyon (messaging & video)
{
id: 'slack',
name: 'Slack',
category: 'messaging',
region: 'Telegraph Canyon',
crime: 'sells workspace analytics to your boss',
bounty: 8,
policy: 35,
infra: 42,
cloud: 'Salesforce / AWS',
ncReplacement: 'Nextcloud Talk',
sovereign: false,
faceArtSlot: 'outlaw_slack',
monthlyCost: 8,
},
{
id: 'discord',
name: 'Discord',
category: 'messaging',
region: 'Telegraph Canyon',
crime: 'every message passes through their servers',
bounty: 0,
policy: 40,
infra: 35,
cloud: 'Discord / GCP',
ncReplacement: 'Nextcloud Talk',
sovereign: false,
faceArtSlot: 'outlaw_discord',
monthlyCost: 0,
},
{
id: 'zoom',
name: 'Zoom',
category: 'video',
region: 'Telegraph Canyon',
crime: 'routed calls through China-adjacent infra',
bounty: 16,
policy: 38,
infra: 40,
cloud: 'Zoom / AWS',
ncReplacement: 'Nextcloud Talk',
sovereign: false,
faceArtSlot: 'outlaw_zoom',
monthlyCost: 16,
},
{
id: 'otter',
name: 'Otter.ai',
category: 'video',
region: 'Telegraph Canyon',
crime: 'records every word of your meetings',
bounty: 17,
policy: 30,
infra: 35,
cloud: 'Otter / AWS',
ncReplacement: 'Local transcription',
sovereign: false,
faceArtSlot: 'outlaw_otter',
monthlyCost: 17,
},
{
id: 'teams',
name: 'Microsoft Teams',
category: 'video',
region: 'Telegraph Canyon',
crime: 'meeting data feeds the mothership',
bounty: 0,
policy: 32,
infra: 40,
cloud: 'Microsoft',
ncReplacement: 'Nextcloud Talk',
sovereign: false,
faceArtSlot: 'outlaw_teams',
monthlyCost: 0,
},
// Inkwell Ridge (notes & voice)
{
id: 'notion',
name: 'Notion',
category: 'notes',
region: 'Inkwell Ridge',
crime: 'your second brain lives on their servers',
bounty: 10,
policy: 35,
infra: 38,
cloud: 'Notion / AWS',
ncReplacement: 'Nextcloud Notes',
sovereign: false,
faceArtSlot: 'outlaw_notion',
monthlyCost: 10,
},
{
id: 'evernote',
name: 'Evernote',
category: 'notes',
region: 'Inkwell Ridge',
crime: 'decade of notes held for ransom',
bounty: 8,
policy: 30,
infra: 35,
cloud: 'Evernote / GCP',
ncReplacement: 'Nextcloud Notes',
sovereign: false,
faceArtSlot: 'outlaw_evernote',
monthlyCost: 8,
},
{
id: 'obsidian_sync',
name: 'Obsidian Sync',
category: 'notes',
region: 'Inkwell Ridge',
crime: 'encrypts but still rents the road',
bounty: 5,
policy: 65,
infra: 50,
cloud: 'Obsidian / AWS',
ncReplacement: 'Nextcloud + local vault',
sovereign: false,
faceArtSlot: 'outlaw_obsidian_sync',
monthlyCost: 5,
},
{
id: 'whisper_api',
name: 'OpenAI Whisper API',
category: 'voice',
region: 'Inkwell Ridge',
crime: 'your voice shipped to a black box',
bounty: 0,
policy: 25,
infra: 30,
cloud: 'OpenAI / Azure',
ncReplacement: 'Local Whisper',
sovereign: false,
faceArtSlot: 'outlaw_whisper_api',
monthlyCost: 0,
},
// Strongbox Gulch (passwords & calendar)
{
id: 'lastpass',
name: 'LastPass',
category: 'passwords',
region: 'Strongbox Gulch',
crime: 'breached vaults, slow to confess',
bounty: 3,
policy: 20,
infra: 35,
cloud: 'LastPass / GCP',
ncReplacement: 'Nextcloud Passwords',
sovereign: false,
faceArtSlot: 'outlaw_lastpass',
monthlyCost: 3,
},
{
id: '1password',
name: '1Password',
category: 'passwords',
region: 'Strongbox Gulch',
crime: 'keys to the kingdom on rented ground',
bounty: 3,
policy: 55,
infra: 45,
cloud: '1Password / AWS',
ncReplacement: 'Nextcloud Passwords',
sovereign: false,
faceArtSlot: 'outlaw_1password',
monthlyCost: 3,
},
{
id: 'google_calendar',
name: 'Google Calendar',
category: 'calendar',
region: 'Strongbox Gulch',
crime: 'maps your life hour by hour',
bounty: 0,
policy: 28,
infra: 45,
cloud: 'Google',
ncReplacement: 'Nextcloud Calendar',
sovereign: false,
faceArtSlot: 'outlaw_google_calendar',
monthlyCost: 0,
},
// Shutter Hills (photos)
{
id: 'google_photos',
name: 'Google Photos',
category: 'photos',
region: 'Shutter Hills',
crime: 'face-scanned your family album',
bounty: 3,
policy: 25,
infra: 45,
cloud: 'Google',
ncReplacement: 'Nextcloud Photos',
sovereign: false,
faceArtSlot: 'outlaw_google_photos',
monthlyCost: 3,
},
{
id: 'icloud_photos',
name: 'iCloud Photos',
category: 'photos',
region: 'Shutter Hills',
crime: 'golden cage for your memories',
bounty: 3,
policy: 45,
infra: 42,
cloud: 'Apple',
ncReplacement: 'Nextcloud Photos',
sovereign: false,
faceArtSlot: 'outlaw_icloud_photos',
monthlyCost: 3,
},
// Crossroads (browser, AI, social)
{
id: 'chrome',
name: 'Google Chrome',
category: 'browser',
region: 'Crossroads',
crime: 'the browser is the tracking device',
bounty: 0,
policy: 22,
infra: 45,
cloud: 'Google',
ncReplacement: 'Privacy browser + self-hosted',
sovereign: false,
faceArtSlot: 'outlaw_chrome',
monthlyCost: 0,
},
{
id: 'chatgpt',
name: 'ChatGPT',
category: 'ai',
region: 'Crossroads',
crime: 'every prompt logged for training',
bounty: 20,
policy: 28,
infra: 32,
cloud: 'OpenAI / Azure',
ncReplacement: 'Local / self-hosted AI',
sovereign: false,
faceArtSlot: 'outlaw_chatgpt',
monthlyCost: 20,
},
{
id: 'gemini',
name: 'Google Gemini',
category: 'ai',
region: 'Crossroads',
crime: 'reads your workspace to answer questions',
bounty: 0,
policy: 25,
infra: 45,
cloud: 'Google',
ncReplacement: 'Local / self-hosted AI',
sovereign: false,
faceArtSlot: 'outlaw_gemini',
monthlyCost: 0,
},
{
id: 'twitter',
name: 'X (Twitter)',
category: 'social',
region: 'Crossroads',
crime: 'your posts train their ad engine',
bounty: 0,
policy: 30,
infra: 38,
cloud: 'X Corp / GCP',
ncReplacement: 'Federated / owned blog',
sovereign: false,
faceArtSlot: 'outlaw_twitter',
monthlyCost: 0,
},
{
id: 'facebook',
name: 'Facebook',
category: 'social',
region: 'Crossroads',
crime: 'sold mood data to political operatives',
bounty: 0,
policy: 15,
infra: 35,
cloud: 'Meta',
ncReplacement: 'Federated / owned blog',
sovereign: false,
faceArtSlot: 'outlaw_facebook',
monthlyCost: 0,
},
// Ledger Town (money, CRM, project)
{
id: 'mint',
name: 'Mint (Intuit)',
category: 'money',
region: 'Ledger Town',
crime: 'sold your spending habits to lenders',
bounty: 0,
policy: 20,
infra: 38,
cloud: 'Intuit / AWS',
ncReplacement: 'Self-hosted finance',
sovereign: false,
faceArtSlot: 'outlaw_mint',
monthlyCost: 0,
},
{
id: 'plaid',
name: 'Plaid',
category: 'money',
region: 'Ledger Town',
crime: 'middleman with keys to every bank',
bounty: 0,
policy: 35,
infra: 40,
cloud: 'Plaid / GCP',
ncReplacement: 'Direct bank access',
sovereign: false,
faceArtSlot: 'outlaw_plaid',
monthlyCost: 0,
},
{
id: 'salesforce',
name: 'Salesforce',
category: 'crm',
region: 'Ledger Town',
crime: 'your customer list on their balance sheet',
bounty: 25,
policy: 32,
infra: 38,
cloud: 'Salesforce / AWS',
ncReplacement: 'Nextcloud CRM apps',
sovereign: false,
faceArtSlot: 'outlaw_salesforce',
monthlyCost: 25,
},
{
id: 'asana',
name: 'Asana',
category: 'project',
region: 'Ledger Town',
crime: 'task lists mined for workflow patterns',
bounty: 11,
policy: 38,
infra: 40,
cloud: 'Asana / AWS',
ncReplacement: 'Nextcloud Deck',
sovereign: false,
faceArtSlot: 'outlaw_asana',
monthlyCost: 11,
},
{
id: 'trello',
name: 'Trello',
category: 'project',
region: 'Ledger Town',
crime: 'boards on Atlassian ground',
bounty: 0,
policy: 40,
infra: 38,
cloud: 'Atlassian / AWS',
ncReplacement: 'Nextcloud Deck',
sovereign: false,
faceArtSlot: 'outlaw_trello',
monthlyCost: 0,
},
// Frontier Edge (health, IoT, dev, VPN, streaming, OS)
{
id: '23andme',
name: '23andMe',
category: 'health',
region: 'Frontier Edge',
crime: 'sold genetic data to pharma',
bounty: 0,
policy: 18,
infra: 35,
cloud: '23andMe / AWS',
ncReplacement: 'Local health vault',
sovereign: false,
faceArtSlot: 'outlaw_23andme',
monthlyCost: 0,
},
{
id: 'fitbit',
name: 'Fitbit',
category: 'health',
region: 'Frontier Edge',
crime: 'heartbeat data in Google hands',
bounty: 10,
policy: 30,
infra: 42,
cloud: 'Google',
ncReplacement: 'Local health vault',
sovereign: false,
faceArtSlot: 'outlaw_fitbit',
monthlyCost: 10,
},
{
id: 'ring',
name: 'Ring (Amazon)',
category: 'smarthome',
region: 'Frontier Edge',
crime: 'doorbell footage shared with police',
bounty: 0,
policy: 22,
infra: 38,
cloud: 'Amazon',
ncReplacement: 'Local Home Assistant',
sovereign: false,
faceArtSlot: 'outlaw_ring',
monthlyCost: 0,
},
{
id: 'nest',
name: 'Google Nest',
category: 'smarthome',
region: 'Frontier Edge',
crime: 'thermostat telemetry to the mothership',
bounty: 0,
policy: 25,
infra: 45,
cloud: 'Google',
ncReplacement: 'Local Home Assistant',
sovereign: false,
faceArtSlot: 'outlaw_nest',
monthlyCost: 0,
},
{
id: 'github',
name: 'GitHub',
category: 'dev',
region: 'Frontier Edge',
crime: 'your code on Microsoft land',
bounty: 0,
policy: 45,
infra: 42,
cloud: 'Microsoft',
ncReplacement: 'Self-hosted Git',
sovereign: false,
faceArtSlot: 'outlaw_github',
monthlyCost: 0,
},
{
id: 'vercel',
name: 'Vercel',
category: 'dev',
region: 'Frontier Edge',
crime: 'serverless on ground you cannot port',
bounty: 20,
policy: 40,
infra: 35,
cloud: 'Vercel / AWS',
ncReplacement: 'Self-hosted CI/CD',
sovereign: false,
faceArtSlot: 'outlaw_vercel',
monthlyCost: 20,
},
{
id: 'nordvpn',
name: 'NordVPN',
category: 'vpn',
region: 'Frontier Edge',
crime: 'promised privacy, rented servers',
bounty: 4,
policy: 50,
infra: 40,
cloud: 'Nord / third-party DCs',
ncReplacement: 'Self-hosted VPN',
sovereign: false,
faceArtSlot: 'outlaw_nordvpn',
monthlyCost: 4,
},
{
id: 'netflix',
name: 'Netflix',
category: 'streaming',
region: 'Frontier Edge',
crime: 'you rent access, own nothing',
bounty: 16,
policy: 45,
infra: 38,
cloud: 'Netflix / AWS',
ncReplacement: 'Owned media library',
sovereign: false,
faceArtSlot: 'outlaw_netflix',
monthlyCost: 16,
},
{
id: 'windows',
name: 'Windows 11',
category: 'os',
region: 'Frontier Edge',
crime: 'telemetry baked into the substrate',
bounty: 0,
policy: 28,
infra: 35,
cloud: 'Microsoft',
ncReplacement: 'Linux / open firmware',
sovereign: false,
faceArtSlot: 'outlaw_windows',
monthlyCost: 0,
},
{
id: 'android',
name: 'Google Android',
category: 'os',
region: 'Frontier Edge',
crime: 'mobile OS with a tracking core',
bounty: 0,
policy: 25,
infra: 42,
cloud: 'Google',
ncReplacement: 'GrapheneOS / Linux mobile',
sovereign: false,
faceArtSlot: 'outlaw_android',
monthlyCost: 0,
},
// Sovereign exemplars (high scores — not outlaws to hunt)
{
id: 'nextcloud',
name: 'Nextcloud (self-hosted)',
category: 'files',
region: 'Homestead',
crime: 'none — this is your land',
bounty: 0,
policy: 90,
infra: 95,
cloud: 'Yours',
ncReplacement: 'Nextcloud Files',
sovereign: true,
faceArtSlot: 'ally_nextcloud',
monthlyCost: 0,
},
];
export const regions = [
'Homestead',
'Data Flats',
'Telegraph Canyon',
'Inkwell Ridge',
'Strongbox Gulch',
'Shutter Hills',
'Crossroads',
'Ledger Town',
'Frontier Edge',
'Mail River',
] as const;
export type Region = (typeof regions)[number];

View file

@ -0,0 +1,8 @@
import type { Trophy } from '../types.js';
export const trophies: Trophy[] = [
{ id: 'patch', label: 'Trail Patch', threshold: 1, physical: false },
{ id: 'bandana', label: 'Outlaw Bandana', threshold: 3, physical: false },
{ id: 'hat', label: 'Wagon Boss Hat', threshold: 7, physical: true, gate: 'sustained_use_60d' },
{ id: 'buckle', label: 'Homestead Buckle', threshold: 12, physical: true, gate: 'sustained_use_60d' },
];

View file

@ -0,0 +1,60 @@
/** Red's dialogue — director owns final copy; these are staged candidates */
export const redDialogue = {
welcome: [
'You look road-weary, traveler.',
'Most folks riding through here do not even know how many hands are in their pockets.',
'I am Red. I have made this crossing before.',
'Before we talk solutions, let us talk exposure.',
],
drawTheLine: [
'Every service you use is someone living off your data.',
'Draw the line — put your captors on the wanted board.',
'This is not a shame list. It is a map.',
],
sharecropperEvocation: [
'You work the land every day.',
'You pay rent on a harvest you never keep.',
'A dozen hands in your pocket — not one acre to your name.',
'Sound familiar?',
],
overworld: [
'Pick your hunt. West is the homestead — always in sight.',
'No conveyor belt here. You choose the order.',
],
showdownRoundUp: [
'Round them up. We move your data to ground you own.',
'V1: this step is illustrative — the real migration comes with the outfit.',
],
showdownValidate: [
'Check your land. Every file, every thread — make sure it is all there.',
'We do not cut the cord until you trust what you see.',
],
showdownCut: [
'Data proven safe. Cut the cord.',
'That is a capture worth celebrating.',
],
mailRiverBoss: [
'Mail River is the hard crossing.',
'Email is the boss outlaw — highest stakes, longest shadow.',
'Take a breath. We ford together.',
],
homestead: [
'You made it west.',
'Look at what you reclaimed — in sovereignty and in dollars.',
'The journey ends here. That is intentional.',
],
reveal: [
'You have seen the exposure. You have seen the path off.',
'The outfit that runs this trail is called Binary Ranch.',
'We get the wagon to homestead intact.',
'Your data does not have a party. Neither do we.',
],
waitlist: [
'Claim your early-settler spot.',
'We store your email and your channel — nothing else.',
'The rest stays in your vault.',
],
} as const;
export type DialogueKey = keyof typeof redDialogue;

View file

@ -0,0 +1,66 @@
import { loginChannelMap } from './data/config.js';
import { allSelectedCaptured } from './capture.js';
import { computePersonalScore } from './scoring.js';
import type { GamePhase, LoginMethod, PlayerJourneyState } from './types.js';
export function createInitialState(loginMethod: LoginMethod = 'anonymous'): PlayerJourneyState {
const today = new Date().toISOString().slice(0, 10);
return {
settlerSince: today,
loginMethod,
selectedOutlaws: [],
captured: {},
bountyReclaimed: 0,
trophies: [],
sovereigntyScore: 0,
phase: 'welcome',
};
}
export function setPhase(state: PlayerJourneyState, phase: GamePhase): PlayerJourneyState {
return { ...state, phase };
}
export function selectOutlaws(state: PlayerJourneyState, ids: string[]): PlayerJourneyState {
const next = {
...state,
selectedOutlaws: ids,
sovereigntyScore: computePersonalScore(ids),
};
return setPhase(next, 'overworld');
}
export function transitionAfterShowdown(state: PlayerJourneyState): PlayerJourneyState {
const next = { ...state, activeOutlawId: undefined };
if (allSelectedCaptured(next)) {
return setPhase(next, 'homestead');
}
return setPhase(next, 'overworld');
}
export function getDistributionChannel(state: PlayerJourneyState): string {
return loginChannelMap[state.loginMethod] ?? 'direct';
}
export function persistToVault(state: PlayerJourneyState): string {
/** Client-side vault export — §8.3 no honeypot */
return JSON.stringify(
{
version: '0.1',
exportedAt: new Date().toISOString(),
journey: {
settlerSince: state.settlerSince,
loginMethod: state.loginMethod,
selectedOutlaws: state.selectedOutlaws,
captured: state.captured,
bountyReclaimed: state.bountyReclaimed,
trophies: state.trophies,
sovereigntyScore: state.sovereigntyScore,
},
},
null,
2,
);
}

View file

@ -0,0 +1,12 @@
export * from './types.js';
export * from './data/categories.js';
export * from './data/outlaws.js';
export * from './data/trophies.js';
export * from './data/artSlots.js';
export * from './data/config.js';
export * from './scoring.js';
export * from './capture.js';
export * from './offRamp.js';
export * from './gameState.js';
export * from './dialogue.js';
export * from './v2-stubs.js';

View file

@ -0,0 +1,29 @@
import { consolidatedMonthlyCost } from './data/config.js';
import { outlaws } from './data/outlaws.js';
import { computePersonalScore, computeProjectedScore } from './scoring.js';
import type { OffRampPayload, ScoreModel } from './types.js';
export function computeOffRamp(
selectedIds: string[],
scoreModel: ScoreModel = 'weighted_average',
): OffRampPayload {
const replaceableTools = selectedIds
.map((id) => outlaws.find((o) => o.id === id))
.filter((o) => o && !o.sovereign) as typeof outlaws;
const currentScore = computePersonalScore(selectedIds, scoreModel);
const projectedScore = computeProjectedScore(selectedIds, scoreModel);
const monthlyFragmented = replaceableTools.reduce(
(sum, o) => sum + (o.monthlyCost ?? o.bounty),
0,
);
return {
replaceableTools,
projectedScore,
projectedMonthlyCost: consolidatedMonthlyCost,
monthlySavings: Math.max(0, monthlyFragmented - consolidatedMonthlyCost),
sovereigntyGain: projectedScore - currentScore,
};
}

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { computePersonalScore, computeToolSov, selfIndexScore } from './scoring.js';
describe('scoring', () => {
it('computes tool sovereignty from policy and infra weights', () => {
expect(computeToolSov({ policy: 30, infra: 45 })).toBe(36);
});
it('scores selected outlaws with weighted average', () => {
const score = computePersonalScore(['gmail', 'dropbox']);
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThan(70);
});
it('tops its own index', () => {
const self = selfIndexScore();
expect(self.toolSov).toBeGreaterThanOrEqual(90);
});
});

View file

@ -0,0 +1,152 @@
import { categories } from './data/categories.js';
import { outlaws } from './data/outlaws.js';
import {
SOVEREIGNTY_LINE,
defaultWeights,
loginExposureScores,
} from './data/config.js';
import type {
LoginMethod,
Outlaw,
ScoreModel,
ScoringWeights,
SovereigntyReport,
} from './types.js';
export function computeToolSov(
outlaw: Pick<Outlaw, 'policy' | 'infra'>,
weights: ScoringWeights = defaultWeights,
): number {
return Math.round(weights.wPolicy * outlaw.policy + weights.wInfra * outlaw.infra);
}
export function getOutlawById(id: string): Outlaw | undefined {
return outlaws.find((o) => o.id === id);
}
export function getCategoryById(id: string) {
return categories.find((c) => c.id === id);
}
function categoryScoreForSelection(
selectedIds: string[],
scoreModel: ScoreModel,
): Record<string, number> {
const scores: Record<string, number> = {};
for (const category of categories) {
const selectedInCategory = selectedIds
.map(getOutlawById)
.filter((o): o is Outlaw => !!o && o.category === category.id && !o.sovereign);
if (selectedInCategory.length === 0) continue;
const toolScores = selectedInCategory.map((o) => computeToolSov(o));
scores[category.id] =
scoreModel === 'weakest_link'
? Math.min(...toolScores)
: Math.round(toolScores.reduce((a, b) => a + b, 0) / toolScores.length);
}
return scores;
}
export function computePersonalScore(
selectedIds: string[],
scoreModel: ScoreModel = 'weighted_average',
): number {
const categoryScores = categoryScoreForSelection(selectedIds, scoreModel);
const entries = Object.entries(categoryScores);
if (entries.length === 0) return 0;
let weightedSum = 0;
let weightTotal = 0;
for (const [catId, score] of entries) {
const cat = getCategoryById(catId);
const sensitivity = cat?.sensitivity ?? 1;
weightedSum += sensitivity * score;
weightTotal += sensitivity;
}
return Math.round(weightedSum / weightTotal);
}
export function loginExposureNote(method: LoginMethod): string {
const score = loginExposureScores[method] ?? 50;
if (score >= SOVEREIGNTY_LINE) {
return 'You rode in on your own horse. That counts for something.';
}
if (method === 'google') {
return 'You logged in with Google — that is row zero of your exposure report.';
}
if (method === 'facebook') {
return 'Facebook SSO? Conviction lands before you tick a single box.';
}
return 'Your login choice is part of the picture. We surface it; we do not hide it.';
}
export function buildSovereigntyReport(
selectedIds: string[],
loginMethod: LoginMethod,
scoreModel: ScoreModel = 'weighted_average',
): SovereigntyReport {
const personalScore = computePersonalScore(selectedIds, scoreModel);
const categoryScores = categoryScoreForSelection(selectedIds, scoreModel);
const selectedOutlaws = selectedIds
.map(getOutlawById)
.filter((o): o is Outlaw => !!o && !o.sovereign);
const monthlyFragmentedCost = selectedOutlaws.reduce(
(sum, o) => sum + (o.monthlyCost ?? o.bounty),
0,
);
const replaceable = selectedOutlaws.filter((o) => !o.sovereign);
const projectedScore = computeProjectedScore(selectedIds, scoreModel);
return {
personalScore,
aboveTheLine: personalScore >= SOVEREIGNTY_LINE,
categoryScores,
monthlyFragmentedCost,
consolidatedMonthlyCost: 12,
projectedScore,
replaceableCount: replaceable.length,
loginExposureNote: loginExposureNote(loginMethod),
};
}
export function computeProjectedScore(
selectedIds: string[],
scoreModel: ScoreModel = 'weighted_average',
): number {
const categoryScores = categoryScoreForSelection(selectedIds, scoreModel);
const entries = Object.entries(categoryScores);
if (entries.length === 0) return 85;
let weightedSum = 0;
let weightTotal = 0;
for (const [catId] of entries) {
const cat = getCategoryById(catId);
const sensitivity = cat?.sensitivity ?? 1;
weightedSum += sensitivity * 82;
weightTotal += sensitivity;
}
return Math.round(weightedSum / weightTotal);
}
/** §8.3 — score our own property; must top the index */
export function selfIndexScore(): { policy: number; infra: number; toolSov: number } {
return {
policy: 95,
infra: 98,
toolSov: computeToolSov({ policy: 95, infra: 98 }),
};
}

107
packages/core/src/types.ts Normal file
View file

@ -0,0 +1,107 @@
export type CaptureStatus = 'at_large' | 'in_custody' | 'captured';
export type GamePhase =
| 'welcome'
| 'meet_red'
| 'draw_the_line'
| 'overworld'
| 'showdown'
| 'homestead'
| 'reveal'
| 'waitlist';
export type LoginMethod =
| 'passkey'
| 'email'
| 'proton'
| 'google'
| 'facebook'
| 'apple'
| 'github'
| 'anonymous';
export type ScoreModel = 'weighted_average' | 'weakest_link';
export interface Outlaw {
id: string;
name: string;
category: string;
region: string;
crime: string;
bounty: number;
policy: number;
infra: number;
cloud: string;
ncReplacement: string;
sovereign: boolean;
boss?: boolean;
faceArtSlot: string;
monthlyCost?: number;
}
export interface Category {
id: string;
label: string;
sensitivity: number;
nc: string;
}
export interface Trophy {
id: string;
label: string;
threshold: number;
physical: boolean;
gate?: string;
}
export interface ArtSlot {
id: string;
label: string;
placeholder: string;
assetPath?: string;
approved: boolean;
}
export interface ScoringWeights {
wPolicy: number;
wInfra: number;
}
export interface PlayerJourneyState {
settlerSince: string;
loginMethod: LoginMethod;
selectedOutlaws: string[];
captured: Record<string, CaptureStatus>;
bountyReclaimed: number;
trophies: string[];
sovereigntyScore: number;
phase: GamePhase;
activeOutlawId?: string;
waitlistEmail?: string;
waitlistChannel?: string;
}
export interface SovereigntyReport {
personalScore: number;
aboveTheLine: boolean;
categoryScores: Record<string, number>;
monthlyFragmentedCost: number;
consolidatedMonthlyCost: number;
projectedScore: number;
replaceableCount: number;
loginExposureNote: string;
}
export interface OffRampPayload {
replaceableTools: Outlaw[];
projectedScore: number;
projectedMonthlyCost: number;
monthlySavings: number;
sovereigntyGain: number;
}
export interface WaitlistSubmission {
email: string;
channel: string;
settlerSince: string;
}

View file

@ -0,0 +1,39 @@
/** V2 hooks — concierge, chronicle, deed fulfillment; not built in V1 */
export interface ConciergeMigrationRequest {
outlawId: string;
playerVaultPath: string;
}
export interface ChronicleEntry {
outlawId: string;
action: 'round_up' | 'validate' | 'cut';
ghostwrittenBy: 'red';
draft: string;
published: boolean;
}
export function conciergeMigrate(_req: ConciergeMigrationRequest): Promise<{ status: 'v2_stub' }> {
return Promise.resolve({ status: 'v2_stub' });
}
export function ghostwriteChronicleEntry(
_outlawId: string,
_action: ChronicleEntry['action'],
): ChronicleEntry {
return {
outlawId: _outlawId,
action: _action,
ghostwrittenBy: 'red',
draft: '[V2] Red will ghostwrite this leg from your actions.',
published: false,
};
}
export function exportChronicleToHomestead(_entries: ChronicleEntry[]): Promise<{ status: 'v2_stub' }> {
return Promise.resolve({ status: 'v2_stub' });
}
export function verifySustainedUse(_settlerId: string): Promise<{ eligible: false; reason: 'v2_stub' }> {
return Promise.resolve({ eligible: false, reason: 'v2_stub' });
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,11 @@
{
"name": "@bigtech-sucks/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.js",
"build": "echo 'server is plain JS — no build step'",
"start": "node src/index.js"
}
}

View file

@ -0,0 +1,72 @@
import { createServer } from 'node:http';
import { appendFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001;
const DATA_DIR = process.env.WAITLIST_DIR || join(__dirname, '..', 'data');
const WAITLIST_FILE = join(DATA_DIR, 'waitlist.jsonl');
/** §8.3 — server stores ONLY waitlist email + channel + settlerSince */
async function handleWaitlist(req, res, body) {
const { email, channel, settlerSince } = JSON.parse(body);
if (!email || !channel || !settlerSince) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'email, channel, and settlerSince required' }));
return;
}
const entry = {
email: String(email).trim().toLowerCase(),
channel: String(channel).trim(),
settlerSince: String(settlerSince),
receivedAt: new Date().toISOString(),
};
await mkdir(DATA_DIR, { recursive: true });
await appendFile(WAITLIST_FILE, JSON.stringify(entry) + '\n');
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, stored: ['email', 'channel', 'settlerSince'] }));
}
const server = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
if (req.method === 'POST' && req.url === '/api/waitlist') {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
try {
await handleWaitlist(req, res, Buffer.concat(chunks).toString());
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'internal error' }));
console.error(err);
}
return;
}
if (req.method === 'GET' && req.url === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, honeypot: false }));
return;
}
res.writeHead(404);
res.end('Not found');
});
server.listen(PORT, () => {
console.log(`bigtech.sucks waitlist API on :${PORT}`);
console.log('Stores only: email, channel, settlerSince');
});

16
packages/web/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Draw the Line — scope your Big Tech exposure and plan your off-ramp." />
<title>bigtech.sucks — Draw the Line</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Press+Start+2P&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
packages/web/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "@bigtech-sucks/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@bigtech-sucks/core": "*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}

100
packages/web/src/App.tsx Normal file
View file

@ -0,0 +1,100 @@
import { Layout } from './components/Layout';
import { useGameState } from './hooks/useGameState';
import { WelcomeScreen } from './screens/WelcomeScreen';
import { MeetRedScreen } from './screens/MeetRedScreen';
import { DrawTheLineScreen } from './screens/DrawTheLineScreen';
import { OverworldScreen } from './screens/OverworldScreen';
import { ShowdownScreen } from './screens/ShowdownScreen';
import { HomesteadScreen } from './screens/HomesteadScreen';
import { RevealScreen } from './screens/RevealScreen';
import { WaitlistScreen } from './screens/WaitlistScreen';
import { allSelectedCaptured } from '@bigtech-sucks/core';
export default function App() {
const {
state,
report,
offRamp,
vaultExport,
login,
goToPhase,
drawLine,
startShowdown,
showdownStep,
submitWaitlist,
} = useGameState();
const goal =
state.phase === 'overworld'
? 'Hunt outlaws → homestead west'
: state.phase === 'showdown'
? 'Round up → validate → cut'
: undefined;
return (
<Layout
bounty={state.bountyReclaimed}
score={state.selectedOutlaws.length ? report.personalScore : undefined}
goal={goal}
>
{state.phase === 'welcome' && (
<WelcomeScreen
onLogin={login}
onContinue={() => goToPhase('meet_red')}
/>
)}
{state.phase === 'meet_red' && (
<MeetRedScreen onComplete={() => goToPhase('draw_the_line')} />
)}
{state.phase === 'draw_the_line' && (
<DrawTheLineScreen onSubmit={drawLine} />
)}
{state.phase === 'overworld' && (
<OverworldScreen
state={state}
onHunt={startShowdown}
onHomestead={() => {
if (allSelectedCaptured(state)) goToPhase('homestead');
}}
/>
)}
{state.phase === 'showdown' && (
<ShowdownScreen
state={state}
onStep={showdownStep}
onBack={() => goToPhase('overworld')}
/>
)}
{state.phase === 'homestead' && offRamp && (
<HomesteadScreen
report={report}
offRamp={offRamp}
earnedTrophies={state.trophies}
bountyReclaimed={state.bountyReclaimed}
onContinue={() => goToPhase('reveal')}
/>
)}
{state.phase === 'reveal' && (
<RevealScreen
report={report}
offRamp={offRamp}
onContinue={() => goToPhase('waitlist')}
/>
)}
{state.phase === 'waitlist' && (
<WaitlistScreen
state={state}
vaultExport={vaultExport}
onSubmit={submitWaitlist}
/>
)}
</Layout>
);
}

View file

@ -0,0 +1,32 @@
import { artSlots } from '@bigtech-sucks/core';
interface Props {
slotId: string;
className?: string;
}
export function ArtSlotView({ slotId, className = '' }: Props) {
const slot = artSlots.find((s) => s.id === slotId) ?? artSlots.find((s) => s.id === slotId.replace('outlaw_', 'outlaw_'));
const outlawSlot = slot ?? {
id: slotId,
label: slotId,
placeholder: `
┌─────────────┐
│ WANTED │
│ ▓▓▓▓▓▓▓▓ │
│ original │
│ art slot │
└─────────────┘`,
approved: false,
};
return (
<pre className={`art-slot ${className}`} aria-label={outlawSlot.label}>
{outlawSlot.assetPath ? (
<img src={outlawSlot.assetPath} alt={outlawSlot.label} />
) : (
outlawSlot.placeholder.trim()
)}
</pre>
);
}

View file

@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
import { selfIndexScore, SOVEREIGNTY_LINE } from '@bigtech-sucks/core';
interface Props {
children: ReactNode;
bounty?: number;
score?: number;
goal?: string;
}
export function Layout({ children, bounty, score, goal }: Props) {
const selfScore = selfIndexScore();
return (
<div className="game-shell">
<header className="game-hud">
<div className="hud-brand">
<span className="hud-title">DRAW THE LINE</span>
<span className="hud-domain">bigtech.sucks</span>
</div>
<div className="hud-stats">
{score !== undefined && (
<div className="hud-stat">
<span className="label">Exposure</span>
<span className={`value ${score >= SOVEREIGNTY_LINE ? 'good' : 'warn'}`}>
{score}
</span>
</div>
)}
{bounty !== undefined && (
<div className="hud-stat">
<span className="label">Reclaimed</span>
<span className="value">${bounty}/mo</span>
</div>
)}
{goal && (
<div className="hud-goal">
<span className="label">Goal</span>
<span>{goal}</span>
</div>
)}
</div>
</header>
<main className="game-main">{children}</main>
<footer className="game-footer">
<span>
This site scores itself: {selfScore.toolSov}/100 — client-side report, no honeypot.
</span>
</footer>
</div>
);
}

View file

@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react';
interface Props {
lines: readonly string[];
onComplete: () => void;
speed?: number;
}
export function TypewriterDialogue({ lines, onComplete, speed = 18 }: Props) {
const [lineIndex, setLineIndex] = useState(0);
const [charIndex, setCharIndex] = useState(0);
const [done, setDone] = useState(false);
const currentLine = lines[lineIndex] ?? '';
const displayed = currentLine.slice(0, charIndex);
const skipLine = useCallback(() => {
if (lineIndex < lines.length - 1) {
setLineIndex((i) => i + 1);
setCharIndex(0);
} else {
setDone(true);
onComplete();
}
}, [lineIndex, lines.length, onComplete]);
useEffect(() => {
if (done) return;
if (charIndex < currentLine.length) {
const t = setTimeout(() => setCharIndex((c) => c + 1), speed);
return () => clearTimeout(t);
}
const t = setTimeout(skipLine, 400);
return () => clearTimeout(t);
}, [charIndex, currentLine.length, done, skipLine, speed]);
useEffect(() => {
const onKey = () => skipLine();
window.addEventListener('keydown', onKey);
window.addEventListener('click', onKey);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('click', onKey);
};
}, [skipLine]);
return (
<div className="dialogue-box" role="status" aria-live="polite">
<p className="dialogue-text">
{displayed}
<span className="dialogue-cursor" aria-hidden="true">
▌
</span>
</p>
<p className="dialogue-hint">Press any key or click to skip</p>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { useCallback, useMemo, useState } from 'react';
import {
advanceCapture,
buildSovereigntyReport,
computeOffRamp,
createInitialState,
persistToVault,
selectOutlaws,
setPhase,
transitionAfterShowdown,
type LoginMethod,
type PlayerJourneyState,
} from '@bigtech-sucks/core';
export function useGameState() {
const [state, setState] = useState<PlayerJourneyState>(() => createInitialState());
const report = useMemo(
() => buildSovereigntyReport(state.selectedOutlaws, state.loginMethod),
[state.selectedOutlaws, state.loginMethod],
);
const offRamp = useMemo(
() => (state.selectedOutlaws.length ? computeOffRamp(state.selectedOutlaws) : null),
[state.selectedOutlaws],
);
const vaultExport = useMemo(() => persistToVault(state), [state]);
const login = useCallback((method: LoginMethod) => {
setState((s) => ({ ...s, loginMethod: method }));
}, []);
const goToPhase = useCallback((phase: PlayerJourneyState['phase']) => {
setState((s) => setPhase(s, phase));
}, []);
const drawLine = useCallback((ids: string[]) => {
setState((s) => selectOutlaws(s, ids));
}, []);
const startShowdown = useCallback((outlawId: string) => {
setState((s) => setPhase({ ...s, activeOutlawId: outlawId }, 'showdown'));
}, []);
const showdownStep = useCallback(
(step: 'round_up' | 'validate' | 'cut') => {
setState((s) => {
if (!s.activeOutlawId) return s;
const advanced = advanceCapture(s, s.activeOutlawId, step);
if (step === 'cut') {
return transitionAfterShowdown(advanced);
}
return advanced;
});
},
[],
);
const submitWaitlist = useCallback(async (email: string, channel: string) => {
const payload = {
email,
channel,
settlerSince: state.settlerSince,
};
const res = await fetch('/api/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Waitlist submission failed');
setState((s) => ({ ...s, waitlistEmail: email, waitlistChannel: channel }));
return payload;
}, [state.settlerSince]);
return {
state,
report,
offRamp,
vaultExport,
login,
goToPhase,
drawLine,
startShowdown,
showdownStep,
submitWaitlist,
};
}

10
packages/web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -0,0 +1,57 @@
import { useMemo, useState } from 'react';
import { outlaws, computeToolSov, redDialogue } from '@bigtech-sucks/core';
import { ArtSlotView } from '../components/ArtSlotView';
interface Props {
onSubmit: (ids: string[]) => void;
}
export function DrawTheLineScreen({ onSubmit }: Props) {
const huntable = useMemo(() => outlaws.filter((o) => !o.sovereign), []);
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<section className="screen draw-line-screen">
<h2>Draw the Line</h2>
<p className="section-desc">{redDialogue.drawTheLine[1]}</p>
<div className="wanted-grid">
{huntable.map((o) => (
<label key={o.id} className={`wanted-card ${selected.has(o.id) ? 'selected' : ''}`}>
<input
type="checkbox"
checked={selected.has(o.id)}
onChange={() => toggle(o.id)}
/>
<ArtSlotView slotId={o.faceArtSlot} className="wanted-face" />
<div className="wanted-info">
<strong>{o.name}</strong>
<span className="wanted-crime">{o.crime}</span>
<span className="wanted-meta">
{o.region} · exposure {computeToolSov(o)} · ${o.bounty}/mo
</span>
</div>
</label>
))}
</div>
<button
type="button"
className="btn btn-primary"
disabled={selected.size === 0}
onClick={() => onSubmit([...selected])}
>
Post the wanted board ({selected.size})
</button>
</section>
);
}

View file

@ -0,0 +1,72 @@
import type { OffRampPayload, SovereigntyReport } from '@bigtech-sucks/core';
import { redDialogue, trophies } from '@bigtech-sucks/core';
import { ArtSlotView } from '../components/ArtSlotView';
interface Props {
report: SovereigntyReport;
offRamp: OffRampPayload | null;
earnedTrophies: string[];
bountyReclaimed: number;
onContinue: () => void;
}
export function HomesteadScreen({
report,
offRamp,
earnedTrophies,
bountyReclaimed,
onContinue,
}: Props) {
return (
<section className="screen homestead-screen">
<ArtSlotView slotId="homestead" />
<h2>Homestead</h2>
<p className="section-desc">{redDialogue.homestead[0]}</p>
<div className="results-grid">
<div className="result-card">
<span className="label">Sovereignty score</span>
<span className="big-number">{report.personalScore}</span>
<span className="sub">The line is at 70</span>
</div>
<div className="result-card">
<span className="label">Fragmented spend</span>
<span className="big-number">${report.monthlyFragmentedCost}</span>
<span className="sub">/month across {report.replaceableCount} tools</span>
</div>
<div className="result-card">
<span className="label">Bounty reclaimed</span>
<span className="big-number">${bountyReclaimed}</span>
<span className="sub">/month in captures</span>
</div>
</div>
{offRamp && (
<div className="offramp-preview">
<h3>Off-ramp projection</h3>
<p>
Consolidate {offRamp.replaceableTools.length} tools → score {offRamp.projectedScore},
~${offRamp.projectedMonthlyCost}/mo, save ~${offRamp.monthlySavings}/mo
</p>
</div>
)}
<div className="trophy-row">
{trophies.map((t) => (
<span
key={t.id}
className={`trophy ${earnedTrophies.includes(t.id) ? 'earned' : 'locked'}`}
>
{t.label}
</span>
))}
</div>
<p className="finite-note">{redDialogue.homestead[2]}</p>
<button type="button" className="btn btn-primary" onClick={onContinue}>
See the path off
</button>
</section>
);
}

View file

@ -0,0 +1,28 @@
import { redDialogue } from '@bigtech-sucks/core';
import { ArtSlotView } from '../components/ArtSlotView';
import { TypewriterDialogue } from '../components/TypewriterDialogue';
interface Props {
onComplete: () => void;
}
export function MeetRedScreen({ onComplete }: Props) {
const lines = [
...redDialogue.welcome,
...redDialogue.sharecropperEvocation,
...redDialogue.drawTheLine.slice(0, 1),
];
return (
<section className="screen meet-red-screen">
<div className="red-panel">
<ArtSlotView slotId="red_color" className="red-portrait" />
<div className="red-speech">
<h2>Red</h2>
<p className="red-role">Wagon Boss</p>
<TypewriterDialogue lines={lines} onComplete={onComplete} />
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,83 @@
import {
getCaptureStatus,
getHuntableOutlaws,
getCapturedOutlaws,
getOutlawById,
canHuntOutlaw,
regions,
type PlayerJourneyState,
} from '@bigtech-sucks/core';
import { ArtSlotView } from '../components/ArtSlotView';
interface Props {
state: PlayerJourneyState;
onHunt: (outlawId: string) => void;
onHomestead: () => void;
}
export function OverworldScreen({ state, onHunt, onHomestead }: Props) {
const huntable = getHuntableOutlaws(state);
const captured = getCapturedOutlaws(state);
const territoryRegions = regions.filter((r) => r !== 'Homestead');
return (
<section className="screen overworld-screen">
<ArtSlotView slotId="overworld_backdrop" />
<h2>Territory Map</h2>
<p className="section-desc">
West is the homestead — always in sight. Pick your hunt.
</p>
<div className="overworld-map">
<button type="button" className="homestead-beacon" onClick={onHomestead}>
← HOMESTEAD
</button>
{territoryRegions.map((region) => {
const regionOutlaws = state.selectedOutlaws
.map((id) => getOutlawById(id))
.filter((o) => o && o.region === region);
if (regionOutlaws.length === 0) return null;
return (
<div key={region} className="territory">
<h3>{region}</h3>
<ul>
{state.selectedOutlaws
.filter((id) => getOutlawById(id)?.region === region)
.map((id) => {
const outlaw = getOutlawById(id);
const status = getCaptureStatus(state, id);
const canHunt = canHuntOutlaw(state, id);
const name = outlaw?.name ?? id;
return (
<li key={id} className={`outlaw-row status-${status}`}>
<span>{name}</span>
<span className="status-badge">{status.replace('_', ' ')}</span>
{canHunt && status === 'at_large' && (
<button type="button" className="btn btn-small" onClick={() => onHunt(id)}>
Hunt
</button>
)}
{status === 'in_custody' && (
<button type="button" className="btn btn-small" onClick={() => onHunt(id)}>
Showdown
</button>
)}
</li>
);
})}
</ul>
</div>
);
})}
</div>
<div className="capture-tally">
<strong>{captured.length}</strong> captured · <strong>{huntable.length}</strong> at large
</div>
</section>
);
}

View file

@ -0,0 +1,39 @@
import { redDialogue } from '@bigtech-sucks/core';
import type { OffRampPayload, SovereigntyReport } from '@bigtech-sucks/core';
interface Props {
report: SovereigntyReport;
offRamp: OffRampPayload | null;
onContinue: () => void;
}
export function RevealScreen({ report, offRamp, onContinue }: Props) {
return (
<section className="screen reveal-screen">
<h2>The Outfit</h2>
<div className="reveal-copy">
{redDialogue.reveal.map((line) => (
<p key={line}>{line}</p>
))}
</div>
<div className="reveal-card">
<h3>Binary Ranch</h3>
<p className="principles">Transparent · Decentralized · Sovereign · Secure</p>
<ul className="reveal-stats">
<li>Your score today: {report.personalScore} → projected {offRamp?.projectedScore ?? '—'}</li>
<li>
Monthly: ${report.monthlyFragmentedCost} fragmented → $
{offRamp?.projectedMonthlyCost ?? 12} consolidated
</li>
<li>Tools replaceable: {offRamp?.replaceableTools.length ?? 0}</li>
</ul>
</div>
<button type="button" className="btn btn-primary" onClick={onContinue}>
Claim early-settler spot
</button>
</section>
);
}

View file

@ -0,0 +1,74 @@
import {
getCaptureStatus,
getOutlawById,
redDialogue,
type PlayerJourneyState,
} from '@bigtech-sucks/core';
import { ArtSlotView } from '../components/ArtSlotView';
interface Props {
state: PlayerJourneyState;
onStep: (step: 'round_up' | 'validate' | 'cut') => void;
onBack: () => void;
}
export function ShowdownScreen({ state, onStep, onBack }: Props) {
const outlaw = state.activeOutlawId ? getOutlawById(state.activeOutlawId) : undefined;
if (!outlaw) return null;
const status = getCaptureStatus(state, outlaw.id);
const isBoss = !!outlaw.boss;
return (
<section className="screen showdown-screen">
<button type="button" className="btn btn-ghost back-btn" onClick={onBack}>
← Territory
</button>
<div className="showdown-stage">
<ArtSlotView slotId={outlaw.faceArtSlot} className="showdown-face" />
<h2>{outlaw.name}</h2>
<p className="wanted-crime">{outlaw.crime}</p>
{isBoss && <p className="boss-note">{redDialogue.mailRiverBoss[0]}</p>}
<div className="capture-tracker">
<div className={`step ${status === 'at_large' ? 'active' : status !== 'at_large' ? 'done' : ''}`}>
1. At Large
</div>
<div className={`step ${status === 'in_custody' ? 'active' : status === 'captured' ? 'done' : ''}`}>
2. In Custody
</div>
<div className={`step ${status === 'captured' ? 'done' : ''}`}>3. Captured</div>
</div>
{status === 'at_large' && (
<div className="showdown-action">
<p>{redDialogue.showdownRoundUp[0]}</p>
<button type="button" className="btn btn-primary" onClick={() => onStep('round_up')}>
Round up
</button>
</div>
)}
{status === 'in_custody' && (
<div className="showdown-action">
<p>{redDialogue.showdownValidate[0]}</p>
<button type="button" className="btn btn-secondary" onClick={() => onStep('validate')}>
Validate (check your land)
</button>
<button type="button" className="btn btn-primary" onClick={() => onStep('cut')}>
Cut the cord
</button>
</div>
)}
{status === 'captured' && (
<div className="captured-stamp">
<ArtSlotView slotId="captured_stamp" />
<p>Bounty collected: ${outlaw.bounty}/mo</p>
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,84 @@
import { useState } from 'react';
import { getDistributionChannel, redDialogue, type PlayerJourneyState } from '@bigtech-sucks/core';
interface Props {
state: PlayerJourneyState;
vaultExport: string;
onSubmit: (email: string, channel: string) => Promise<void>;
}
export function WaitlistScreen({ state, vaultExport, onSubmit }: Props) {
const [email, setEmail] = useState('');
const [channel, setChannel] = useState(getDistributionChannel(state));
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('loading');
try {
await onSubmit(email, channel);
setStatus('done');
} catch {
setStatus('error');
}
};
const downloadVault = () => {
const blob = new Blob([vaultExport], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bigtech-sucks-journey-${state.settlerSince}.json`;
a.click();
URL.revokeObjectURL(url);
};
return (
<section className="screen waitlist-screen">
<h2>Early Settler</h2>
<p className="section-desc">{redDialogue.waitlist[0]}</p>
{status === 'done' ? (
<div className="success-panel">
<p>You&apos;re on the list, settler since {state.settlerSince}.</p>
<p>Only your email and channel touched our server. Everything else stays yours.</p>
<button type="button" className="btn btn-secondary" onClick={downloadVault}>
Download your vault (JSON)
</button>
</div>
) : (
<form className="waitlist-form" onSubmit={handleSubmit}>
<label>
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@yourdomain.com"
/>
</label>
<label>
Share channel (from your login)
<input
type="text"
value={channel}
onChange={(e) => setChannel(e.target.value)}
/>
</label>
<p className="privacy-note">{redDialogue.waitlist[1]}</p>
<button type="submit" className="btn btn-primary" disabled={status === 'loading'}>
{status === 'loading' ? 'Claiming…' : 'Claim spot'}
</button>
{status === 'error' && (
<p className="error-note">Could not reach server — download your vault locally instead.</p>
)}
</form>
)}
<button type="button" className="btn btn-ghost" onClick={downloadVault}>
Export journey to your vault
</button>
</section>
);
}

View file

@ -0,0 +1,52 @@
import { ArtSlotView } from '../components/ArtSlotView';
import type { LoginMethod } from '@bigtech-sucks/core';
interface Props {
onContinue: () => void;
onLogin: (method: LoginMethod) => void;
}
const loginOptions: { method: LoginMethod; label: string }[] = [
{ method: 'passkey', label: 'Passkey' },
{ method: 'email', label: 'Email' },
{ method: 'proton', label: 'Proton' },
{ method: 'google', label: 'Google' },
{ method: 'github', label: 'GitHub' },
{ method: 'anonymous', label: 'Ride in quiet' },
];
export function WelcomeScreen({ onContinue, onLogin }: Props) {
return (
<section className="screen welcome-screen">
<ArtSlotView slotId="title_illustration" />
<h1>Draw the Line</h1>
<p className="tagline">Your data doesn&apos;t have a party. Neither do we.</p>
<p className="lede">
A weary traveler&apos;s assessment — scope your exposure before you plan the crossing.
</p>
<div className="login-picker">
<p className="section-label">How are you riding in?</p>
<div className="login-grid">
{loginOptions.map(({ method, label }) => (
<button
key={method}
type="button"
className="btn btn-secondary"
onClick={() => {
onLogin(method);
onContinue();
}}
>
{label}
</button>
))}
</div>
</div>
<button type="button" className="btn btn-primary" onClick={onContinue}>
Meet the wagon boss
</button>
</section>
);
}

View file

@ -0,0 +1,527 @@
:root {
--bg: #1a1208;
--bg-panel: #2a1f12;
--dust: #c4a574;
--sunset: #e85d3a;
--trail: #8b6914;
--text: #f5e6c8;
--text-dim: #a89070;
--good: #6fcf6f;
--warn: #f2c94c;
--crt: #33ff66;
--font-display: 'Press Start 2P', monospace;
--font-body: 'IBM Plex Mono', monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(ellipse at 50% 0%, #3d2814 0%, var(--bg) 55%),
var(--bg);
color: var(--text);
font-family: var(--font-body);
line-height: 1.5;
}
.game-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.game-hud {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.25rem;
border-bottom: 2px solid var(--trail);
background: rgba(0, 0, 0, 0.35);
flex-wrap: wrap;
}
.hud-title {
font-family: var(--font-display);
font-size: 0.55rem;
color: var(--sunset);
display: block;
}
.hud-domain {
font-size: 0.75rem;
color: var(--text-dim);
}
.hud-stats {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.hud-stat .label,
.hud-goal .label {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-dim);
}
.hud-stat .value {
font-size: 1.1rem;
font-weight: 600;
}
.hud-stat .value.good {
color: var(--good);
}
.hud-stat .value.warn {
color: var(--warn);
}
.game-main {
flex: 1;
padding: 1.5rem;
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
.game-footer {
padding: 0.5rem 1.25rem;
font-size: 0.7rem;
color: var(--text-dim);
border-top: 1px solid var(--trail);
text-align: center;
}
.screen h1,
.screen h2 {
font-family: var(--font-display);
font-size: clamp(0.9rem, 2.5vw, 1.1rem);
line-height: 1.6;
color: var(--dust);
}
.tagline {
color: var(--sunset);
font-style: italic;
}
.lede,
.section-desc {
color: var(--text-dim);
max-width: 60ch;
}
.btn {
font-family: var(--font-body);
cursor: pointer;
border: 2px solid var(--trail);
padding: 0.6rem 1rem;
background: var(--bg-panel);
color: var(--text);
transition: background 0.15s, border-color 0.15s;
}
.btn:hover:not(:disabled) {
border-color: var(--dust);
background: #3a2c18;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background: var(--sunset);
border-color: #b8432a;
color: #1a1208;
font-weight: 600;
}
.btn-secondary {
background: transparent;
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-dim);
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.art-slot {
font-size: 0.65rem;
line-height: 1.2;
color: var(--dust);
background: rgba(0, 0, 0, 0.4);
border: 1px dashed var(--trail);
padding: 0.75rem;
overflow-x: auto;
white-space: pre;
margin: 0 0 1rem;
}
.dialogue-box {
background: rgba(0, 0, 0, 0.45);
border-left: 3px solid var(--sunset);
padding: 1rem;
}
.dialogue-text {
min-height: 3rem;
margin: 0;
}
.dialogue-cursor {
animation: blink 0.8s step-end infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
.dialogue-hint {
font-size: 0.7rem;
color: var(--text-dim);
margin: 0.5rem 0 0;
}
.red-panel {
display: grid;
grid-template-columns: minmax(120px, 200px) 1fr;
gap: 1.5rem;
align-items: start;
}
.red-role {
color: var(--text-dim);
margin-top: 0;
}
.login-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.section-label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-dim);
}
.wanted-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
margin: 1rem 0 1.5rem;
max-height: 55vh;
overflow-y: auto;
}
.wanted-card {
display: grid;
grid-template-columns: 80px 1fr;
gap: 0.5rem;
padding: 0.5rem;
border: 2px solid var(--trail);
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
}
.wanted-card.selected {
border-color: var(--sunset);
background: rgba(232, 93, 58, 0.08);
}
.wanted-card input {
display: none;
}
.wanted-face {
font-size: 0.45rem;
margin: 0;
padding: 0.25rem;
}
.wanted-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.8rem;
}
.wanted-crime {
color: var(--sunset);
font-style: italic;
}
.wanted-meta {
color: var(--text-dim);
font-size: 0.7rem;
}
.overworld-map {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.homestead-beacon {
font-family: var(--font-display);
font-size: 0.55rem;
padding: 0.75rem;
background: linear-gradient(90deg, rgba(111, 207, 111, 0.15), transparent);
border: 2px solid var(--good);
color: var(--good);
cursor: pointer;
text-align: left;
}
.territory {
border: 1px solid var(--trail);
padding: 0.75rem;
background: rgba(0, 0, 0, 0.2);
}
.territory h3 {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--dust);
}
.territory ul {
list-style: none;
padding: 0;
margin: 0;
}
.outlaw-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(139, 105, 20, 0.3);
font-size: 0.85rem;
}
.status-badge {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-dim);
}
.status-captured .status-badge {
color: var(--good);
}
.showdown-stage {
text-align: center;
max-width: 520px;
margin: 0 auto;
}
.capture-tracker {
display: flex;
justify-content: center;
gap: 0.5rem;
margin: 1.5rem 0;
flex-wrap: wrap;
}
.capture-tracker .step {
padding: 0.35rem 0.6rem;
border: 1px solid var(--trail);
font-size: 0.7rem;
color: var(--text-dim);
}
.capture-tracker .step.active {
border-color: var(--warn);
color: var(--warn);
}
.capture-tracker .step.done {
border-color: var(--good);
color: var(--good);
}
.showdown-action {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.captured-stamp {
color: var(--good);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.result-card {
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--trail);
padding: 1rem;
text-align: center;
}
.result-card .label {
display: block;
font-size: 0.7rem;
color: var(--text-dim);
text-transform: uppercase;
}
.result-card .big-number {
display: block;
font-size: 2rem;
font-weight: 600;
color: var(--dust);
}
.result-card .sub {
font-size: 0.75rem;
color: var(--text-dim);
}
.trophy-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1rem 0;
}
.trophy {
padding: 0.35rem 0.6rem;
border: 1px solid var(--trail);
font-size: 0.75rem;
}
.trophy.earned {
border-color: var(--good);
color: var(--good);
}
.trophy.locked {
opacity: 0.4;
}
.finite-note {
font-style: italic;
color: var(--text-dim);
}
.reveal-screen .reveal-copy p {
margin: 0.5rem 0;
}
.reveal-card {
background: rgba(0, 0, 0, 0.4);
border: 2px solid var(--sunset);
padding: 1.25rem;
margin: 1.5rem 0;
}
.reveal-card h3 {
font-family: var(--font-display);
font-size: 0.8rem;
color: var(--sunset);
margin-top: 0;
}
.principles {
color: var(--dust);
}
.reveal-stats {
padding-left: 1.25rem;
color: var(--text-dim);
}
.waitlist-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 400px;
margin-bottom: 1rem;
}
.waitlist-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8rem;
}
.waitlist-form input {
font-family: var(--font-body);
padding: 0.5rem;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--trail);
color: var(--text);
}
.privacy-note {
font-size: 0.75rem;
color: var(--text-dim);
}
.success-panel {
background: rgba(111, 207, 111, 0.1);
border: 1px solid var(--good);
padding: 1rem;
margin-bottom: 1rem;
}
.error-note {
color: var(--sunset);
font-size: 0.8rem;
}
@media (prefers-reduced-motion: reduce) {
.dialogue-cursor {
animation: none;
}
.btn {
transition: none;
}
}
@media (max-width: 600px) {
.red-panel {
grid-template-columns: 1fr;
}
.wanted-grid {
max-height: none;
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -0,0 +1,18 @@
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@bigtech-sucks/core': path.resolve(__dirname, '../core/src/index.ts'),
},
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001',
},
},
});