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