renovate-bot 35e4af63b3
CI / secrets-scan (push) Successful in 6s
CI / sast (push) Successful in 13s
CI / vuln-scan (push) Successful in 17s
CI / lint (push) Successful in 41s
CI / test (push) Successful in 44s
CI / build-images (push) Successful in 12m35s
CI / image-scan (push) Successful in 52s
CI / push (push) Successful in 40s
Merge pull request 'chore(deps): lock file maintenance' (#26) from renovate/lock-file-maintenance into main
2026-10-26 23:03:05 -07:00
2026-10-11 14:19:47 -07:00
2026-10-26 15:48:53 -07:00
2026-10-12 15:18:23 -07:00
2026-10-26 21:41:47 +00:00

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: playable (version in package.json). Six layers with about 7 hours of play to the current ending (The Great Rewrite) for an active player, full auth + cloud saves with envelope encryption, Tailwind-themed IDE UI. See ProjectPlan.md for the original design plan.


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 24+
  • pnpm 9+ (corepack enable && corepack prepare pnpm@9.12.0 --activate)
  • Docker (for local Postgres) — or a locally-running Postgres if you prefer

Setup

# 1. Install dependencies
pnpm install

# 2. Generate local-dev secrets into .env. The example file ships with
#    REPLACE_ME placeholders; the snippet below picks fresh keys.
cp .env.example .env
node -e "
  const c=require('node:crypto'),fs=require('node:fs'),p='.env';
  let e=fs.readFileSync(p,'utf8')
    .replace(/^ENCRYPTION_MASTER_KEY=.*/m,'ENCRYPTION_MASTER_KEY=\"'+c.randomBytes(32).toString('base64')+'\"')
    .replace(/^EMAIL_HASH_SECRET=.*/m,'EMAIL_HASH_SECRET=\"'+c.randomBytes(32).toString('base64')+'\"')
    .replace(/^JWT_SECRET=.*/m,'JWT_SECRET=\"'+c.randomBytes(64).toString('base64')+'\"')
    .replace(/^JWT_REFRESH_SECRET=.*/m,'JWT_REFRESH_SECRET=\"'+c.randomBytes(64).toString('base64')+'\"');
  fs.writeFileSync(p,e);
"

# 3. Start Postgres
docker compose up -d

# 4. Generate Prisma client (into packages/server/src/generated, gitignored) + apply migrations.
#    Prisma 7: `migrate dev` no longer runs `generate`, so run it yourself after every schema change.
pnpm --filter @teh-riehl/server exec prisma generate
pnpm --filter @teh-riehl/server exec prisma migrate dev

# 5. Start everything (server on :3000, client on :5173)
pnpm dev

Open http://localhost:5173. Sign up, then play. Saves sync to Postgres every 30s; localStorage holds the truth in between.

Useful scripts

Command What it does
pnpm dev Run client + server + watchers in parallel
pnpm build Build all packages
pnpm test Run all unit suites
pnpm test:coverage Tests + ≥90% coverage gate (server's needs Postgres)
pnpm --filter @teh-riehl/server test:integration E2E against real Postgres — requires docker compose up -d
pnpm --filter @teh-riehl/client bench Tick and selector benchmarks (not run by test or CI)
pnpm --filter @teh-riehl/client pacing Pacing sim on the real engine, ~1 min/profile (not in CI)
pnpm typecheck tsc --noEmit across packages
pnpm lint ESLint across packages
pnpm format / pnpm format:check Prettier write / verify
pnpm --filter @teh-riehl/server exec prisma generate Regenerate the Prisma client (after schema changes)
pnpm --filter @teh-riehl/server exec prisma migrate dev Create + apply a migration (does not regenerate the client)

CI (.gitea/workflows/ci.yml) runs test, lint, secrets-scan, vuln-scan, and sast in parallel. The test job enforces coverage thresholds as a hard failure: ≥90% for shared, content, and the client's logic (src/app, src/features, src/lib), and ≥90% lines / ≥85% branches for the server measured across unit and Postgres integration tests together. On the main branch, it then builds + scans both Docker images (tehriehlincremental-server, tehriehlincremental-client), pushes signed (cosign) images to Harbor under harbor.tehriehldeal.com/tehriehlincremental/{server,client}, and back-pushes per-package git tags (server-vX.Y.Z, client-vX.Y.Z). Bump the version in the root package.json and packages/{server,client}/package.json (kept equal) before merging; the push job refuses to overwrite an existing Harbor tag.

Container images

# Build (from repo root — workspace deps need the full context)
docker build -f packages/server/Dockerfile -t teh-riehl-server .
docker build -f packages/client/Dockerfile -t teh-riehl-client .

Runtime config

The client is a static Vite build. Rather than baking the backend URL into the bundle at build time, the nginx entrypoint writes /config.js from env vars on container start, and index.html loads it before the React bundle. So one immutable image targets any backend:

docker run -p 8080:80 \
  -e APP_API_BASE_URL="https://api.example.com/api" \
  -e APP_PUBLIC_URL="https://tehriehl.example.com" \
  teh-riehl-client

APP_PUBLIC_URL is the site's absolute origin (no trailing slash). The entrypoint substitutes it into the Open Graph / Twitter Card meta tags in index.html so chat clients (Slack, Discord, iMessage, Twitter, etc.) can render link previews against fully-qualified URLs. Leave it unset for local Docker spin-ups; previews will fall back to relative paths, which most modern scrapers handle but Facebook does not.

Add a new runtime knob by extending the AppConfig interface in packages/client/src/lib/runtimeConfig.ts, adding the env var to packages/client/docker/40-app-config.sh, and documenting it here.

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. The server never reads game values; it only checks the schemaVersion stamp. A PUT below schema v2 (resource amounts as numbers, from a client older than the big-number release) gets 426 Upgrade Required. The old tab's cloud saves then fail quietly while its local save keeps working, and the cloud save stays intact. Current clients show a "game updated: reload" button if they ever get a 426 from a future floor bump.
  • 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/src/layers/*.json and conform to the TS schema in packages/shared/src/schema/. Adding a new layer = drop a new JSON file in, re-export it from layers.ts, and (if you reference a new formula) register it in packages/shared/src/formulas/builtins.ts. No frontend changes required for content-only additions — the React app reads the layer list at build time.

Numbers are big-number Decimals (break_infinity.js), so values go far past 1.8e308. In content JSON, write any value beyond a double as a string ("1e400"); a JSON number literal like 1e400 becomes Infinity, and the content tests reject it. Saves store resource amounts as canonical Decimal strings ("1.5e+400"), save schema v2.

Day-1 layers (in unlock order):

Layer Resource Role Color
Code Lines of Code Base producer cyan
Commits Commits Prestige (resets Code) magenta
Coffee Cups Multiplier buyables amber
Tickets Closed Tickets Challenge layer (debuffs+goals) green
Releases Release Tags Hard prestige + expansion hook violet
Tech Debt Debt Antagonist mechanic red

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. The full registry (10 formulas, 12 predicates) is in packages/shared/src/formulas/builtins.ts and predicates/builtins.ts.

Bonuses and penalties are content too. An upgrade, a milestone or a challenge penalty carries an effect formula and an affects target (a layer, or * for every producing layer except Tech Debt; production, click, prestige requirement, prestige gain or one buyable) with a scaling: mult, addPerOwnAmount, add, or div for penalties that grow with the owner's amount. The Tech Debt penalty is the compound-interest milestone (÷ (1 + Debt/500)), and the per-ticket Code bonus is definition-of-done. The engine resolves all of them through one index, so production, clicks and prestige can't disagree. Each layer's panel lists its milestones (earned or locked, with the requirement and the live effect).

Challenges list one goal per completion (goals); the card shows the current one and the challenge maxes out after the last. The reward formula receives the number of earlier completions. Multiplier debuffs are penalties on the challenge; only the two structural debuffs (disable-buyables, randomize-upgrade-effects) are engine code.

Achievements live in each layer's JSON (achievements, with game-wide ones in code.json): a trigger predicate, a hint shown while locked, and optionally the same effect + affects as a milestone for a small bonus. scope: "run" achievements are dropped when their layer resets (a prestige or a challenge) and can be earned again; permanent ones never clear. Each layer's save state keeps an achievementsEver list, so the unlock toast shows only the first time. Toasts earned during offline catch-up are skipped, and nothing is awarded until the boot flow has settled on a save (so the save-conflict screen never toasts progress from the placeholder state). The trophy button in the menu bar opens the list.

Keep rules (keep-<layer>-upgrades-on-<prestige>-reset effect tags, e.g. CHANGELOG.md or Stable API) apply to the reset they name and to every challenge reset: entering, finishing or leaving a challenge keeps the upgrades of any layer you own a keep rule for. A Release (the content's reset.confirm) and entering a challenge ask first, listing every layer that resets and whether its upgrades survive; Settings › gameplay.confirm_resets turns that off.

Pacing is checked with pnpm --filter @teh-riehl/client pacing: a greedy player (buys everything affordable, prestiges when the gain rate drops, tries challenges every 5 minutes) driving the real store and engine. Its results and the price changes they led to are in docs/content-plan.md.

Known bugs and planned content are tracked in docs/roadmap.md.

Controls and quality of life

  • Buy ×1 / ×10 / max: the toggle above a layer's buyables sets how many a click buys (up to that many, only what you can afford).
  • Time to afford: an upgrade or buyable you can't afford shows a rough wait next to its price, at the current production rate (clicks not counted).
  • While you were away: after more than a minute offline, a summary lists what each resource gained or lost, the milestones earned and the achievements earned for the first time. Offline progress stops at 24 hours.
  • Stats (menu bar): play time, clicks, achievements, and per layer the current amount, best this run, lifetime total, prestige count and challenges closed. Lifetime totals, prestige counts and clicks are extra save fields; older saves load with lifetime = their current total.
  • Save export / import (Settings): export copies the save as text or downloads a .txt; import checks it first (a save from a newer version or a damaged one is refused and nothing changes), then replaces the current game and saves at once. The import keeps this device's cloud sync position and starts the clock from now, so it can't trigger a save conflict or grant offline time.
  • Number notation (Settings): standard (12.35K, 1.00e400), scientific (1.23e4) or engineering (12.35e3).

Visual system

The design commits to one concept: the game lives inside an IDE. Type pairing is Geist (UI) + JetBrains Mono (numbers and system text). Six layer accents drawn from Tailwind's -400 family so they read as one palette. Diff gutters on cards (+ affordable, ~ owned, − locked). Hairline 1px borders, never CSS shadow blur. One signature element: the >_ blinking prompt, recurring at hero moments.

Tokens live in the @theme block of packages/client/src/styles/globals.css; the per-layer --accent CSS variable is set on data-layer="..." parents, so every component themes itself from one CSS var rather than needing layer-specific class variants.

Phones and desktops

From 768px wide (Tailwind's md) the game uses the desktop shell: a layer rail on the left, the code canvas in the middle, and upgrades, buyables and challenges stacked in a panel on the right. Below 768px it switches to a single column: a compact menu bar (settings and patch notes move there), the resource readout, content tabs (Play, Upgrades, Buyables, Challenges, Milestones; only the ones the layer has), and the layer tabs as a strip along the bottom edge. Play holds the click button and the prestige action, with the prestige details shown inline because touch screens can't hover. Tap targets are at least 44px on phones.

Phones and players with prefers-reduced-motion start with Performance Mode on and scanlines off. Settings you change (display, notation, reset confirmations) are stored per device in localStorage and override those defaults; they are not part of the cloud save.

License

GNU Affero General Public License v3.0 or later (SPDX: AGPL-3.0-or-later).

In plain terms: you may run, study, modify, and redistribute this code — but any fork, including a network-deployed one, must also be released under AGPL-3.0 and made available to its users. If you want to integrate this work into a project that can't comply with those terms, contact the author.

S
Description
No description provided
Readme AGPL-3.0
2.1 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 96.5%
CSS 1.4%
Dockerfile 0.8%
Shell 0.5%
JavaScript 0.4%
Other 0.4%