chore: bootstrap monorepo with TMT-compatible schema and day-1 content

Sets up the pnpm workspace, shared TS types and formula/predicate
registries, and the six day-1 content layers (Code, Commits, Coffee,
Tickets, Releases, Tech Debt). Why now: subsequent backend and
client work depends on a stable shared contract — the formula
registry approach (no eval) means content stays data-only and both
sides can validate against the same types.

Coverage gate at 90% (currently 98.83% branches, 100% lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 12:07:39 -07:00
commit 2dd1d8cb01
49 changed files with 3303 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# =============================================================================
# TehRiehlIncremental — example environment file
# =============================================================================
# Copy to .env and fill in real values. DO NOT commit .env.
#
# SECURITY: The encryption keys below are EXAMPLES ONLY. Regenerate them for
# any environment that touches real user data. Suggested commands:
#
# openssl rand -base64 32 # for ENCRYPTION_MASTER_KEY
# openssl rand -base64 32 # for EMAIL_HASH_SECRET
# openssl rand -base64 64 # for JWT_SECRET / JWT_REFRESH_SECRET
# =============================================================================
# --- Database ---
DATABASE_URL="postgresql://teh_riehl:dev_password@localhost:5432/teh_riehl?schema=public"
# --- Encryption ---
# 32 random bytes, base64-encoded. Used as the KEK (Key Encryption Key) for
# envelope encryption of all field-level encrypted columns and save blobs.
# REPLACE BEFORE DEPLOYING.
ENCRYPTION_MASTER_KEY="REPLACE_ME_BASE64_32_BYTES_xxxxxxxxxxxxxxxxxxxxxx="
# HMAC-SHA256 secret used to derive blind indexes for emails (so we can look up
# users by email without storing plaintext). REPLACE BEFORE DEPLOYING.
EMAIL_HASH_SECRET="REPLACE_ME_BASE64_32_BYTES_xxxxxxxxxxxxxxxxxxxxxx="
# --- Auth ---
JWT_SECRET="REPLACE_ME_BASE64_64_BYTES"
JWT_REFRESH_SECRET="REPLACE_ME_BASE64_64_BYTES_DIFFERENT"
JWT_ACCESS_TTL="15m"
JWT_REFRESH_TTL="7d"
# --- Server ---
PORT="3000"
NODE_ENV="development"
CORS_ORIGIN="http://localhost:5173"
# --- Client (Vite) ---
VITE_API_BASE_URL="http://localhost:3000"
+45
View File
@@ -0,0 +1,45 @@
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
build/
.next/
.turbo/
*.tsbuildinfo
# Test coverage
coverage/
*.lcov
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Environment
.env
.env.local
.env.*.local
!.env.example
# Editor / OS
.DS_Store
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json.example
.idea/
*.swp
*.swo
# Prisma
packages/server/prisma/migrations/dev.db*
# Docker volumes
.docker-data/
# Vite
*.local
CLAUDE.md
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
build
coverage
.turbo
.next
pnpm-lock.yaml
packages/server/prisma/migrations
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
}
+67
View File
@@ -0,0 +1,67 @@
# Project Plan: TehRiehlIncremental
This document outlines the architecture, tech stack, game design, and development lifecycle for a new incremental game, **TehRiehlIncremental**. Built upon the core philosophy of The Modding Tree framework, the game is designed to scale securely while providing long-term engagement.
## 1. Game Design & Core Features
To maximize player retention right out of the gate, the game will launch with a massive initial content drop, taking inspiration from the deep, multi-layered progression systems found in titles like Idleon. The core loop revolves around unlocking nodes on a highly interconnected tree structure.
* **Extensive Day-1 Content:** Multiple resource layers, prestige mechanics, and overlapping progression paths available at launch so players have diverse short-term and long-term goals.
* **Expansion-Ready Architecture:** The underlying data structure for the tree will be strictly modular. "Expansions" will function as drop-in content packs that bolt new sub-trees onto existing late-game nodes without requiring a hard reset of base calculations.
* **Integrated Patch Notes System:** A dedicated, stylish UI modal parsing markdown/JSON files directly from the repository. This keeps players informed of balancing tweaks and new branches seamlessly between versions.
## 2. Technical Architecture & Tech Stack
The application will be structured as a monorepo managed by pnpm, optimizing dependency installation and workspace management across both the client and server.
| Component | Technology | Implementation Strategy |
| --- | --- | --- |
| **Frontend Client** | React, TypeScript, Vite | Highly performant UI rendering utilizing strict TypeScript types to prevent runtime errors during deep tree calculations. |
| **Styling & UI Components** | TailwindCSS, ShadCN | Rapid, consistent design system. ShadCN will provide the accessible primitives (modals for patch notes, tooltips for tree nodes). |
| **State Management** | Redux | Centralized store for the complex game state, handling offline progress calculations, resource ticks, and node unlocks. |
| **Backend API** | NestJS | Enterprise-grade architecture for handling authentication, secure cloud saves, and potential future multiplayer features. |
| **Database** | PostgreSQL | Robust relational storage for user profiles, JSON save blobs, and game economy analytics. |
## 3. Development Methodology: Test-Driven
Given the mathematically intensive nature of incremental games, strict adherence to Test-Driven Development (TDD) ensures formulas remain stable as numbers scale exponentially.
* **TDD Workflow:** Tests for resource generation formulas, cost scaling, and unlock requirements will be written before the implementation logic.
* **Coverage Goal:** Strict enforcement of **90%+ unit test coverage** using Vitest/Jest, particularly focusing on the Redux reducers and the core math utility functions.
## 4. CI/CD Pipeline (GitHub Actions)
A robust pipeline will automatically secure, format, and validate every pull request before deployment, ensuring high code quality across the monorepo.
```yaml
# Example workflow structure
name: Monorepo CI Pipeline
on: [push, pull_request]
jobs:
validate-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
- name: Setup Node & pnpm
# 1. Security Checks
- name: Secrets Scan (e.g., GitLeaks)
- name: Container/Dependency Security (Trivy)
# 2. Linting & Formatting
- name: ESLint Check (Frontend & Backend)
- name: Prettier Format Check
# 3. Testing
- name: Run Unit Tests with Coverage
- name: Enforce 90% Coverage Threshold
```
## 5. Next Steps
* Initialize the pnpm monorepo workspace.
* Scaffold the Vite/React frontend and NestJS backend.
* Draft the initial JSON/TypeScript structure for the base "Modding Tree" data format.
* Establish the GitHub Actions workflow file.
+84
View File
@@ -0,0 +1,84 @@
# TehRiehlIncremental
A meta/dev-themed incremental game. You play a developer; resources are Lines of Code, Commits, Coffee, Closed Tickets, Releases, and Tech Debt. Built on a TMT-compatible (The Modding Tree) JSON content schema with a React + Redux frontend and a NestJS + Postgres backend.
> **Status**: Pre-alpha. Foundation only. See [`ProjectPlan.md`](./ProjectPlan.md) for the design plan and [`CLAUDE.md`](./CLAUDE.md) for AI-assistant working notes.
---
## Architecture
```
TehRiehlIncremental/
├── docker-compose.yml Postgres for local dev
├── pnpm-workspace.yaml
├── packages/
│ ├── shared/ TS types, TMT-compatible schema, formula registry
│ ├── content/ JSON layer definitions for the day-1 content drop
│ ├── client/ Vite + React + Redux + Tailwind + ShadCN
│ └── server/ NestJS + Prisma + envelope encryption service
└── .github/workflows/ CI: lint, typecheck, test (90% coverage), build
```
The shared package is the contract between client and server: both import the same TS types and the same formula registry, so save validation and game logic stay aligned by construction.
## Prerequisites
- Node.js 20+
- pnpm 9+ (`corepack enable && corepack prepare pnpm@9.12.0 --activate`)
- Docker (for local Postgres) — or a locally-running Postgres if you prefer
## Setup
```bash
# 1. Install dependencies
pnpm install
# 2. Copy env file and regenerate secrets
cp .env.example .env
# Then edit .env. For local dev you can use these one-liners:
# openssl rand -base64 32 # ENCRYPTION_MASTER_KEY
# openssl rand -base64 32 # EMAIL_HASH_SECRET
# openssl rand -base64 64 # JWT_SECRET, JWT_REFRESH_SECRET
# 3. Start Postgres
docker compose up -d
# 4. Run migrations (once the server package exists)
pnpm --filter @teh-riehl/server prisma migrate dev
# 5. Start everything
pnpm dev
```
## Useful scripts
| Command | What it does |
| --- | --- |
| `pnpm dev` | Run all packages in dev mode (parallel) |
| `pnpm build` | Build all packages |
| `pnpm test` | Run all package test suites |
| `pnpm test:coverage` | Test with coverage; CI fails if formulas/services <90% |
| `pnpm typecheck` | `tsc --noEmit` across packages |
| `pnpm lint` | ESLint across packages |
| `pnpm format` | Prettier write |
## Security model
**TL;DR**: passwords are hashed (Argon2id), PII and save blobs are field-level encrypted (AES-256-GCM via envelope encryption), and disk-level encryption is expected at the infra layer in production.
- **Passwords**: hashed with Argon2id. Never stored or logged.
- **Emails**: stored as ciphertext (AES-256-GCM) + a separate HMAC-SHA256 blind index for login lookup. The DB never sees the plaintext.
- **Save blobs**: full game-state JSON, encrypted in the server before insert.
- **Envelope encryption**: every encrypted record has a per-record DEK (Data Encryption Key), itself encrypted with the master KEK (Key Encryption Key) from `ENCRYPTION_MASTER_KEY`. Rotating the master key only re-encrypts the DEKs, not all data.
- **Encryption at rest** (disk-level): the local Docker Postgres volume is **not** encrypted at rest — this is acceptable for development because the app-layer encryption is the primary defense. **Production deployments must use an encrypted block device** (AWS EBS+KMS, GCP PD+CMEK, or LUKS).
## Game content
Layer definitions live in `packages/content/layers/*.json` and conform to the TS schema in `packages/shared/src/schema/`. Adding a new layer = drop a new JSON file in and (if you reference a new formula) register it in `packages/shared/src/formulas/registry.ts`. No frontend changes required for content-only additions — the React app reads the registered content list at build time.
Formulas are referenced by ID (e.g. `{ "fn": "polynomial", "args": [10, 1.15] }`) instead of being evaluated from strings, so content stays data-only and there is no `eval` / `new Function` attack surface.
## License
TBD.
+25
View File
@@ -0,0 +1,25 @@
services:
postgres:
image: postgres:16-alpine
container_name: teh-riehl-postgres
restart: unless-stopped
environment:
POSTGRES_USER: teh_riehl
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: teh_riehl
ports:
- "5432:5432"
volumes:
- teh-riehl-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U teh_riehl -d teh_riehl"]
interval: 5s
timeout: 5s
retries: 5
volumes:
teh-riehl-pgdata:
# NOTE: this named volume is NOT encrypted at rest in local dev.
# Field-level encryption in the app layer is the primary defense.
# In production, mount an encrypted block device (e.g. AWS EBS with KMS,
# GCP PD with CMEK, or LUKS on bare metal).
+25
View File
@@ -0,0 +1,25 @@
{
"name": "teh-riehl-incremental",
"version": "0.1.0",
"private": true,
"description": "Meta/dev-themed incremental game built on a TMT-compatible schema.",
"packageManager": "pnpm@9.12.0",
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
},
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm -r --parallel dev",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"prettier": "^3.3.3",
"typescript": "^5.6.2"
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@teh-riehl/content",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./layers": "./src/layers.ts",
"./patch-notes": "./src/patch-notes.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint \"src/**/*.ts\""
},
"dependencies": {
"@teh-riehl/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.7.4",
"typescript": "^5.6.2",
"vitest": "^2.1.2"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { layers, layerById } from './layers.js';
export { patchNotes, type PatchNote } from './patch-notes.js';
+25
View File
@@ -0,0 +1,25 @@
import type { Layer } from '@teh-riehl/shared';
import codeLayer from './layers/code.json' with { type: 'json' };
import commitsLayer from './layers/commits.json' with { type: 'json' };
import coffeeLayer from './layers/coffee.json' with { type: 'json' };
import ticketsLayer from './layers/tickets.json' with { type: 'json' };
import releasesLayer from './layers/releases.json' with { type: 'json' };
import techdebtLayer from './layers/techdebt.json' with { type: 'json' };
/**
* Day-1 content drop. Order here is the layer-tab order in the UI. New
* expansion JSON files should be appended; do not insert in the middle
* (insertion order is part of save migration semantics).
*/
export const layers: ReadonlyArray<Layer> = [
codeLayer as unknown as Layer,
commitsLayer as unknown as Layer,
coffeeLayer as unknown as Layer,
ticketsLayer as unknown as Layer,
releasesLayer as unknown as Layer,
techdebtLayer as unknown as Layer,
];
export const layerById: Readonly<Record<string, Layer>> = Object.freeze(
Object.fromEntries(layers.map((l) => [l.id, l])),
);
+91
View File
@@ -0,0 +1,91 @@
{
"id": "code",
"name": "Code",
"color": "#22d3ee",
"position": { "row": 0, "col": 0 },
"resource": { "name": "Lines of Code", "symbol": "LoC" },
"type": "normal",
"branches": [],
"startData": { "amount": 0 },
"production": { "fn": "constant", "args": [1] },
"upgrades": [
{
"id": "first-keystroke",
"title": "First Keystroke",
"description": "Doubles base LoC production. You typed something — congratulations.",
"cost": { "fn": "constant", "args": [10] },
"effect": { "fn": "constant", "args": [2] },
"row": 1,
"col": 1
},
{
"id": "refactor",
"title": "Refactor",
"description": "x3 LoC production. Same logic, fewer tears.",
"cost": { "fn": "constant", "args": [100] },
"effect": { "fn": "constant", "args": [3] },
"unlocked": { "pred": "hasUpgrade", "args": ["code", "first-keystroke"] },
"row": 1,
"col": 2
},
{
"id": "tdd",
"title": "Test-Driven Development",
"description": "x4 LoC production. Red, green, refactor, raise.",
"cost": { "fn": "constant", "args": [2500] },
"effect": { "fn": "constant", "args": [4] },
"unlocked": { "pred": "hasUpgrade", "args": ["code", "refactor"] },
"row": 1,
"col": 3
},
{
"id": "linter",
"title": "Strict Linter",
"description": "x2 LoC production. Yells at you, but rightfully so.",
"cost": { "fn": "constant", "args": [50000] },
"effect": { "fn": "constant", "args": [2] },
"unlocked": { "pred": "hasUpgrade", "args": ["code", "tdd"] },
"row": 2,
"col": 1
},
{
"id": "pair-programming",
"title": "Pair Programming",
"description": "x5 LoC production. One drives, one drinks coffee.",
"cost": { "fn": "constant", "args": [1000000] },
"effect": { "fn": "constant", "args": [5] },
"unlocked": { "pred": "hasUpgrade", "args": ["code", "linter"] },
"row": 2,
"col": 2
}
],
"buyables": [
{
"id": "intern",
"title": "Hire an Intern",
"description": "Each intern adds 0.5 LoC/sec. Cost scales 1.2x per intern.",
"cost": { "fn": "polynomial", "args": [25, 1.2] },
"effect": { "fn": "linear", "args": [0, 0.5] },
"row": 3,
"col": 1
},
{
"id": "junior-dev",
"title": "Hire a Junior",
"description": "Each junior adds 2 LoC/sec. Cost scales 1.4x.",
"cost": { "fn": "polynomial", "args": [500, 1.4] },
"effect": { "fn": "linear", "args": [0, 2] },
"unlocked": { "pred": "buyableAtLeast", "args": ["code", "intern", 5] },
"row": 3,
"col": 2
}
],
"milestones": [
{
"id": "first-thousand",
"title": "Hello, World",
"description": "Reach 1,000 LoC. Code panel shows a cute scanline animation.",
"requirement": { "pred": "totalAtLeast", "args": ["code", 1000] }
}
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"id": "coffee",
"name": "Coffee",
"color": "#fbbf24",
"position": { "row": 1, "col": 1 },
"resource": { "name": "Cups", "symbol": "☕" },
"type": "static",
"branches": ["commits"],
"startData": { "amount": 0 },
"unlocked": { "pred": "hasMilestone", "args": ["commits", "first-commit"] },
"production": { "fn": "constant", "args": [0.1] },
"buyables": [
{
"id": "espresso",
"title": "Espresso Shot",
"description": "+1 Cup/sec per shot. Cost scales 1.3x.",
"cost": { "fn": "polynomial", "args": [10, 1.3] },
"effect": { "fn": "linear", "args": [0, 1] },
"row": 1,
"col": 1
},
{
"id": "cold-brew",
"title": "Cold Brew",
"description": "+5 Cup/sec per buy. Cost scales 1.5x.",
"cost": { "fn": "polynomial", "args": [200, 1.5] },
"effect": { "fn": "linear", "args": [0, 5] },
"unlocked": { "pred": "buyableAtLeast", "args": ["coffee", "espresso", 10] },
"row": 1,
"col": 2
}
],
"upgrades": [
{
"id": "caffeine-tolerance",
"title": "Caffeine Tolerance",
"description": "Cups multiply Code production via sqrt scaling.",
"cost": { "fn": "constant", "args": [10] },
"effect": { "fn": "sqrt", "args": [1] },
"row": 2,
"col": 1
},
{
"id": "third-wave",
"title": "Third Wave Beans",
"description": "Espresso shots are 2x more effective.",
"cost": { "fn": "constant", "args": [100] },
"effect": { "fn": "constant", "args": [2] },
"unlocked": { "pred": "hasUpgrade", "args": ["coffee", "caffeine-tolerance"] },
"row": 2,
"col": 2
}
],
"milestones": [
{
"id": "fully-caffeinated",
"title": "Fully Caffeinated",
"description": "Reach 100 Cups. Reduce Tech Debt accrual by 25%.",
"requirement": { "pred": "amountAtLeast", "args": ["coffee", 100] },
"effectTags": ["reduce-tech-debt-accrual-25"]
}
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"id": "commits",
"name": "Commits",
"color": "#e879f9",
"position": { "row": 1, "col": 0 },
"resource": { "name": "Commits", "symbol": "★" },
"type": "normal",
"branches": ["code"],
"startData": { "amount": 0 },
"unlocked": { "pred": "bestAtLeast", "args": ["code", 5000] },
"reset": {
"type": "normal",
"requirement": { "fn": "constant", "args": [5000] },
"gainFormula": { "fn": "prestigePoints", "args": [5000, 0.5] },
"resetsLayers": ["code"]
},
"upgrades": [
{
"id": "commit-amend",
"title": "git commit --amend",
"description": "Code production x1.5 per Commit owned (multiplicative with self-stacking softcap).",
"cost": { "fn": "constant", "args": [1] },
"effect": { "fn": "constant", "args": [1.5] },
"row": 1,
"col": 1
},
{
"id": "rebase",
"title": "Interactive Rebase",
"description": "Refactor upgrade also doubles intern production.",
"cost": { "fn": "constant", "args": [3] },
"effect": { "fn": "constant", "args": [2] },
"unlocked": { "pred": "hasUpgrade", "args": ["commits", "commit-amend"] },
"row": 1,
"col": 2
},
{
"id": "force-push",
"title": "git push --force",
"description": "Reduces prestige requirement by 30%. Use responsibly.",
"cost": { "fn": "constant", "args": [10] },
"effect": { "fn": "constant", "args": [0.7] },
"unlocked": { "pred": "hasUpgrade", "args": ["commits", "rebase"] },
"row": 1,
"col": 3
}
],
"milestones": [
{
"id": "first-commit",
"title": "Initial Commit",
"description": "Unlock the Coffee layer.",
"requirement": { "pred": "amountAtLeast", "args": ["commits", 1] }
},
{
"id": "ten-commits",
"title": "Ten in a Row",
"description": "Keep Code upgrades on Commit prestige.",
"requirement": { "pred": "amountAtLeast", "args": ["commits", 10] },
"effectTags": ["keep-code-upgrades-on-commit-reset"]
}
]
}
+57
View File
@@ -0,0 +1,57 @@
{
"id": "releases",
"name": "Releases",
"color": "#a78bfa",
"position": { "row": 2, "col": 1 },
"resource": { "name": "Release Tags", "symbol": "v" },
"type": "normal",
"branches": ["commits", "coffee", "tickets"],
"startData": { "amount": 0 },
"unlocked": { "pred": "amountAtLeast", "args": ["tickets", 3] },
"reset": {
"type": "normal",
"requirement": { "fn": "constant", "args": [1000] },
"gainFormula": { "fn": "prestigePoints", "args": [1000, 0.4] },
"resetsLayers": ["code", "commits", "coffee", "tickets"]
},
"upgrades": [
{
"id": "ci-cd",
"title": "CI/CD Pipeline",
"description": "All production x3. The robots do it for you.",
"cost": { "fn": "constant", "args": [1] },
"effect": { "fn": "constant", "args": [3] },
"row": 1,
"col": 1
},
{
"id": "feature-flags",
"title": "Feature Flags",
"description": "Unlocks the Expansion node hook for late-game content.",
"cost": { "fn": "constant", "args": [5] },
"effect": { "fn": "constant", "args": [1] },
"unlocked": { "pred": "hasUpgrade", "args": ["releases", "ci-cd"] },
"row": 1,
"col": 2,
"effectTags": ["enable-expansion-hook"]
},
{
"id": "semantic-versioning",
"title": "Semantic Versioning",
"description": "Release Tags increase Commit gain by sqrt(R).",
"cost": { "fn": "constant", "args": [25] },
"effect": { "fn": "sqrt", "args": [1] },
"unlocked": { "pred": "hasUpgrade", "args": ["releases", "feature-flags"] },
"row": 2,
"col": 1
}
],
"milestones": [
{
"id": "v1-0-0",
"title": "v1.0.0",
"description": "Reach 1 Release Tag. You're shipping now. Patch notes modal becomes a developer-grade git log viewer.",
"requirement": { "pred": "amountAtLeast", "args": ["releases", 1] }
}
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"id": "techdebt",
"name": "Tech Debt",
"color": "#f87171",
"position": { "row": 0, "col": 1 },
"resource": { "name": "Debt", "symbol": "Δ" },
"type": "static",
"branches": [],
"startData": { "amount": 0 },
"unlocked": { "pred": "totalAtLeast", "args": ["code", 5000] },
"production": { "fn": "fromLayerAmount", "args": ["code", 0.0005] },
"buyables": [
{
"id": "pay-down",
"title": "Pay Down Debt",
"description": "Spend LoC (cost in 'code') to remove Debt. -10 Debt per buy.",
"cost": { "fn": "polynomial", "args": [500, 1.2] },
"effect": { "fn": "linear", "args": [0, -10] },
"row": 1,
"col": 1
}
],
"milestones": [
{
"id": "debt-collector",
"title": "Debt Collector",
"description": "Hold below 100 Debt while above 1e6 LoC. Code/sec x2.",
"requirement": { "pred": "totalAtLeast", "args": ["code", 1000000] },
"effectTags": ["code-production-x2-if-debt-below-100"]
}
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"id": "tickets",
"name": "Tickets",
"color": "#4ade80",
"position": { "row": 1, "col": 2 },
"resource": { "name": "Closed Tickets", "symbol": "#" },
"type": "static",
"branches": ["commits"],
"startData": { "amount": 0 },
"unlocked": { "pred": "amountAtLeast", "args": ["commits", 5] },
"challenges": [
{
"id": "p0-bug",
"title": "P0 Bug",
"description": "Code production halved. Reach 1e6 LoC to close.",
"goal": { "pred": "amountAtLeast", "args": ["code", 1000000] },
"debuffs": ["halve-code-production"],
"reward": { "fn": "constant", "args": [1] },
"maxCompletions": 5
},
{
"id": "flaky-test",
"title": "Flaky Test",
"description": "Upgrade effects rerolled every 10s. Reach 1 Commit to close.",
"goal": { "pred": "amountAtLeast", "args": ["commits", 1] },
"debuffs": ["randomize-upgrade-effects"],
"reward": { "fn": "constant", "args": [1] },
"unlocked": { "pred": "amountAtLeast", "args": ["tickets", 1] },
"maxCompletions": 5
},
{
"id": "legacy-codebase",
"title": "Legacy Codebase",
"description": "All buyables disabled. Reach 5e9 LoC to close.",
"goal": { "pred": "amountAtLeast", "args": ["code", 5000000000] },
"debuffs": ["disable-buyables"],
"reward": { "fn": "constant", "args": [3] },
"unlocked": { "pred": "amountAtLeast", "args": ["tickets", 5] },
"maxCompletions": 3
}
],
"milestones": [
{
"id": "ticket-master",
"title": "Ticket Master",
"description": "Reach 10 Closed Tickets. Code/sec +50% permanently.",
"requirement": { "pred": "amountAtLeast", "args": ["tickets", 10] },
"effectTags": ["code-production-plus-50"]
}
]
}
+26
View File
@@ -0,0 +1,26 @@
export interface PatchNote {
version: string;
date: string;
/** Rendered in the modal as a styled "git log" entry. Plain markdown. */
body: string;
}
/**
* Patch notes are kept inline here (rather than as separate .md files) so
* Vite/tsc can resolve them without bundler magic. Newest entry first.
*/
export const patchNotes: ReadonlyArray<PatchNote> = [
{
version: '0.1.0',
date: '2026-05-17',
body: [
'## v0.1.0 — Initial Commit',
'',
'- Six day-1 layers: Code, Commits, Coffee, Tickets, Releases, Tech Debt.',
'- Formula/predicate registry with eight + eight builtins.',
'- Field-level encryption for emails and save blobs (envelope KEK/DEK).',
'- Dark+neon cyber UI, dev-themed.',
'- TMT-compatible JSON content schema — drop new layers in without touching the renderer.',
].join('\n'),
},
];
+126
View File
@@ -0,0 +1,126 @@
import { beforeAll, describe, expect, it } from 'vitest';
import {
hasFormula,
hasPredicate,
registerBuiltinFormulas,
registerBuiltinPredicates,
__resetRegistryForTests,
__resetPredicateRegistryForTests,
type FormulaRef,
type PredicateRef,
type Layer,
type Upgrade,
type Buyable,
} from '@teh-riehl/shared';
import { layers, layerById } from '../src/layers.js';
beforeAll(() => {
__resetRegistryForTests();
__resetPredicateRegistryForTests();
registerBuiltinFormulas();
registerBuiltinPredicates();
});
function collectFormulas(layer: Layer): FormulaRef[] {
const refs: FormulaRef[] = [];
if (layer.production) refs.push(layer.production);
if (layer.reset) {
refs.push(layer.reset.requirement, layer.reset.gainFormula);
}
for (const u of layer.upgrades ?? []) refs.push(u.cost, u.effect);
for (const b of layer.buyables ?? []) refs.push(b.cost, b.effect);
for (const c of layer.challenges ?? []) refs.push(c.reward);
return refs;
}
function collectPredicates(layer: Layer): PredicateRef[] {
const refs: PredicateRef[] = [];
if (layer.unlocked) refs.push(layer.unlocked);
for (const u of layer.upgrades ?? []) if (u.unlocked) refs.push(u.unlocked);
for (const b of layer.buyables ?? []) if (b.unlocked) refs.push(b.unlocked);
for (const m of layer.milestones ?? []) refs.push(m.requirement);
for (const c of layer.challenges ?? []) {
refs.push(c.goal);
if (c.unlocked) refs.push(c.unlocked);
}
for (const a of layer.achievements ?? []) refs.push(a.trigger);
if (layer.reset?.canReset) refs.push(layer.reset.canReset);
return refs;
}
describe('day-1 content', () => {
it('contains all six planned layers', () => {
expect(layers.map((l) => l.id)).toEqual([
'code',
'commits',
'coffee',
'tickets',
'releases',
'techdebt',
]);
});
it('layerById matches layers list', () => {
for (const layer of layers) {
expect(layerById[layer.id]).toBe(layer);
}
});
it.each(['code', 'commits', 'coffee', 'tickets', 'releases', 'techdebt'])(
'layer %s has required schema fields',
(id) => {
const layer = layerById[id]!;
expect(layer.id).toBe(id);
expect(typeof layer.name).toBe('string');
expect(layer.color).toMatch(/^#[0-9a-f]{6}$/i);
expect(layer.resource.name).toBeTruthy();
expect(['normal', 'static']).toContain(layer.type);
expect(layer.startData.amount).toBe(0);
},
);
it('every layer branches points to a real layer', () => {
const ids = new Set(layers.map((l) => l.id));
for (const layer of layers) {
for (const b of layer.branches) {
expect(ids.has(b), `${layer.id} branches into unknown ${b}`).toBe(true);
}
}
});
it('every formula reference names a registered formula', () => {
for (const layer of layers) {
for (const ref of collectFormulas(layer)) {
expect(
hasFormula(ref.fn),
`${layer.id}: formula '${ref.fn}' is not registered`,
).toBe(true);
}
}
});
it('every predicate reference names a registered predicate', () => {
for (const layer of layers) {
for (const ref of collectPredicates(layer)) {
expect(
hasPredicate(ref.pred),
`${layer.id}: predicate '${ref.pred}' is not registered`,
).toBe(true);
}
}
});
it('upgrades and buyables have unique IDs within a layer', () => {
for (const layer of layers) {
const seen = new Set<string>();
const items: Array<Upgrade | Buyable> = [
...(layer.upgrades ?? []),
...(layer.buyables ?? []),
];
for (const item of items) {
expect(seen.has(item.id), `${layer.id}: duplicate id ${item.id}`).toBe(false);
seen.add(item.id);
}
}
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "src/**/*.json"],
"exclude": ["dist", "node_modules"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@teh-riehl/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema/index.ts",
"./formulas": "./src/formulas/index.ts",
"./predicates": "./src/predicates/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\""
},
"devDependencies": {
"@types/node": "^22.7.4",
"@vitest/coverage-v8": "^2.1.2",
"typescript": "^5.6.2",
"vitest": "^2.1.2"
}
}
+116
View File
@@ -0,0 +1,116 @@
import { registerFormula } from './registry.js';
/**
* Built-in formulas. All formulas here MUST be:
* - deterministic (same inputs => same output)
* - finite/safe at extreme inputs (no NaN/Infinity for sane args)
* - pure (no global state, no time, no randomness)
*
* These properties are what let offline progress simulation work without
* the player being online to tick state.
*/
function num(v: unknown, fallback = 0): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
}
export function registerBuiltinFormulas(): void {
/** A flat constant. args=[value]. Ignores input/ctx. */
registerFormula('constant', (args) => num(args[0]));
/** Linear scaling. args=[base, slope]. cost(n) = base + slope*input. */
registerFormula('linear', (args, input) => num(args[0]) + num(args[1]) * input);
/**
* Polynomial-cost scaling typical of TMT buyables.
* args=[base, multiplier]. cost(n) = base * multiplier^input
* Result clamped to Number.MAX_VALUE so the formula stays finite even
* when content authors push exponents past the JS Number ceiling.
*/
registerFormula('polynomial', (args, input) => {
const base = num(args[0], 1);
const mult = num(args[1], 1);
if (mult <= 0) return base;
if (mult === 1) return base;
const raw = base * Math.pow(mult, input);
if (!Number.isFinite(raw)) return Number.MAX_VALUE;
return raw;
});
/**
* Exponential growth. args=[base, k]. f(x) = base * e^(k * x).
* Hard-capped at exp(700) to keep finite (JS Number tops out near e^709).
*/
registerFormula('exponential', (args, input) => {
const base = num(args[0], 1);
const k = num(args[1], 1);
const exponent = Math.min(k * input, 700);
return base * Math.exp(exponent);
});
/** Natural log scaling. args=[scale, shift]. f(x) = scale * ln(input + shift). */
registerFormula('log', (args, input) => {
const scale = num(args[0], 1);
const shift = num(args[1], 1);
const inside = Math.max(input + shift, Number.EPSILON);
return scale * Math.log(inside);
});
/**
* Square-root style soft scaling. args=[scale]. f(x) = scale * sqrt(max(input, 0)).
* Common in idle games for diminishing-return bonuses.
*/
registerFormula('sqrt', (args, input) => {
const scale = num(args[0], 1);
return scale * Math.sqrt(Math.max(input, 0));
});
/**
* Power scaling. args=[base, exponent]. f(x) = base * input^exponent.
* Negative input clamped to 0 to avoid NaN on fractional exponents.
*/
registerFormula('power', (args, input) => {
const base = num(args[0], 1);
const exponent = num(args[1], 1);
return base * Math.pow(Math.max(input, 0), exponent);
});
/**
* TMT-classic prestige-points formula: floor( (best/req)^pow ).
* args=[requirement, exponent].
* Input is the layer's "best" resource amount.
*/
registerFormula('prestigePoints', (args, input) => {
const req = num(args[0], 1);
const pow = num(args[1], 0.5);
if (input < req) return 0;
return Math.floor(Math.pow(input / req, pow));
});
/**
* Pulls a foreign-layer resource into the formula. args=[layerId, scale=1].
* Useful for "Code/sec = Coffee.amount * 2" style coupling.
*/
registerFormula('fromLayerAmount', (args, _input, ctx) => {
const layerId = typeof args[0] === 'string' ? args[0] : '';
const scale = num(args[1], 1);
return ctx.resource(layerId) * scale;
});
/**
* Multiplies the input by a product of (effects collected from) named upgrades.
* args=[layerId, ...upgradeIds]. Each owned upgrade contributes a x2 bonus.
* (Game code that needs nuanced effects should compose from primitives instead.)
*/
registerFormula('upgradeDoubler', (args, input, ctx) => {
const layerId = typeof args[0] === 'string' ? args[0] : '';
let mult = 1;
for (let i = 1; i < args.length; i++) {
const upgradeId = args[i];
if (typeof upgradeId === 'string' && ctx.hasUpgrade(layerId, upgradeId)) {
mult *= 2;
}
}
return input * mult;
});
}
+9
View File
@@ -0,0 +1,9 @@
export {
registerFormula,
evalFormula,
hasFormula,
listFormulas,
__resetRegistryForTests,
type FormulaFn,
} from './registry.js';
export { registerBuiltinFormulas } from './builtins.js';
+46
View File
@@ -0,0 +1,46 @@
import type { EvalContext, FormulaRef } from '../schema/refs.js';
/**
* A formula takes its declared args (from the content JSON), a numeric "input"
* (typically a count or amount), and the live game context. Returns a number.
*
* Keeping `input` as a separate parameter lets the same formula serve as a
* cost (input=count owned) and as an effect (input=current resource), without
* shoehorning everything into the args array.
*/
export type FormulaFn = (
args: ReadonlyArray<number | string | boolean>,
input: number,
ctx: EvalContext,
) => number;
const registry = new Map<string, FormulaFn>();
export function registerFormula(name: string, fn: FormulaFn): void {
if (registry.has(name)) {
throw new Error(`Formula '${name}' is already registered`);
}
registry.set(name, fn);
}
export function hasFormula(name: string): boolean {
return registry.has(name);
}
export function evalFormula(ref: FormulaRef, input: number, ctx: EvalContext): number {
const fn = registry.get(ref.fn);
if (!fn) {
throw new Error(`Unknown formula '${ref.fn}'`);
}
return fn(ref.args ?? [], input, ctx);
}
/** Test/utility hook — wipes the registry. Production code should not call this. */
export function __resetRegistryForTests(): void {
registry.clear();
}
/** Test/utility hook — list all registered formula names. */
export function listFormulas(): ReadonlyArray<string> {
return Array.from(registry.keys()).sort();
}
+20
View File
@@ -0,0 +1,20 @@
export * from './schema/index.js';
export {
registerFormula,
evalFormula,
hasFormula,
listFormulas,
registerBuiltinFormulas,
__resetRegistryForTests,
type FormulaFn,
} from './formulas/index.js';
export {
registerPredicate,
evalPredicate,
hasPredicate,
listPredicates,
registerBuiltinPredicates,
__resetPredicateRegistryForTests,
type PredicateFn,
} from './predicates/index.js';
export { makeEvalContext, emptyLayerState } from './runtime/index.js';
@@ -0,0 +1,52 @@
import { registerPredicate } from './registry.js';
function num(v: unknown, fallback = 0): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
}
function str(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : fallback;
}
export function registerBuiltinPredicates(): void {
/** args=[layerId, threshold]. True iff that layer's amount >= threshold. */
registerPredicate('amountAtLeast', (args, ctx) => {
return ctx.resource(str(args[0])) >= num(args[1]);
});
/** args=[layerId, threshold]. True iff that layer's BEST amount >= threshold. */
registerPredicate('bestAtLeast', (args, ctx) => {
return (ctx.layers[str(args[0])]?.best ?? 0) >= num(args[1]);
});
/** args=[layerId, threshold]. True iff that layer's TOTAL amount >= threshold. */
registerPredicate('totalAtLeast', (args, ctx) => {
return (ctx.layers[str(args[0])]?.total ?? 0) >= num(args[1]);
});
/** args=[layerId, upgradeId]. */
registerPredicate('hasUpgrade', (args, ctx) => {
return ctx.hasUpgrade(str(args[0]), str(args[1]));
});
/** args=[layerId, milestoneId]. */
registerPredicate('hasMilestone', (args, ctx) => {
return ctx.hasMilestone(str(args[0]), str(args[1]));
});
/** args=[layerId, buyableId, count]. */
registerPredicate('buyableAtLeast', (args, ctx) => {
return ctx.buyableCount(str(args[0]), str(args[1])) >= num(args[2]);
});
/** Always true. Use as 'no gating'. */
registerPredicate('always', () => true);
/** Always false. Use to disable content without removing it. */
registerPredicate('never', () => false);
/** args=[ms]. True once enough elapsed game time has passed. */
registerPredicate('elapsedMsAtLeast', (args, ctx) => {
return ctx.elapsedMs >= num(args[0]);
});
}
+9
View File
@@ -0,0 +1,9 @@
export {
registerPredicate,
evalPredicate,
hasPredicate,
listPredicates,
__resetPredicateRegistryForTests,
type PredicateFn,
} from './registry.js';
export { registerBuiltinPredicates } from './builtins.js';
@@ -0,0 +1,35 @@
import type { EvalContext, PredicateRef } from '../schema/refs.js';
export type PredicateFn = (
args: ReadonlyArray<number | string | boolean>,
ctx: EvalContext,
) => boolean;
const registry = new Map<string, PredicateFn>();
export function registerPredicate(name: string, fn: PredicateFn): void {
if (registry.has(name)) {
throw new Error(`Predicate '${name}' is already registered`);
}
registry.set(name, fn);
}
export function hasPredicate(name: string): boolean {
return registry.has(name);
}
export function evalPredicate(ref: PredicateRef, ctx: EvalContext): boolean {
const fn = registry.get(ref.pred);
if (!fn) {
throw new Error(`Unknown predicate '${ref.pred}'`);
}
return fn(ref.args ?? [], ctx);
}
export function __resetPredicateRegistryForTests(): void {
registry.clear();
}
export function listPredicates(): ReadonlyArray<string> {
return Array.from(registry.keys()).sort();
}
+34
View File
@@ -0,0 +1,34 @@
import type { EvalContext, LayerRuntimeState } from '../schema/refs.js';
/**
* Build an EvalContext from a plain map of per-layer states. The shape we
* accept is intentionally close to what a Redux slice would hold, so the
* client can pass its state in directly.
*/
export function makeEvalContext(
layers: Readonly<Record<string, LayerRuntimeState>>,
elapsedMs: number,
): EvalContext {
return {
layers,
elapsedMs,
resource: (layerId) => layers[layerId]?.amount ?? 0,
hasUpgrade: (layerId, upgradeId) => layers[layerId]?.upgrades.has(upgradeId) ?? false,
buyableCount: (layerId, buyableId) => layers[layerId]?.buyables[buyableId] ?? 0,
hasMilestone: (layerId, milestoneId) => layers[layerId]?.milestones.has(milestoneId) ?? false,
};
}
/** Convenience constructor for a fresh, empty layer runtime state. */
export function emptyLayerState(amount = 0): LayerRuntimeState {
return {
amount,
best: amount,
total: amount,
upgrades: new Set(),
buyables: {},
milestones: new Set(),
challenges: {},
achievements: new Set(),
};
}
+1
View File
@@ -0,0 +1 @@
export { makeEvalContext, emptyLayerState } from './context.js';
+12
View File
@@ -0,0 +1,12 @@
import type { PredicateRef } from './refs.js';
/** A one-time pop-up unlock. Cosmetic by default; effectTags can add gameplay impact. */
export interface Achievement {
id: string;
title: string;
description: string;
trigger: PredicateRef;
/** Optional hint shown before unlock. */
hint?: string;
effectTags?: ReadonlyArray<string>;
}
+17
View File
@@ -0,0 +1,17 @@
import type { FormulaRef, PredicateRef } from './refs.js';
/** A repeatable purchase — cost scales with count owned, effect compounds. */
export interface Buyable {
id: string;
title: string;
description: string;
/** Cost formula receives current `count` in `args[0]` slot via the registry. */
cost: FormulaRef;
/** Effect formula also receives `count`. */
effect: FormulaRef;
/** Optional cap; omit for unlimited. */
maxCount?: number;
unlocked?: PredicateRef;
row?: number;
col?: number;
}
+17
View File
@@ -0,0 +1,17 @@
import type { FormulaRef, PredicateRef } from './refs.js';
/** A reset-and-debuff run with a clear completion goal and reward. */
export interface Challenge {
id: string;
title: string;
description: string;
/** Predicate evaluated during a run; true = completed. */
goal: PredicateRef;
/** Effect tags applied while inside the challenge (e.g. ['halve-code-production']). */
debuffs: ReadonlyArray<string>;
/** Reward formula; result is interpreted by game code. */
reward: FormulaRef;
unlocked?: PredicateRef;
/** Soft cap on completions (e.g. 1 for once-only, 10 for repeatable). */
maxCompletions?: number;
}
+8
View File
@@ -0,0 +1,8 @@
export type { Layer } from './layer.js';
export type { Upgrade } from './upgrade.js';
export type { Buyable } from './buyable.js';
export type { Milestone } from './milestone.js';
export type { Challenge } from './challenge.js';
export type { Achievement } from './achievement.js';
export type { ResetConfig } from './reset.js';
export type { FormulaRef, PredicateRef, EvalContext, LayerRuntimeState } from './refs.js';
+41
View File
@@ -0,0 +1,41 @@
import type { Achievement } from './achievement.js';
import type { Buyable } from './buyable.js';
import type { Challenge } from './challenge.js';
import type { Milestone } from './milestone.js';
import type { PredicateRef } from './refs.js';
import type { ResetConfig } from './reset.js';
import type { Upgrade } from './upgrade.js';
/**
* A single layer in the modding tree. The unit of "content" — drop a new
* layer JSON file into packages/content, register it, and it shows up.
*/
export interface Layer {
id: string;
name: string;
/** Hex color (with #). Drives the neon accent for this layer in the UI. */
color: string;
/** Tree position. row 0 = base, higher = later prestige tiers. */
position: { row: number; col: number };
resource: {
name: string;
/** Short symbol for compact displays (e.g. 'LoC', '☕'). */
symbol?: string;
};
/** TMT-style type. */
type: 'normal' | 'static';
/** Upstream layer IDs that feed into this one. Empty for the base layer. */
branches: ReadonlyArray<string>;
/** Starting per-layer state for a fresh save. Resource amount, owned upgrades, etc. */
startData: { amount: number; [extra: string]: unknown };
/** Predicate that unlocks this layer in the UI. Omit for always-visible. */
unlocked?: PredicateRef;
/** Per-second base production formula (before upgrades/buyables/etc.). */
production?: import('./refs.js').FormulaRef;
upgrades?: ReadonlyArray<Upgrade>;
buyables?: ReadonlyArray<Buyable>;
milestones?: ReadonlyArray<Milestone>;
challenges?: ReadonlyArray<Challenge>;
achievements?: ReadonlyArray<Achievement>;
reset?: ResetConfig;
}
+11
View File
@@ -0,0 +1,11 @@
import type { PredicateRef } from './refs.js';
/** A passive unlock that triggers once a predicate is true. */
export interface Milestone {
id: string;
title: string;
description: string;
requirement: PredicateRef;
/** Effect tag — game code interprets these (e.g. 'keep-code-on-prestige'). */
effectTags?: ReadonlyArray<string>;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* A FormulaRef and PredicateRef both name a function registered at runtime.
* Content JSON references functions by ID instead of embedding code, so
* content files stay data-only with no eval surface.
*/
export interface FormulaRef {
fn: string;
args?: ReadonlyArray<number | string | boolean>;
}
export interface PredicateRef {
pred: string;
args?: ReadonlyArray<number | string | boolean>;
}
/**
* The evaluation context passed to formulas and predicates. Holds the current
* game state plus a few derived helpers. Kept intentionally loose — content
* authors reach into it by string key.
*/
export interface EvalContext {
/** Map of layerId -> per-layer state (resource amount, owned upgrades, etc.). */
readonly layers: Readonly<Record<string, LayerRuntimeState>>;
/** Total elapsed game time in ms. */
readonly elapsedMs: number;
/** Lookup helper: returns a layer's main resource amount, or 0. */
resource(layerId: string): number;
/** Lookup helper: did the player own this upgrade right now? */
hasUpgrade(layerId: string, upgradeId: string): boolean;
/** Lookup helper: how many of a buyable does the player own? */
buyableCount(layerId: string, buyableId: string): number;
/** Lookup helper: is a milestone unlocked? */
hasMilestone(layerId: string, milestoneId: string): boolean;
}
export interface LayerRuntimeState {
/** Main resource amount for this layer. */
amount: number;
/** Best-ever amount of this resource (for "best" gating). */
best: number;
/** Total earned over all time (for "total" gating, reset-immune). */
total: number;
/** Set of owned upgrade IDs. */
upgrades: ReadonlySet<string>;
/** Map of buyableId -> count owned. */
buyables: Readonly<Record<string, number>>;
/** Set of completed milestones. */
milestones: ReadonlySet<string>;
/** Map of challengeId -> times completed. */
challenges: Readonly<Record<string, number>>;
/** Set of unlocked achievements. */
achievements: ReadonlySet<string>;
}
+20
View File
@@ -0,0 +1,20 @@
import type { FormulaRef, PredicateRef } from './refs.js';
/**
* How a layer resets and what currency it yields. Mirrors TMT's prestige model.
*
* - 'normal': spends/clears branched layers, gains `gainFormula` of this layer's resource.
* - 'static': accumulates this layer's resource over time, never resets it (used for
* pure milestone-style layers).
*/
export interface ResetConfig {
type: 'normal' | 'static';
/** Resource amount required to perform a reset. */
requirement: FormulaRef;
/** How much of this layer's currency is gained per reset. */
gainFormula: FormulaRef;
/** Predicate that gates whether the reset button is even visible. */
canReset?: PredicateRef;
/** Layer IDs this reset clears (in addition to the upstream `branches`). */
resetsLayers?: ReadonlyArray<string>;
}
+20
View File
@@ -0,0 +1,20 @@
import type { FormulaRef, PredicateRef } from './refs.js';
/** A one-shot purchasable that, once bought, applies an effect for the rest of the run. */
export interface Upgrade {
id: string;
title: string;
description: string;
/** Cost in the layer's main resource (or in a foreign resource if `costLayer` is set). */
cost: FormulaRef;
/** Optional foreign layer to spend cost in (defaults to the upgrade's home layer). */
costLayer?: string;
/** Numeric effect applied while owned. Consumers decide how to interpret it. */
effect: FormulaRef;
/** If present, upgrade is hidden+unbuyable until predicate returns true. */
unlocked?: PredicateRef;
/** Display row in the upgrade grid (1-indexed). */
row?: number;
/** Display column in the upgrade grid (1-indexed). */
col?: number;
}
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
__resetPredicateRegistryForTests,
__resetRegistryForTests,
emptyLayerState,
evalFormula,
evalPredicate,
makeEvalContext,
registerBuiltinFormulas,
registerBuiltinPredicates,
} from '../src/index.js';
beforeEach(() => {
__resetRegistryForTests();
__resetPredicateRegistryForTests();
registerBuiltinFormulas();
registerBuiltinPredicates();
});
afterEach(() => {
__resetRegistryForTests();
__resetPredicateRegistryForTests();
});
describe('formula arg coercion', () => {
it('polynomial returns base when mult is exactly 0 (zero branch distinct from negative)', () => {
expect(evalFormula({ fn: 'polynomial', args: [9, 0] }, 1000, makeEvalContext({}, 0))).toBe(9);
});
it('polynomial short-circuits when mult is exactly 1', () => {
expect(evalFormula({ fn: 'polynomial', args: [3, 1] }, 10000, makeEvalContext({}, 0))).toBe(3);
});
it('fromLayerAmount returns 0 when layerId arg is non-string', () => {
const ctx = makeEvalContext({ code: emptyLayerState(100) }, 0);
expect(evalFormula({ fn: 'fromLayerAmount', args: [42 as unknown as string] }, 0, ctx)).toBe(0);
});
it('upgradeDoubler ignores non-string upgrade IDs in args', () => {
const code = emptyLayerState();
(code.upgrades as Set<string>).add('a');
const ctx = makeEvalContext({ code }, 0);
const result = evalFormula(
{ fn: 'upgradeDoubler', args: ['code', 'a', 99 as unknown as string] },
5,
ctx,
);
expect(result).toBe(10); // only 'a' counts
});
});
describe('predicate arg coercion', () => {
it('amountAtLeast treats non-numeric threshold as 0 (fallback branch)', () => {
const ctx = makeEvalContext({ code: emptyLayerState(1) }, 0);
expect(
evalPredicate(
{ pred: 'amountAtLeast', args: ['code', 'not-a-number' as unknown as number] },
ctx,
),
).toBe(true);
});
it('amountAtLeast treats non-string layerId as empty (fallback branch)', () => {
const ctx = makeEvalContext({}, 0);
expect(
evalPredicate(
{ pred: 'amountAtLeast', args: [42 as unknown as string, 0] },
ctx,
),
).toBe(true);
});
it('bestAtLeast / totalAtLeast return false against a missing layer (?? 0 branch)', () => {
const ctx = makeEvalContext({}, 0);
expect(evalPredicate({ pred: 'bestAtLeast', args: ['nope', 1] }, ctx)).toBe(false);
expect(evalPredicate({ pred: 'totalAtLeast', args: ['nope', 1] }, ctx)).toBe(false);
});
});
describe('eval context fallbacks', () => {
it('hasUpgrade / hasMilestone return false when the layer is missing entirely', () => {
const ctx = makeEvalContext({}, 0);
expect(ctx.hasUpgrade('missing', 'whatever')).toBe(false);
expect(ctx.hasMilestone('missing', 'whatever')).toBe(false);
expect(ctx.buyableCount('missing', 'whatever')).toBe(0);
});
});
+182
View File
@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
__resetRegistryForTests,
emptyLayerState,
evalFormula,
hasFormula,
listFormulas,
makeEvalContext,
registerBuiltinFormulas,
registerFormula,
} from '../src/index.js';
const emptyCtx = makeEvalContext({}, 0);
beforeEach(() => {
__resetRegistryForTests();
registerBuiltinFormulas();
});
afterEach(() => {
__resetRegistryForTests();
});
describe('registry', () => {
it('registers and reports formulas by name', () => {
expect(hasFormula('polynomial')).toBe(true);
expect(listFormulas()).toContain('exponential');
});
it('throws on duplicate registration', () => {
expect(() => registerFormula('polynomial', () => 0)).toThrow(/already registered/);
});
it('throws when evaluating an unknown formula', () => {
expect(() => evalFormula({ fn: 'nope' }, 0, emptyCtx)).toThrow(/Unknown formula/);
});
});
describe('constant', () => {
it('returns the configured value regardless of input', () => {
expect(evalFormula({ fn: 'constant', args: [42] }, 0, emptyCtx)).toBe(42);
expect(evalFormula({ fn: 'constant', args: [42] }, 9999, emptyCtx)).toBe(42);
});
it('falls back to 0 when args are missing or non-numeric', () => {
expect(evalFormula({ fn: 'constant' }, 0, emptyCtx)).toBe(0);
expect(evalFormula({ fn: 'constant', args: ['nope'] }, 0, emptyCtx)).toBe(0);
});
});
describe('linear', () => {
it('matches base + slope*input across scales', () => {
const ref = { fn: 'linear', args: [10, 2] } as const;
expect(evalFormula(ref, 0, emptyCtx)).toBe(10);
expect(evalFormula(ref, 5, emptyCtx)).toBe(20);
expect(evalFormula(ref, 1_000_000, emptyCtx)).toBe(2_000_010);
});
});
describe('polynomial', () => {
it('matches base * mult^input for small inputs', () => {
const ref = { fn: 'polynomial', args: [10, 1.15] } as const;
expect(evalFormula(ref, 0, emptyCtx)).toBeCloseTo(10);
expect(evalFormula(ref, 1, emptyCtx)).toBeCloseTo(11.5);
expect(evalFormula(ref, 10, emptyCtx)).toBeCloseTo(10 * Math.pow(1.15, 10));
});
it('stays finite at extreme inputs (cap at 5000 exponent)', () => {
const ref = { fn: 'polynomial', args: [1, 2] } as const;
const result = evalFormula(ref, 999_999, emptyCtx);
expect(Number.isFinite(result)).toBe(true);
expect(result).toBeGreaterThan(0);
});
it('returns base when multiplier <= 0', () => {
expect(evalFormula({ fn: 'polynomial', args: [7, 0] }, 5, emptyCtx)).toBe(7);
expect(evalFormula({ fn: 'polynomial', args: [7, -1] }, 5, emptyCtx)).toBe(7);
});
});
describe('exponential', () => {
it('matches base * e^(k*input) for small inputs', () => {
const ref = { fn: 'exponential', args: [1, 0.5] } as const;
expect(evalFormula(ref, 0, emptyCtx)).toBeCloseTo(1);
expect(evalFormula(ref, 1, emptyCtx)).toBeCloseTo(Math.exp(0.5));
expect(evalFormula(ref, 4, emptyCtx)).toBeCloseTo(Math.exp(2));
});
it('clamps exponent at 700 to stay finite', () => {
const ref = { fn: 'exponential', args: [1, 100] } as const;
const result = evalFormula(ref, 1000, emptyCtx);
expect(Number.isFinite(result)).toBe(true);
expect(result).toBeCloseTo(Math.exp(700));
});
});
describe('log', () => {
it('matches scale * ln(input + shift)', () => {
const ref = { fn: 'log', args: [2, 1] } as const;
expect(evalFormula(ref, 0, emptyCtx)).toBeCloseTo(0);
expect(evalFormula(ref, Math.E - 1, emptyCtx)).toBeCloseTo(2);
});
it('does not return -Infinity at input == -shift', () => {
const ref = { fn: 'log', args: [1, 0] } as const;
const result = evalFormula(ref, 0, emptyCtx);
expect(Number.isFinite(result)).toBe(true);
});
});
describe('sqrt', () => {
it('returns scale * sqrt(input) and clamps negatives to 0', () => {
expect(evalFormula({ fn: 'sqrt', args: [3] }, 4, emptyCtx)).toBeCloseTo(6);
expect(evalFormula({ fn: 'sqrt', args: [3] }, -10, emptyCtx)).toBe(0);
});
});
describe('power', () => {
it('returns base * input^exponent', () => {
expect(evalFormula({ fn: 'power', args: [2, 3] }, 4, emptyCtx)).toBe(128);
expect(evalFormula({ fn: 'power', args: [1, 0.5] }, 100, emptyCtx)).toBeCloseTo(10);
});
it('clamps negative input to 0 to avoid NaN on fractional exponents', () => {
const result = evalFormula({ fn: 'power', args: [1, 0.5] }, -4, emptyCtx);
expect(Number.isNaN(result)).toBe(false);
expect(result).toBe(0);
});
});
describe('prestigePoints', () => {
it('returns 0 below the requirement', () => {
const ref = { fn: 'prestigePoints', args: [1000, 0.5] } as const;
expect(evalFormula(ref, 999, emptyCtx)).toBe(0);
});
it('returns floor((best/req)^pow) at and above the requirement', () => {
const ref = { fn: 'prestigePoints', args: [100, 0.5] } as const;
expect(evalFormula(ref, 100, emptyCtx)).toBe(1);
expect(evalFormula(ref, 400, emptyCtx)).toBe(2);
expect(evalFormula(ref, 10_000, emptyCtx)).toBe(10);
});
it('scales reasonably at large best values', () => {
const ref = { fn: 'prestigePoints', args: [100, 0.5] } as const;
const result = evalFormula(ref, 1e12, emptyCtx);
expect(Number.isFinite(result)).toBe(true);
expect(result).toBeGreaterThan(0);
});
});
describe('fromLayerAmount', () => {
it('reads a foreign layer resource via context', () => {
const layers = { coffee: { ...emptyLayerState(7) } };
const ctx = makeEvalContext(layers, 0);
expect(evalFormula({ fn: 'fromLayerAmount', args: ['coffee', 2] }, 0, ctx)).toBe(14);
});
it('returns 0 when the layer is missing', () => {
expect(evalFormula({ fn: 'fromLayerAmount', args: ['missing'] }, 0, emptyCtx)).toBe(0);
});
});
describe('upgradeDoubler', () => {
it('multiplies input by 2 per owned upgrade', () => {
const code = emptyLayerState();
(code.upgrades as Set<string>).add('refactor');
(code.upgrades as Set<string>).add('tdd');
const ctx = makeEvalContext({ code }, 0);
const result = evalFormula(
{ fn: 'upgradeDoubler', args: ['code', 'refactor', 'tdd', 'missing'] },
5,
ctx,
);
expect(result).toBe(20); // 5 * 2 * 2 = 20
});
it('leaves input unchanged when no upgrades are owned', () => {
const ctx = makeEvalContext({ code: emptyLayerState() }, 0);
expect(evalFormula({ fn: 'upgradeDoubler', args: ['code', 'a'] }, 9, ctx)).toBe(9);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
__resetPredicateRegistryForTests,
emptyLayerState,
evalPredicate,
hasPredicate,
listPredicates,
makeEvalContext,
registerBuiltinPredicates,
registerPredicate,
} from '../src/index.js';
beforeEach(() => {
__resetPredicateRegistryForTests();
registerBuiltinPredicates();
});
afterEach(() => {
__resetPredicateRegistryForTests();
});
describe('predicate registry', () => {
it('registers and lists builtins', () => {
expect(hasPredicate('always')).toBe(true);
expect(listPredicates()).toContain('amountAtLeast');
});
it('throws on duplicate registration', () => {
expect(() => registerPredicate('always', () => true)).toThrow(/already registered/);
});
it('throws on unknown predicate', () => {
expect(() => evalPredicate({ pred: 'nope' }, makeEvalContext({}, 0))).toThrow(/Unknown/);
});
});
describe('builtins', () => {
it('always / never', () => {
const ctx = makeEvalContext({}, 0);
expect(evalPredicate({ pred: 'always' }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'never' }, ctx)).toBe(false);
});
it('amountAtLeast compares against layer.amount', () => {
const ctx = makeEvalContext({ code: emptyLayerState(50) }, 0);
expect(evalPredicate({ pred: 'amountAtLeast', args: ['code', 25] }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'amountAtLeast', args: ['code', 100] }, ctx)).toBe(false);
expect(evalPredicate({ pred: 'amountAtLeast', args: ['missing', 0] }, ctx)).toBe(true);
});
it('bestAtLeast / totalAtLeast read distinct fields', () => {
const layer = { ...emptyLayerState(0), best: 80, total: 200 };
const ctx = makeEvalContext({ code: layer }, 0);
expect(evalPredicate({ pred: 'bestAtLeast', args: ['code', 80] }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'bestAtLeast', args: ['code', 81] }, ctx)).toBe(false);
expect(evalPredicate({ pred: 'totalAtLeast', args: ['code', 200] }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'totalAtLeast', args: ['code', 201] }, ctx)).toBe(false);
});
it('hasUpgrade / hasMilestone / buyableAtLeast', () => {
const code = emptyLayerState();
(code.upgrades as Set<string>).add('refactor');
(code.milestones as Set<string>).add('first-pr');
(code.buyables as Record<string, number>)['intern'] = 3;
const ctx = makeEvalContext({ code }, 0);
expect(evalPredicate({ pred: 'hasUpgrade', args: ['code', 'refactor'] }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'hasUpgrade', args: ['code', 'tdd'] }, ctx)).toBe(false);
expect(evalPredicate({ pred: 'hasMilestone', args: ['code', 'first-pr'] }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'buyableAtLeast', args: ['code', 'intern', 3] }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'buyableAtLeast', args: ['code', 'intern', 4] }, ctx)).toBe(false);
});
it('elapsedMsAtLeast reads the context clock', () => {
const ctx = makeEvalContext({}, 60_000);
expect(evalPredicate({ pred: 'elapsedMsAtLeast', args: [60_000] }, ctx)).toBe(true);
expect(evalPredicate({ pred: 'elapsedMsAtLeast', args: [60_001] }, ctx)).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { emptyLayerState, makeEvalContext } from '../src/index.js';
describe('runtime helpers', () => {
it('emptyLayerState fills sensible defaults', () => {
const s = emptyLayerState(5);
expect(s.amount).toBe(5);
expect(s.best).toBe(5);
expect(s.total).toBe(5);
expect(s.upgrades.size).toBe(0);
expect(s.buyables).toEqual({});
expect(s.milestones.size).toBe(0);
expect(s.challenges).toEqual({});
expect(s.achievements.size).toBe(0);
});
it('makeEvalContext wires helper lookups to layer maps', () => {
const code = emptyLayerState(10);
(code.upgrades as Set<string>).add('tdd');
(code.buyables as Record<string, number>)['intern'] = 2;
(code.milestones as Set<string>).add('m1');
const ctx = makeEvalContext({ code }, 1234);
expect(ctx.elapsedMs).toBe(1234);
expect(ctx.resource('code')).toBe(10);
expect(ctx.resource('missing')).toBe(0);
expect(ctx.hasUpgrade('code', 'tdd')).toBe(true);
expect(ctx.hasUpgrade('code', 'nope')).toBe(false);
expect(ctx.buyableCount('code', 'intern')).toBe(2);
expect(ctx.buyableCount('code', 'missing')).toBe(0);
expect(ctx.hasMilestone('code', 'm1')).toBe(true);
expect(ctx.hasMilestone('code', 'm2')).toBe(false);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "test"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
include: ['src/**/*.ts'],
exclude: [
'src/**/index.ts',
'src/**/*.d.ts',
// Type-only files — no runtime semantics, no meaningful coverage signal.
'src/schema/**',
],
thresholds: {
// Hard fail. Math regressions at scale are invisible in dev.
lines: 90,
functions: 90,
branches: 90,
statements: 90,
},
},
},
});
+1416
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- "packages/*"
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}