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:
@@ -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
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
.turbo
|
||||
.next
|
||||
pnpm-lock.yaml
|
||||
packages/server/prisma/migrations
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { layers, layerById } from './layers.js';
|
||||
export { patchNotes, type PatchNote } from './patch-notes.js';
|
||||
@@ -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])),
|
||||
);
|
||||
@@ -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] }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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] }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
registerFormula,
|
||||
evalFormula,
|
||||
hasFormula,
|
||||
listFormulas,
|
||||
__resetRegistryForTests,
|
||||
type FormulaFn,
|
||||
} from './registry.js';
|
||||
export { registerBuiltinFormulas } from './builtins.js';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { makeEvalContext, emptyLayerState } from './context.js';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+1416
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user