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:
commit
de53ac41a5
47 changed files with 8553 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
37
README.md
Normal 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
2344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal 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
320
packages/core/db/build.mjs
Normal 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
163
packages/core/db/schema.sql
Normal 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);
|
||||
135
packages/core/db/seed/0001-signal.sql
Normal file
135
packages/core/db/seed/0001-signal.sql
Normal 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.');
|
||||
23
packages/core/package.json
Normal file
23
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
113
packages/core/src/capture.ts
Normal file
113
packages/core/src/capture.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
63
packages/core/src/data/artSlots.ts
Normal file
63
packages/core/src/data/artSlots.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
27
packages/core/src/data/categories.ts
Normal file
27
packages/core/src/data/categories.ts
Normal 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' },
|
||||
];
|
||||
35
packages/core/src/data/config.ts
Normal file
35
packages/core/src/data/config.ts
Normal 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',
|
||||
};
|
||||
1231
packages/core/src/data/generated/catalog.json
Normal file
1231
packages/core/src/data/generated/catalog.json
Normal file
File diff suppressed because it is too large
Load diff
1290
packages/core/src/data/generated/catalog.ts
Normal file
1290
packages/core/src/data/generated/catalog.ts
Normal file
File diff suppressed because it is too large
Load diff
689
packages/core/src/data/outlaws.ts
Normal file
689
packages/core/src/data/outlaws.ts
Normal 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];
|
||||
8
packages/core/src/data/trophies.ts
Normal file
8
packages/core/src/data/trophies.ts
Normal 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' },
|
||||
];
|
||||
60
packages/core/src/dialogue.ts
Normal file
60
packages/core/src/dialogue.ts
Normal 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;
|
||||
66
packages/core/src/gameState.ts
Normal file
66
packages/core/src/gameState.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
12
packages/core/src/index.ts
Normal file
12
packages/core/src/index.ts
Normal 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';
|
||||
29
packages/core/src/offRamp.ts
Normal file
29
packages/core/src/offRamp.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
packages/core/src/scoring.test.ts
Normal file
19
packages/core/src/scoring.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
packages/core/src/scoring.ts
Normal file
152
packages/core/src/scoring.ts
Normal 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
107
packages/core/src/types.ts
Normal 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;
|
||||
}
|
||||
39
packages/core/src/v2-stubs.ts
Normal file
39
packages/core/src/v2-stubs.ts
Normal 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' });
|
||||
}
|
||||
16
packages/core/tsconfig.json
Normal file
16
packages/core/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
11
packages/server/package.json
Normal file
11
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
72
packages/server/src/index.js
Normal file
72
packages/server/src/index.js
Normal 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
16
packages/web/index.html
Normal 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
23
packages/web/package.json
Normal 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
100
packages/web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
packages/web/src/components/ArtSlotView.tsx
Normal file
32
packages/web/src/components/ArtSlotView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
packages/web/src/components/Layout.tsx
Normal file
54
packages/web/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
packages/web/src/components/TypewriterDialogue.tsx
Normal file
58
packages/web/src/components/TypewriterDialogue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
packages/web/src/hooks/useGameState.ts
Normal file
91
packages/web/src/hooks/useGameState.ts
Normal 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
10
packages/web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
57
packages/web/src/screens/DrawTheLineScreen.tsx
Normal file
57
packages/web/src/screens/DrawTheLineScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
packages/web/src/screens/HomesteadScreen.tsx
Normal file
72
packages/web/src/screens/HomesteadScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
packages/web/src/screens/MeetRedScreen.tsx
Normal file
28
packages/web/src/screens/MeetRedScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
packages/web/src/screens/OverworldScreen.tsx
Normal file
83
packages/web/src/screens/OverworldScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
packages/web/src/screens/RevealScreen.tsx
Normal file
39
packages/web/src/screens/RevealScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
packages/web/src/screens/ShowdownScreen.tsx
Normal file
74
packages/web/src/screens/ShowdownScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
packages/web/src/screens/WaitlistScreen.tsx
Normal file
84
packages/web/src/screens/WaitlistScreen.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
52
packages/web/src/screens/WelcomeScreen.tsx
Normal file
52
packages/web/src/screens/WelcomeScreen.tsx
Normal 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't have a party. Neither do we.</p>
|
||||
<p className="lede">
|
||||
A weary traveler'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>
|
||||
);
|
||||
}
|
||||
527
packages/web/src/styles/global.css
Normal file
527
packages/web/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
22
packages/web/tsconfig.app.json
Normal file
22
packages/web/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
21
packages/web/tsconfig.json
Normal file
21
packages/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
packages/web/vite.config.ts
Normal file
18
packages/web/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue