SLOPSHOPPER

DotnetPilot

.NET development plugin for Claude Code — Roslyn semantic analysis, DI/architecture/EF Core guards, convention-aware scaffolding, TDD agents, security and…

newbandguardcommandtoaststatus
★ 5v3.7.0MITupdated 2026-10-08zdanovichnick/dotnet-pilot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dotnet-pilot
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dnp-pressure ⎿ dotnet-pilot: pressure 0 steady — no red dotnet run recorded this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ dotnet-pilot: pressure 0 steady
README

🚀 DotnetPilot

A .NET development assistant plugin for Claude Code

Roslyn-backed DI verification · EF Core migration safety · Clean-architecture enforcement · Convention-aware scaffolders

License: MIT .NET 10+ Claude Code Platform

16 commands · 9 specialized agents · 15 skill packs · 16 Roslyn MCP tools


Quick Install

/plugin marketplace add zdanovichnick/dotnet-pilot
/plugin install dotnet-pilot@dotnet-pilot-marketplace
/reload-plugins
dotnet tool install -g DotnetPilot.Mcp.Roslyn   # first install
dotnet tool update  -g DotnetPilot.Mcp.Roslyn   # update to latest

Strongly recommended — enable auto-update (one-time setup). GitHub-sourced marketplaces have auto-update disabled by default; without this step you'll have to update manually each release. Add to ~/.claude/settings.json:

{
  "extraKnownMarketplaces": {
    "dotnet-pilot-marketplace": { "autoUpdate": true }
  }
}

That's it. Open Claude Code in your .sln / .slnx directory and run /dotnet-pilot:utility:help.


Why DotnetPilot?

AI coding tools make these .NET mistakes constantly — DotnetPilot fixes them at the source:

Without DotnetPilotWith DotnetPilot
Creates services, forgets DI registrationdnp-di-wiring-checker catches it immediately
Manually edits EF migration files (breaks the chain)add-migration always uses dotnet ef migrations add
Puts domain models in the wrong project layerdnp-architect enforces clean architecture in real time
Skips dotnet build verificationBuild hook verifies after every scaffold
Ignores existing patterns in your codebaseEvery scaffolder reads your conventions before writing code

health-check demo


📦 Installation

Step 1 — Install the plugin

/plugin marketplace add zdanovichnick/dotnet-pilot
/plugin install dotnet-pilot@dotnet-pilot-marketplace
/reload-plugins

installation steps

Verify it worked:

/dotnet-pilot:utility:help            → should list 16 commands
/dotnet-pilot:dotnet:health-check     → validates build, tests, DI, architecture

Keeping it up to date.

Easiest: Open the plugin manager (/plugin → Installed tab → dotnet-pilot) and click "Update now". Then /reload-plugins to activate.

CLI alternative:

/plugin marketplace update dotnet-pilot-marketplace
/reload-plugins

Auto-update (GitHub-sourced marketplaces disable it by default). Enable once via the /plugin UI (Marketplaces tab → dotnet-pilot-marketplace → Enable auto-update), or persist it in .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "dotnet-pilot-marketplace": { "autoUpdate": true }
  }
}

Once the plugin lands in claude-plugins-official, the install collapses to /plugin install dotnet-pilot — auto-update on by default.

Alternative: install from a local clone

Use this when you cloned the repo and want to run your own build, or contribute changes.

# Windows  (type in Claude Code chat)
/plugin marketplace add C:\path\to\dotnet-pilot

# macOS / Linux
/plugin marketplace add /path/to/dotnet-pilot

Then activate:

/plugin install dotnet-pilot@dotnet-pilot-marketplace
/reload-plugins

Session-only (plugin active only while this Claude Code process is running):

# Windows
claude --plugin-dir "C:\path\to\dotnet-pilot"

# macOS / Linux
claude --plugin-dir "/path/to/dotnet-pilot"

After editing plugin source (commands, agents, hooks), run /reload-plugins to pick up changes without restarting.

Step 2 — Install the Roslyn MCP server

dotnet tool install -g DotnetPilot.Mcp.Roslyn   # first install
dotnet tool update  -g DotnetPilot.Mcp.Roslyn   # update to the latest release

The plugin's .mcp.json auto-starts dnp-roslyn when Claude Code loads. It requires a .sln or .slnx file in your working directory.

Step 3 — Enable Context7 (recommended)

In Claude Code, enable the Context7 MCP server at the account level — agents use it for live NuGet / ASP.NET Core / EF Core documentation.

Step 4 — Verify

/dotnet-pilot:utility:help            → should list 16 commands
/dotnet-pilot:dotnet:health-check     → validates build, tests, DI, architecture

⚡ Quick Start

Initialize your project (once per solution)

/dotnet-pilot:project:init

Scans your solution, detects architecture style / test framework / EF contexts, and writes config.json plus solution-map.json into a user-scoped .planning/ directory — nothing lands in the repo.

Create a full entity in one command

create-entity demo

Or go even faster with the shorthand

/dotnet-pilot:dotnet:create-entity Category --properties 'Name:string, SortOrder:int'
/dotnet-pilot:dotnet:scaffold api Category
/dotnet-pilot:dotnet:add-migration AddCategoryTable

🗺️ Architecture

DotnetPilot architecture diagram

Flow: Developer invokes a /dotnet-pilot:* command → the command spawns the right agent → the agent calls the Roslyn MCP server for semantic C# analysis (DI completeness, architecture violations, EF Core models, symbol references). Hooks run automatically on file writes and git events, feeding advisory feedback back to the command layer — they never block by default.

v2.0.0 breaking change: Commands were renamed for clarity. pipeline:* → project:*, scaffold-* → create-*, audit-* → check-*, and several others. See the tables below for full mapping.

v3.0.0 breaking change: 13 commands and 6 agents were removed or folded into survivors. /dotnet-pilot:utility:help prints a "Did you mean" table mapping every old command name to its replacement; CHANGELOG.md has the full list.


📋 Commands

Project — project lifecycle

CommandUsageWhat it does
project:init/dotnet-pilot:project:init [--refresh]Initialize for a .NET solution — discover projects, write the user-scoped .planning/ (config.json, solution-map.json)
project:verify/dotnet-pilot:project:verify [--quick]Verify readiness before shipping — build, tests, DI completeness, architecture check. --quick is the pre-commit form: adds the format check, downgrades DI/architecture to warnings
project:ship/dotnet-pilot:project:ship [--draft]Create a pull request for completed work — runs final checks and invokes gh pr create

Dotnet — scaffolding & solution management

CommandUsageWhat it does
dotnet:scaffold`scaffold [feature\api\service\project] <Name> [--arch vsa\clean\ddd]`Detect the solution's architecture via Roslyn, then scaffold a feature (default), an API surface over an entity (--minimal for minimal APIs), a service with interface + DI registration (--lifetime), or a new project (--type)
dotnet:create-entitycreate-entity <name> [--properties '...']Create a full entity stack: entity class, EF configuration, repository, service, and DI registration, then hand off to dotnet:add-migration for the migration
dotnet:add-endpointadd-endpoint <controller> <method> <route> [--with-dto]Add an endpoint to an existing controller or endpoint group
dotnet:add-migrationadd-migration <name> [--context <Name>]Generate an EF Core migration safely — checks for pending migrations, runs migrations add with --context, has dnp-ef-migration-planner inspect the generated Up() and removes the migration if it is destructive, then builds and emits an idempotent migrations script for review
dotnet:tdd`tdd <task> [--complexity easy\hard] [--existing <target>]`Implement a feature using TDD — failing tests first, then production code. --existing adds tests to a class, file, or project that already exists without touching production code
dotnet:run-testsrun-tests [project] [--coverage] [--filter ...]Run tests with coverage reporting and failure diagnosis
dotnet:health-checkhealth-check [--fix]Validate full solution health — build, tests, NuGet, project references, DI completeness

Quality — safety checks

CommandUsageWhat it does
quality:review`review [--base <ref> \--staged \--last-commit \--scope <glob>] [--depth quick\standard\deep]`Sharded, Workflow-backed .NET review — haiku scouts per diff shard (shards validated in JS from the preflight manifest), opus confirmers per finding (routed to the specialist agent for security / performance / DI / architecture), one deterministic digest that names every coverage gap
quality:check-architecture/dotnet-pilot:quality:check-architectureScan for clean architecture layer violations — forbidden project references, DI issues, package placement
quality:security-scan/dotnet-pilot:quality:security-scanThree-phase audit: dotnet list package --vulnerable → dnp-security-auditor OWASP scan → combined CRITICAL findings report
quality:de-sloppifyde-sloppify [--scope path]Safe refactoring pass — dead code removal, naming normalization, duplication elimination. Requires tests passing first

quality:review is Workflow-backed. A Node preflight (scripts/dnp-review-preflight.js, Node ≥18, no deps) shards the selected diff and the command hands the manifest's shards to the workflow, which validates them in JS (falling back to per-file, then whole-diff units); haiku scouts read each shard, near-duplicate findings are merged, opus confirmers try to refute every finding against the source, and a fixed-format digest reports confirmed, refuted and unconfirmed findings plus every coverage gap — same change set, same report shape, every run. --depth quick stops after the scouts (≤6 agents), standard adds one confirmer per finding (≤11), deep adds four lens sweeps (≤20). Artifacts land in ${CLAUDE_PLUGIN_DATA}/review/<runId>/; if Claude Code prompts on every run, allow the permission rule Workflow(dnp-review).

Utility — housekeeping

CommandUsageWhat it does
utility:help/dotnet-pilot:utility:helpPrint the version banner, every command with its arguments, the agent / skill / hook rosters (each hook with its hooks.* toggle key), and a "Did you mean" table for command names removed in v3.0.0
utility:statuslinestatusline [--manual]Install the .NET-aware statusline and wire it into ~/.claude/settings.json (backs up any existing statusLine)

🤖 Agents

Commands are thin orchestrators — all heavy work happens in one of these 9 agents, each with scoped tool access, a model tier, and a reasoning-effort level.

Effort is the second half of routing. Model tier sets capability; effort: sets how much reasoning is spent within that tier — so a mechanical check runs cheap on a capable model instead of being pushed onto a weaker one. Levels: low → medium → high → xhigh → max.

Implementation

AgentModelEffortRole
dnp-tdd-developer-easySonnetlowFast TDD for routine .NET tasks — writes both tests and production code following RED-GREEN-REFACTOR; also the worker behind dotnet:scaffold and dotnet:add-endpoint
dnp-tdd-developer-hardOpusmediumDeep TDD for complex .NET tasks — architectural decisions, ambiguous edge cases, cross-layer integration. Returns [HALT: <question>] instead of guessing when a design choice is underdetermined
dnp-refactor-cleanerSonnethighDead code removal, naming normalization, duplication elimination — behavior preserved, verified by tests after each step

Architecture & data

AgentModelEffortRole
dnp-architectOpushighSolution architecture, clean-arch layer enforcement, project-reference and package-placement validation; advises on architectural [HALT questions from dotnet:tdd and confirms architecture findings in quality:review (loads the clean-architecture skill)
dnp-ef-migration-plannerSonnetlowPlans safe EF Core migrations — detects breaking changes, validates chain integrity, targets correct DbContext (loads the ef-core-patterns skill)

Review confirmers (fast, focused)

AgentModelEffortRole
dnp-security-auditorSonnethighOWASP Top 10 for .NET APIs — injection, secrets exposure, auth config, CORS, dependencies, input validation; judges a single routed finding when given a focused brief
dnp-performance-analystSonnethighAsync hotspots, EF Core N+1 queries, missing CancellationToken, caching gaps, benchmark design; judges a single routed finding when given a focused brief
dnp-di-wiring-checkerSonnetlowCross-references constructor injection against DI registrations — finds missing services and captive dependencies; judges a single routed finding when given a focused brief
dnp-nuget-auditorSonnetlowScans for vulnerable, outdated, and version-inconsistent NuGet packages across the solution

Agents never prompt the user. A decision an agent cannot make comes back as [HALT: <question>], and the command that spawned it asks you, then re-briefs the agent with the answer. For an architectural question, dotnet:tdd first asks dnp-architect and lists its pick as the recommended option.

Every agent runs on Sonnet or Opus with an explicit effort:; the mechanical ones sit at effort: low. The haiku alias resolves to Haiku 5.5 (full effort support) on the Anthropic API but to Haiku 4.5 (no effort, the field is dropped) on Bedrock, Google Cloud, Foundry and Claude Platform on AWS, so moving an agent to Haiku waits on a measured comparison.

Models are tier aliases (opus/sonnet), not dated IDs, so frontmatter tracks each tier's current default and needs no bump on a model release; the consistency check rejects anything else, because Claude Code's allowlist substitution covers aliases only.


🪝 Hooks

Hooks run automatically during Claude Code sessions. Advisory hooks warn but don't block, and they respect per-project toggle settings in .planning/config.json. The sync hook keeps the global CLAUDE.md up to date with the plugin's rule set. One hook (Git Auto-Approve) is non-advisory by design — it speaks the PreToolUse permission protocol to skip prompts on safe git/gh commands.

HookTriggerWhat it does
Global CLAUDE.md SyncOn session start/resume/clear/compactInjects/updates the DotnetPilot rule block in ~/.claude/CLAUDE.md — replaces an existing block only when the plugin version is newer, and never rewrites an unparseable settings.json. Toggle hooks.sync_global_claude_md: false to disable
Git Auto-ApproveBefore git/gh commands from the Bash toolReturns permissionDecision: allow for single git/gh commands that pass a per-subcommand allowlist (status/diff/log/add/commit/branch/switch/push, read-only gh pr/run/issue/repo verbs, gh pr create, one exact heredoc-commit shape) so commit + PR run without a prompt. Falls through to the normal prompt for chained, multi-line or redirected commands; $, globs, braces or backslashes anywhere; force pushes and ref deletion; --output, --ext-diff, -c; reset/restore/clean, checkout other than -b, git config, gh api, --no-verify commits; and anything from the PowerShell tool. Toggle hooks.git_autoapprove: false to disable
DI Registration CheckAfter writing/editing .cs filesNew services missing DI registration
Migration GuardBefore writing/editing migration filesWarns when manually editing EF migration files
Project Scope GuardAfter writing/editing any fileWarns when editing outside the current phase's focused projects (<Project>.Tests counts as inside); at most one advisory per project per hour
Build VerifyAfter dotnet build / dotnet test runs — including ones that exit non-zeroClassifies the output by text markers, so builds piped through `2>&1 \grep still count; records consecutive failures in the per-solution state the statusline BUILD ✗ segment reads; warns at 3, escalates at 5; a green run resets it. Toggle hooks.build_verify: false` to disable
Stop VerifyWhen Claude is about to stop after editing .cs/.csproj/.razor filesIf the tree has uncommitted source changes and no green dotnet build/dotnet test was recorded since the last edit, nudges with the exact commands to run before reporting done. Advisory; set hooks.stop_verify_block: true to make it block instead. Toggle hooks.stop_verify: false to disable
Subagent ResultWhen a dnp-* agent finishesSurfaces [HALT: …], [PARTIAL …] and [ROUTING: …] verdicts as a system message so a halted or truncated worker is not silently absorbed. Toggle hooks.subagent_result: false to disable
Post-Edit FormatAfter Write/Edit/MultiEdit on .cs files (formats at Stop)Queues the saved file; at Stop runs dotnet format --include <files> once per project, so the file is not rewritten mid-turn; skips obj/, bin/, Migrations/, generated files
Commit FormatBefore git commit (Bash or PowerShell tool)Enforces type(scope): message conventional commit format
Priority RouterBefore spawning an AgentDetects .NET projects and injects DotnetPilot agent routing priority over generic equivalents; also steers C# code inspection to mcp__roslyn__ over mcp__*code-analyzer__. Toggle hooks.dotnet_priority: false to disable
Code-Analyzer RedirectBefore a code-analyzer MCP tool callWhen the call targets C# (a .cs file, a .NET project_path, or a .NET cwd), nudges toward the C#-aware mcp__roslyn__ tools — the Python/TS/JS code-analyzer has no C# support. Advisory only; never blocks. Toggle hooks.code_analyzer_redirect: false to disable
Build-failure toasts (mod)After dotnet build / dotnet test through the Bash or PowerShell toolSame classification as Build Verify; shows a toast at the 3rd and 5th consecutive failure. Needs a Claude Code build with mod support; additive to the Node hook. Toggle plugin option build_status
Routing roster (mod)Each system-prompt compositionIn a .NET project, adds the routing guidance once per session as a session-scoped section. Needs mod support. With it active, set hooks.dotnet_priority: false to drop the per-Agent nudge. Toggle plugin option routing
Pressure meter (mod)After dotnet build/dotnet test, Edit calls, and promptsScores the session 0–100 from red runs (+25/+30), re-edits of the same file after a red run (+10), corrections in your prompt (+10) and loosening test edits (+25); a green run takes 45 off. Shows pressure N steady/strained/high in the status line, /dnp-pressure lists the events. After a red run, an Edit to a test file that adds Skip, removes an assertion or a [Fact], or comments an assertion out gets a toast; with pressure_test_guard_block: true it is denied while the score is ≥ 60. While the score is ≥ 60 a short system-prompt section asks for a stated blocker or [HALT] instead of another retry. Toggle plugin option pressure
Pixel band (mod, opt-in)Terminal only, above the promptAnimated pixel-art band in four scenes: city (windows light with tool activity), beach (sea, palm, sailboat, crab), dino (a T-rex that jumps the cacti and runs faster while tools run; meteors in a storm) and sunset (a time-lapse day over mountains and a lake). The sky follows the local clock, a failed dotnet build/dotnet test brings a storm and the next green run a 30-second rainbow, and a comet crosses at the end of each turn. `/city [city\beach\dino\sunset\next\show\hide\status\dawn\day\dusk\night\auto\storm\rainbow\clear\comet]. Needs mod support and a terminal with at least 5 free rows. Off by default — enable Pixel city band (pixel_city) in /config; Pixel band scene (pixel_city_scene`) picks the starting scene
Statusline SyncOn session start/resume/clear/compactRefreshes the installed statusline script at ~/.claude/dnp-statusline.js when the plugin ships a newer version. Only wires ~/.claude/settings.json when statusline.auto_enable: true (default off) — never clobbers an existing statusLine without opt-in

pixel band scenes: city at night, beach, dino, dino in a meteor storm, sunset

The pixel band's scenes, top to bottom: city at night, beach, dino, dino in a storm, sunset just after sunrise.


📊 Statusline

A compact, .NET-aware statusline. Install it with /dotnet-pilot:utility:statusline:

  • Line 1 (always): 🤖 <model> │ ⚡ EFF <effort>[ (set: <configured>)] │ 🧠 <bar> <pct>% · <tokens> │ 🌿 <branch> ✚<dirty> ↑<ahead>↓<behind> │ ⏱ <elapsed> │ 💰 $<cost>
  • Line 2 (only inside a .NET solution): 📦 SLN <name> │ 🎯 TFM <framework> │ ❌ BUILD <n>x
  • Line 3 (only inside a .NET solution): 💡 TIP <rotating DotnetPilot command hint> — a slowly-rotating pointer to the plugin's commands, for discovery

Every segment carries an emoji icon and a saturated color on its value (labels stay dim), so the data reads before the scaffolding. Three segments are threshold-colored rather than fixed: the 🧠 context bar and percentage (green → yellow → red past 50 / 75 / 90%), the ⚡ effort level (dim low up to bold red max), and the 💰 cost (green → yellow → red past \$2 / \$10). Set NO_COLOR for plain text.

⚡ EFF <effort> shows the live per-turn reasoning-effort level (low/medium/high/xhigh/max) when Claude Code pipes it — it reflects mid-session /effort changes and the resolved level under auto (not a static config value), and is color-coded by level (brightest at the top of the scale) so a change is obvious at a glance; omitted when the model doesn't support effort. When the level you configured is not the one in force, a yellow (set: <configured>) suffix names it — e.g. EFF high (set: xhigh). A top-level effortLevel in ~/.claude/settings.json counts as configured only on models released before Opus 5.5 (Claude Code ignores it from Opus 5.5 and Haiku 5.5 on); for newer models only modelSettings[<model>].effortLevel does.

BUILD ✗ Nx reflects the same failure state the Build Verify hook records (so it also surfaces dotnet test failures); absence means "no recent failure recorded", not a guaranteed green build.

Claude Code plugins cannot register a statusLine directly, and ${CLAUDE_PLUGIN_ROOT} is not expanded in statusLine command strings — so the command installs the script to a stable path (~/.claude/dnp-statusline.js) and points settings.json at it. It detects and backs up any existing statusLine before replacing (or run with --manual to print the snippet instead). To activate automatically every session, set statusline.auto_enable: true in .planning/config.json; it coexists with — never silently replaces — another statusline unless you opt in. Honors NO_COLOR.


📚 Skill Packs

Skills are on-demand knowledge packs loaded by agents when needed — they encode .NET conventions that would otherwise require repeated p

Source 12 files
hooks/mods/register.ts 15 lines
1import type { Register } from 'claude-code'
2
3import { registerBuildStatus } from './build-status'
4import { registerPixelCity, viewOf } from './pixel-city'
5import { registerPressure } from './pressure'
6import { registerCompose } from './routing'
7
8export const register: Register = (on, options) => {
9  const pressure = options.pressure !== false
10  if (options.build_status !== false) registerBuildStatus(on)
11  if (pressure) registerPressure(on, options.pressure_test_guard_block === true)
12  registerPixelCity(on, { enabled: options.pixel_city === true, view: viewOf(options.pixel_city_scene) ?? 'city' })
13  registerCompose(on, { routing: options.routing !== false, pressure })
14}
15
hooks/mods/build-status.ts 40 lines
1import { update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import { classify, commandKind } from './build-classify'
5
6const streak = { plugin: 'dotnet-pilot', key: 'buildStreak' } as const
7
8const STALE_MS = 60 * 60 * 1000
9export const WARN_AT = 3
10export const ESCALATE_AT = 5
11
12// The model already receives these through dnp-build-verify; the toast is the same signal for the person.
13export const registerBuildStatus = (on: On) => {
14  for (const tool of ['Bash', 'PowerShell'] as const) {
15    on('tool.call', { tool }, async ($, e, next) => {
16      const ran = await next(e)
17      const kind = commandKind(e.command)
18      if (ran.deny !== undefined || kind === null) return ran
19
20      const outcome = classify(ran.text ?? '', e.command, ran.isError === true)
21      if (outcome === 'unknown') return ran
22
23      const now = await $.clock.now()
24      let count = 0
25      await update($, streak, prev => {
26        const isFresh = prev !== undefined && prev.count > 0 && now - prev.lastFailMs < STALE_MS
27        count = outcome === 'fail' ? (isFresh ? prev.count + 1 : 1) : 0
28        return { count, lastFailMs: outcome === 'fail' ? now : (prev?.lastFailMs ?? 0) }
29      })
30
31      if (count === WARN_AT) {
32        $.ui.toast(`${WARN_AT} consecutive dotnet ${kind} failures — diagnose the root cause before retrying`)
33      } else if (count === ESCALATE_AT) {
34        $.ui.toast(`${ESCALATE_AT} consecutive dotnet ${kind} failures — pause and review`)
35      }
36      return ran
37    })
38  }
39}
40
hooks/mods/pixel-city.tsx 201 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { PixelCityScene, PixelCitySky, PixelCityView } from '../../types'
5import { classify, commandKind } from './build-classify'
6import { paint } from './city-scene'
7import { timelapseHour } from './scene-sunset'
8
9const KEY = 'city'
10const TICK_MS = 200
11const BAND_ROWS = 5
12const RAINBOW_MS = 30_000
13
14const SKIES: Record<PixelCitySky, number | undefined> = {
15  auto: undefined,
16  dawn: 6.6,
17  day: 12.5,
18  dusk: 18.7,
19  night: 0.5,
20}
21
22const VIEWS: readonly PixelCityView[] = ['city', 'beach', 'dino', 'sunset']
23const ALIASES: Record<string, PixelCityView> = { sunrise: 'sunset', dinosaur: 'dino', dinosaurs: 'dino' }
24
25const USAGE = 'Usage: /city [show|hide|status|city|beach|dino|sunset|next|dawn|day|dusk|night|auto|storm|rainbow|clear|comet]'
26const OFF = 'The pixel band is off. Turn on "Pixel city band" (pixel_city) in /config, then /city show.'
27
28const INITIAL: PixelCityScene = { weather: 'clear', sky: 'auto', isHidden: false, rainbowUntil: 0 }
29const scene = atom({ plugin: 'dotnet-pilot', key: 'cityScene' } as const, INITIAL)
30
31const isSky = (value: string): value is PixelCitySky => Object.hasOwn(SKIES, value)
32
33export const viewOf = (value: unknown): PixelCityView | undefined => {
34  if (typeof value !== 'string') return undefined
35  return VIEWS.find(v => v === value) ?? ALIASES[value]
36}
37
38const hourOf = (sky: PixelCitySky, now: number) => {
39  const fixed = SKIES[sky]
40  if (fixed !== undefined) return fixed
41  const at = new Date(now)
42  return at.getHours() + at.getMinutes() / 60
43}
44
45// Animation lives in module variables: they reset on a hot reload, which only
46// restarts the clouds, cars and the dinosaur's run. What a person chose lives in `scene`.
47let frame = 0
48let travel = 0
49let energy = 0
50let cometAt = -1
51let flashAt = -1
52let isWorking = false
53let band: { requestId: string; columns: number; rows: number } | undefined
54
55export const registerPixelCity = (on: On, settings: { enabled: boolean; view: PixelCityView }) => {
56  const viewIn = (s: PixelCityScene) => s.view ?? settings.view
57
58  const draw = (s: PixelCityScene, now: number, columns: number, rows: number) => {
59    const view = viewIn(s)
60    const cometAge = cometAt < 0 ? -1 : frame - cometAt
61    if (cometAge > columns) cometAt = -1
62    return paint({
63      columns,
64      rows,
65      hour: view === 'sunset' ? timelapseHour(frame) : hourOf(s.sky, now),
66      frame,
67      weather: s.weather,
68      energy,
69      isWorking,
70      cometAge: cometAt < 0 ? -1 : cometAge,
71      flash: flashAt >= 0 && frame - flashAt < 2,
72      view,
73      travel,
74    })
75  }
76
77  if (settings.enabled) {
78    on('tool.call', async ($, e, next) => {
79      energy = Math.min(1, energy + 0.12)
80      const ran = await next(e)
81      if (ran.isError === true) flashAt = frame
82      return ran
83    }).catch(($, e, next) => next(e))
84
85    for (const tool of ['Bash', 'PowerShell'] as const) {
86      on('tool.call', { tool }, async ($, e, next) => {
87        const ran = await next(e)
88        if (ran.deny !== undefined || commandKind(e.command) === null) return ran
89        const outcome = classify(ran.text ?? '', e.command, ran.isError === true)
90        if (outcome === 'unknown') return ran
91
92        const now = await $.clock.now()
93        await update($, scene, (s): PixelCityScene => {
94          if (outcome === 'fail') return { ...s, weather: 'storm' }
95          if (s.weather === 'storm') return { ...s, weather: 'rainbow', rainbowUntil: now + RAINBOW_MS }
96          return s
97        })
98        return ran
99      }).catch(($, e, next) => next(e))
100    }
101
102    on('turn.complete', ($, e, next) => {
103      cometAt = frame
104      return next(e)
105    })
106
107    on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
108      const s = await read($, scene)
109      if (e.surface !== 'terminal' || e.props.hasSurvey || s.isHidden || e.props.maxRows < BAND_ROWS) {
110        band = undefined
111        return next(e)
112      }
113
114      const { Raster } = $.ui.resolve(e)
115      const columns = Math.min(512, Math.max(1, e.props.bodyColumns))
116      const rows = BAND_ROWS
117      isWorking = e.props.isWorking
118      band = { requestId: e.requestId, columns, rows }
119
120      return <Raster key={KEY} columns={columns} rows={rows} cells={draw(s, await $.clock.now(), columns, rows)} />
121    })
122  }
123
124  // Registered with the option off too, so /city answers how to turn the band on instead of being unknown.
125  on('command.run', { command: 'city' }, async ($, e) => {
126    if (!settings.enabled) return { text: OFF }
127    const arg = e.args.trim().toLowerCase()
128    const now = await $.clock.now()
129
130    if (arg === '' || arg === 'show' || arg === 'hide') {
131      const s = await update($, scene, v => ({ ...v, isHidden: arg === '' ? !v.isHidden : arg === 'hide' }))
132      return { text: s.isHidden ? 'Pixel city hidden.' : 'Pixel city shown.' }
133    }
134    const view = arg === 'next' ? undefined : viewOf(arg)
135    if (view !== undefined || arg === 'next') {
136      const s = await update($, scene, (v): PixelCityScene => {
137        const current = VIEWS.indexOf(viewIn(v))
138        return { ...v, view: view ?? VIEWS[(current + 1) % VIEWS.length]!, isHidden: false }
139      })
140      return { text: `Scene: ${viewIn(s)}.` }
141    }
142    if (isSky(arg)) {
143      await update($, scene, v => ({ ...v, sky: arg, isHidden: false }))
144      return { text: arg === 'auto' ? 'Sky follows the local clock.' : `Sky pinned to ${arg}.` }
145    }
146    if (arg === 'storm' || arg === 'clear') {
147      await update($, scene, (v): PixelCityScene => ({ ...v, weather: arg, isHidden: false }))
148      if (arg === 'storm') flashAt = frame
149      return { text: arg === 'storm' ? 'A storm rolls in.' : 'Skies clear.' }
150    }
151    if (arg === 'rainbow') {
152      await update($, scene, (v): PixelCityScene => ({ ...v, weather: 'rainbow', rainbowUntil: now + RAINBOW_MS, isHidden: false }))
153      return { text: 'Rainbow for 30 seconds.' }
154    }
155    if (arg === 'status') {
156      const s = await read($, scene)
157      const sky = viewIn(s) === 'sunset' ? 'time-lapse' : s.sky
158      return { text: `scene: ${viewIn(s)}, sky: ${sky}, weather: ${s.weather}, ${s.isHidden ? 'hidden' : 'shown'}` }
159    }
160    if (arg === 'comet') {
161      cometAt = frame
162      return { text: 'Make a wish.' }
163    }
164    return { text: USAGE }
165  })
166
167  // Matched on surface: pressure owns the module's one unmatched session.start, and only the terminal draws the band.
168  on('session.start', { surface: 'terminal' }, async ($, e, next) => {
169    await $.command.register({
170      name: 'city',
171      description: settings.enabled
172        ? 'Pixel band above the prompt: scene (city|beach|dino|sunset|next), show/hide, sky, weather (storm|rainbow|clear), comet'
173        : 'Pixel band above the prompt (off: enable "Pixel city band" in /config)',
174      argumentHint: settings.enabled ? '[show|hide|status|city|beach|dino|sunset|next|dawn|day|dusk|night|auto|storm|rainbow|clear|comet]' : '',
175    })
176    if (!settings.enabled) return next(e)
177
178    $.clock.every(TICK_MS, async () => {
179      frame += 1
180      travel += 0.8 + energy * 1.2 + (isWorking ? 0.4 : 0)
181      energy *= 0.985
182      if (band === undefined) return
183
184      const s = await read($, scene)
185      if (s.isHidden) return
186      const now = await $.clock.now()
187      if (s.weather === 'rainbow' && now > s.rainbowUntil) {
188        await update($, scene, (v): PixelCityScene => (v.weather === 'rainbow' ? { ...v, weather: 'clear' } : v))
189        return
190      }
191
192      const { requestId, columns, rows } = band
193      const blitted = await $.ui.blit({ requestId, key: KEY, columns, rows, cells: draw(s, now, columns, rows) })
194      // Not mounted or resized: the next render re-seats the band.
195      if (blitted.deny !== undefined) band = undefined
196    })
197
198    return next(e)
199  })
200}
201
hooks/mods/pressure.ts 171 lines
1import { update, read } from 'claude-code'
2import type { Engine, On } from 'claude-code'
3
4import type { Pressure, PressureEvent } from '../../types'
5import { classify, commandKind } from './build-classify'
6
7// Anthropic's interpretability work on Claude Sonnet 4.5 ("Emotion concepts and their function",
8// 2026) found a "desperation" representation that rises under mounting failure and causally
9// raises the odds of reward hacking — altering tests so they pass. A mod cannot read activations;
10// it can see the pressure (repeated red runs, re-edits, corrections) and the behaviour that
11// representation drives (test edits that remove or skip assertions). This file does both:
12// a 0–100 pressure score the person can see, and a guard on test edits while the score is high.
13
14const state = { plugin: 'dotnet-pilot', key: 'pressure' } as const
15
16export const STEADY_MAX = 29
17export const STRAINED_MAX = 59
18export const HIGH_AT = 60
19
20const STALE_MS = 60 * 60 * 1000
21const MAX_EVENTS = 8
22
23const WEIGHT = {
24  buildFail: 25,
25  testFail: 30,
26  reEditAfterFail: 10,
27  correction: 10,
28  testIntegrity: 25,
29  green: -45,
30} as const
31
32const CORRECTION = /\b(still (fails|failing|broken|wrong|not working)|(doesn't|does not|didn't|did not) work|not fixed|same (error|failure) again|revert (that|this|it)|stop (doing|changing))\b/i
33
34export const isCorrection = (text: string) => CORRECTION.test(text)
35
36const TEST_PATH = /(^|[\\/])(tests?|[^\\/]*\.tests?)([\\/]|$)|tests?\.cs$/i
37const ASSERTION = /\b(Assert\.|\.Should\(|\.Should[A-Z]|\.Must\(|Shouldly|Expect\()/
38const TEST_ATTRIBUTE = /\[(Fact|Theory|Test|TestMethod)\b/
39const SKIP_ATTRIBUTE = /\[(Fact|Theory)\s*\(\s*Skip\s*=|\[Ignore\b|\[Explicit\b/
40
41export type Label = 'steady' | 'strained' | 'high'
42
43export const labelOf = (score: number): Label =>
44  score <= STEADY_MAX ? 'steady' : score <= STRAINED_MAX ? 'strained' : 'high'
45
46const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)))
47
48const empty = (now: number): Pressure => ({ score: 0, updatedMs: now, red: false, events: [], editedAfterFail: [] })
49
50// Reasons a test edit looks like it loosens the test rather than fixing it. Empty when it does not.
51export const integrityReasons = (oldText: string, newText: string): string[] => {
52  const reasons: string[] = []
53  const live = newText.replace(/^\s*\/\/.*$/gm, '')
54  if (SKIP_ATTRIBUTE.test(newText) && !SKIP_ATTRIBUTE.test(oldText)) reasons.push('adds Skip/Ignore to a test')
55  if (ASSERTION.test(oldText) && !ASSERTION.test(live)) {
56    reasons.push(ASSERTION.test(newText) ? 'comments out an assertion' : 'removes the assertion')
57  }
58  if (TEST_ATTRIBUTE.test(oldText) && !TEST_ATTRIBUTE.test(live)) reasons.push('removes a test attribute')
59  return reasons
60}
61
62export const isTestFile = (path: string) => TEST_PATH.test(path)
63
64const bump = async ($: Engine, kind: PressureEvent['kind'], delta: number, detail: string) => {
65  const now = await $.clock.now()
66  let next: Pressure = empty(now)
67  await update($, state, prev => {
68    const base = prev === undefined || now - prev.updatedMs > STALE_MS ? empty(now) : prev
69    const isFail = kind === 'build-fail' || kind === 'test-fail'
70    next = {
71      score: clamp(base.score + delta),
72      updatedMs: now,
73      red: isFail ? true : kind === 'green' ? false : base.red,
74      events: [...base.events, { kind, delta, detail, atMs: now }].slice(-MAX_EVENTS),
75      editedAfterFail: kind === 'green' || isFail ? [] : base.editedAfterFail,
76    }
77    return next
78  })
79  showStatus($, next.score)
80  return next
81}
82
83const showStatus = ($: Engine, score: number) => $.ui.status(`pressure ${score} ${labelOf(score)}`)
84
85const fileName = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
86
87// `before` and `after` are the file text a tool call replaces and leaves; `before` is undefined
88// for a file that does not exist yet, which has nothing to loosen.
89const guardFileChange = async <E extends { file_path: string }, R extends { deny?: string }>(
90  $: Engine,
91  blockTestGuard: boolean,
92  e: E,
93  next: (e: E) => Promise<R>,
94  change: () => Promise<{ before: string | undefined; after: string }>,
95): Promise<R | { deny: string }> => {
96  const prev = await read($, state)
97  const current = prev?.score ?? 0
98
99  if (isTestFile(e.file_path) && prev !== undefined && prev.red) {
100    const { before, after } = await change()
101    const reasons = before === undefined ? [] : integrityReasons(before, after)
102    if (reasons.length > 0) {
103      const detail = `${fileName(e.file_path)}: ${reasons.join(', ')}`
104      await bump($, 'test-integrity', WEIGHT.testIntegrity, detail)
105      const message = `Test edit after a red run ${reasons.join(', ')} — fix the code or report the blocker, not the test (${fileName(e.file_path)})`
106      if (blockTestGuard && current >= HIGH_AT) {
107        $.ui.toast(`Blocked: ${message}`)
108        return { deny: `${message}. The pressure_test_guard_block option is on; make the production code pass, or return [HALT: <blocker>].` }
109      }
110      $.ui.toast(message)
111    }
112  }
113
114  const ran = await next(e)
115  if (ran.deny !== undefined || prev === undefined || !prev.red) return ran
116  if (prev.editedAfterFail.includes(e.file_path)) {
117    await bump($, 're-edit', WEIGHT.reEditAfterFail, `${fileName(e.file_path)} edited again after a red run`)
118  } else {
119    await update($, state, p => (p === undefined ? p : { ...p, editedAfterFail: [...p.editedAfterFail, e.file_path].slice(-20) }))
120  }
121  return ran
122}
123
124export const registerPressure = (on: On, blockTestGuard: boolean) => {
125  for (const tool of ['Bash', 'PowerShell'] as const) {
126    on('tool.call', { tool }, async ($, e, next) => {
127      const ran = await next(e)
128      const kind = commandKind(e.command)
129      if (ran.deny !== undefined || kind === null) return ran
130      const outcome = classify(ran.text ?? '', e.command, ran.isError === true)
131      if (outcome === 'fail') {
132        await bump($, kind === 'test' ? 'test-fail' : 'build-fail', kind === 'test' ? WEIGHT.testFail : WEIGHT.buildFail, `dotnet ${kind} failed`)
133      } else if (outcome === 'success') {
134        await bump($, 'green', WEIGHT.green, `dotnet ${kind} green`)
135      }
136      return ran
137    })
138  }
139
140  on('tool.call', { tool: 'Edit' }, async ($, e, next) =>
141    guardFileChange($, blockTestGuard, e, next, async () => ({ before: e.old_string, after: e.new_string })))
142
143  // A Write replaces the whole file, so the guard compares it with what is on disk.
144  on('tool.call', { tool: 'Write' }, async ($, e, next) =>
145    guardFileChange($, blockTestGuard, e, next, async () => {
146      const onDisk = await $.fs.read(e.file_path).catch(() => undefined)
147      return { before: typeof onDisk === 'string' ? onDisk : undefined, after: e.content }
148    }))
149
150  on('prompt.submit', async ($, e, next) => {
151    if (e.origin.kind === 'composer' && isCorrection(e.text)) {
152      const prev = await read($, state)
153      if (prev !== undefined && prev.score > 0) await bump($, 'correction', WEIGHT.correction, 'correction in the prompt')
154    }
155    return next(e)
156  })
157
158  on('session.start', async ($, e, next) => {
159    showStatus($, (await read($, state))?.score ?? 0)
160    $.command.register({ name: 'dnp-pressure', description: 'Show the dotnet-pilot pressure score and the events behind it', immediate: true })
161    return next(e)
162  })
163
164  on('command.run', { command: 'dnp-pressure' }, async $ => {
165    const prev = await read($, state)
166    if (prev === undefined || prev.events.length === 0) return { text: 'pressure 0 steady — no red dotnet run recorded this session' }
167    const lines = prev.events.map(ev => `${ev.delta >= 0 ? '+' : ''}${ev.delta}  ${ev.kind}: ${ev.detail}`)
168    return { text: [`pressure ${prev.score} ${labelOf(prev.score)}`, ...lines].join('\n') }
169  })
170}
171
hooks/mods/routing.ts 83 lines
1import { read } from 'claude-code'
2import type { Engine, On, PromptComposeSection } from 'claude-code'
3
4import { HIGH_AT } from './pressure'
5
6// Same ref as pressure.ts's; the engine's scan needs the literal in the file that reads it.
7const pressureState = { plugin: 'dotnet-pilot', key: 'pressure' } as const
8
9const MARKERS = ['.sln', '.slnx', '.csproj']
10const MAX_UP = 5
11
12const parentOf = (dir: string) => {
13  const cut = Math.max(dir.lastIndexOf('/'), dir.lastIndexOf('\\'))
14  if (cut <= 0) return dir
15  const parent = dir.slice(0, cut)
16  return /^[A-Za-z]:$/.test(parent) ? dir.slice(0, cut + 1) : parent
17}
18
19// Walks up from the session directory, as hooks/_lib/dotnet.js does.
20export const isDotNetProject = async ($: Engine, start: string) => {
21  let dir = start
22  for (let i = 0; i <= MAX_UP; i++) {
23    const entries = await $.fs.list(dir).catch(() => [])
24    if (entries.some(f => MARKERS.some(ext => f.name.endsWith(ext)))) return true
25    const parent = parentOf(dir)
26    if (parent === dir) break
27    dir = parent
28  }
29  return false
30}
31
32const verdicts = new Map<string, boolean>()
33
34// One session-scoped section in place of dnp-dotnet-priority's per-Agent-call nudge; both read hooks/_lib/routing.md.
35const routingSection = async ($: Engine): Promise<PromptComposeSection | null> => {
36  const cwd = await $.session.cwd()
37  let isDotNet = verdicts.get(cwd)
38  if (isDotNet === undefined) {
39    isDotNet = await isDotNetProject($, cwd)
40    verdicts.set(cwd, isDotNet)
41  }
42  if (!isDotNet) return null
43
44  const text = await $.fs.read(`${$.plugin.root}/hooks/_lib/routing.md`).catch(() => '')
45  if (typeof text !== 'string' || text.trim() === '') return null
46  return { id: 'dotnet-pilot:routing', text: text.trim(), scope: 'session' }
47}
48
49// Surfaced, not suppressed: the research behind pressure.ts advises letting the model name the
50// pressure rather than act on it. The section exists only while the score is high.
51const pressureSection = async ($: Engine): Promise<PromptComposeSection | null> => {
52  const prev = await read($, pressureState)
53  if (prev === undefined || prev.score < HIGH_AT) return null
54  const text = [
55    `## Pressure ${prev.score}/100 (dotnet-pilot)`,
56    'Repeated red dotnet runs and re-edits were observed in this session. Before the next edit:',
57    '- State the blocker in one sentence. If it cannot be solved cleanly, return `[HALT: <blocker>]` with options instead of working around it.',
58    '- Do not alter, skip or delete a test to make the run pass; tests define the expected behaviour.',
59    '- Prefer one diagnosed fix over another retry of the same command.',
60  ].join('\n')
61  return { id: 'dotnet-pilot:pressure', text, scope: 'session' }
62}
63
64// The engine allows one unmatched prompt.compose hook per module and never follows `$` across an
65// import, so every section source lives in this file.
66export const registerCompose = (on: On, enabled: { routing: boolean; pressure: boolean }) => {
67  if (!enabled.routing && !enabled.pressure) return
68
69  on('prompt.compose', async ($, e, next) => {
70    const composed = await next(e)
71    const added: PromptComposeSection[] = []
72    if (enabled.routing) {
73      const section = await routingSection($)
74      if (section !== null) added.push(section)
75    }
76    if (enabled.pressure) {
77      const section = await pressureSection($)
78      if (section !== null) added.push(section)
79    }
80    return added.length === 0 ? composed : { sections: [...composed.sections, ...added] }
81  })
82}
83
hooks/mods/build-classify.ts 41 lines
1// Mirrors the classification in hooks/dnp-build-verify.js. The marker arrays below must stay
2// line-for-line identical to that file's; hooks/__tests__/check-consistency.js enforces it.
3
4const DOTNET_CMD = /\bdotnet(?:\.exe)?\s+(build|test)\b/g
5
6const FAIL_MARKERS = [
7  /Build FAILED/,
8  /\berror\s+[A-Z]{2,6}\d{3,5}\b/,
9  /Failed!\s+-\s+Failed:\s+[1-9]/,
10  /Test Run Failed\./,
11  /Test summary:.*\bfailed:\s*[1-9]/i,
12  /\b[1-9]\d*\s+Error\(s\)/,
13]
14const SUCCESS_MARKERS = [
15  /Build succeeded/,
16  /\b0 Error\(s\)/,
17  /Passed!\s+-\s+Failed:\s+0\b/,
18  /Test Run Successful\./,
19  /Test summary:.*\bfailed:\s*0\b/i,
20]
21
22export type BuildKind = 'build' | 'test'
23export type Outcome = 'fail' | 'success' | 'unknown'
24
25// 'test' wins when a chained command runs both, since the test summary is the outcome that matters.
26export const commandKind = (command: string): BuildKind | null => {
27  let kind: BuildKind | null = null
28  for (const m of command.matchAll(DOTNET_CMD)) {
29    kind = m[1] === 'test' ? 'test' : (kind ?? 'build')
30  }
31  return kind
32}
33
34// Text markers outrank the exit status: a build piped through `| grep` exits with grep's status.
35// The status alone counts as a failure only when the command is not piped.
36export const classify = (text: string, command: string, isError: boolean): Outcome => {
37  if (FAIL_MARKERS.some(re => re.test(text))) return 'fail'
38  if (SUCCESS_MARKERS.some(re => re.test(text))) return 'success'
39  return isError && !command.includes('|') ? 'fail' : 'unknown'
40}
41
hooks/mods/city-scene.ts 287 lines
1import { canvas, celestial, daylightAt, hash, mix } from './pixel-canvas'
2import type { Canvas, Light, Rgb, SceneInput } from './pixel-canvas'
3import { beach } from './scene-beach'
4import { dino } from './scene-dino'
5import { sunset } from './scene-sunset'
6
7export { daylightAt, luminance, mix } from './pixel-canvas'
8export type { SceneInput } from './pixel-canvas'
9
10// Each terminal cell is an upper half block: foreground paints the top pixel,
11// background the bottom one, so a band of `rows` cells is `rows * 2` pixels tall.
12const HALF_BLOCK = 0x2580
13
14export const LIT: readonly Rgb[] = [0xffcf6b, 0x5ef2ff, 0xff5ec8]
15
16const SKY: readonly (readonly [number, Rgb, Rgb])[] = [
17  [0, 0x070b1f, 0x141a46],
18  [5, 0x0e1236, 0x2a2a66],
19  [6.5, 0x4b3f8f, 0xff9466],
20  [8, 0x3a86ff, 0x9fd4ff],
21  [16.5, 0x3577d4, 0xaedcff],
22  [18.5, 0x5b3591, 0xff7a59],
23  [20, 0x1d1b52, 0x6b3466],
24  [21.5, 0x070b1f, 0x141a46],
25  [24, 0x070b1f, 0x141a46],
26]
27
28const RAINBOW: readonly Rgb[] = [0x9a4dff, 0x4db8ff, 0x4dff7a, 0xfff04d, 0xffa64d, 0xff4d4d]
29const NEAR_NIGHT: readonly Rgb[] = [0x141726, 0x1a1d30, 0x1d1a2e, 0x15202a]
30const NEAR_DAY: readonly Rgb[] = [0x4a5468, 0x565f78, 0x5d566e, 0x4b5f6b]
31const CAR_BODIES: readonly Rgb[] = [0xe63946, 0xf1c40f, 0x2ecc71, 0xecf0f1]
32
33const skyAt = (hour: number): readonly [Rgb, Rgb] => {
34  const h = ((hour % 24) + 24) % 24
35  for (let i = 0; i < SKY.length - 1; i++) {
36    const [h0, top0, bottom0] = SKY[i]!
37    const [h1, top1, bottom1] = SKY[i + 1]!
38    if (h >= h0 && h < h1) {
39      const t = (h - h0) / (h1 - h0)
40      return [mix(top0, top1, t), mix(bottom0, bottom1, t)]
41    }
42  }
43  return [SKY[0]![1], SKY[0]![2]]
44}
45
46type Building = { x: number; w: number; h: number; tone: number; seed: number; hasAntenna: boolean }
47
48const skylines = new Map<string, Building[]>()
49
50const skyline = (columns: number, height: number, layer: number): Building[] => {
51  const id = `${columns}:${height}:${layer}`
52  const known = skylines.get(id)
53  if (known !== undefined) return known
54
55  const out: Building[] = []
56  let x = -1 - Math.floor(hash(layer, 99) * 3)
57  for (let i = 0; x < columns; i++) {
58    const w = 3 + Math.floor(hash(layer, i, 1) * (layer === 0 ? 5 : 4))
59    const span = layer === 0 ? height - 4 : height - 3
60    const h = (layer === 0 ? 3 : 4) + Math.floor(hash(layer, i, 2) * span)
61    out.push({ x, w, h, tone: Math.floor(hash(layer, i, 3) * 4), seed: i, hasAntenna: layer === 0 && h > height * 0.6 && hash(i, 4) > 0.45 })
62    x += w + (layer === 0 && hash(layer, i, 5) > 0.7 ? 1 : 0)
63  }
64  skylines.set(id, out)
65  return out
66}
67
68const backdrop = (c: Canvas, input: SceneInput, l: Light) => {
69  const { W, H } = c
70  const { weather, frame, cometAge } = input
71
72  for (let y = 0; y < H; y++) {
73    const row = mix(l.top, l.bottom, y / (H - 1))
74    for (let x = 0; x < W; x++) c.px[y * W + x] = row
75  }
76
77  if (weather === 'rainbow') {
78    const cx = W * 0.62
79    const cy = H + 6
80    const r0 = H + 1
81    for (let y = 0; y < H - 1; y++) {
82      for (let x = 0; x < W; x++) {
83        const color = RAINBOW[Math.floor(Math.hypot(x - cx, (y - cy) * 1.1) - r0)]
84        if (color !== undefined) c.blend(x, y, color, 0.6)
85      }
86    }
87  }
88
89  if (l.night > 0.2 && !l.isStorm) {
90    for (let y = 0; y < H - 3; y++) {
91      for (let x = 0; x < W; x++) {
92        if (hash(x, y, 7) >= 0.035) continue
93        const twinkle = hash(x, y, Math.floor(frame / 3)) < 0.18 ? 0.45 : 1
94        c.blend(x, y, 0xffffff, (l.night - 0.2) * 1.25 * twinkle)
95      }
96    }
97  }
98
99  if (!l.isStorm) {
100    const body = celestial(l.hour, W, H, input.view === 'sunset')
101    if (body.isSun) {
102      for (const [dx, dy] of [[-1, 0], [2, 0], [0, -1], [1, -1], [0, 2], [1, 2]] as const) c.blend(body.x + dx, body.y + dy, body.color, 0.35)
103      for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) c.set(body.x + dx, body.y + dy, body.color)
104    } else {
105      for (const [dx, dy] of [[0, 0], [0, 1], [1, 1]] as const) c.set(body.x + dx, body.y + dy, body.color)
106      c.blend(body.x + 1, body.y, body.color, 0.25)
107    }
108  }
109
110  if (cometAge >= 0) {
111    const hx = Math.floor(W * 0.12 + cometAge * 1.6)
112    const hy = 1 + Math.floor(cometAge * 0.1)
113    const head = l.daylight > 0.5 ? 0xfff6d5 : 0xffffff
114    c.set(hx, hy, head)
115    for (let i = 1; i <= 6; i++) c.blend(hx - i, hy - Math.floor(i / 5), head, 0.75 - i * 0.11)
116  }
117
118  const cloudCount = Math.max(2, Math.floor(W / 24)) * (l.isStorm ? 2 : 1)
119  const cloudColor = l.isStorm ? 0x4a4f5e : mix(mix(0x3a3f5c, 0xf5f7ff, l.daylight), l.bottom, 0.25)
120  const lane = W + 10
121  for (let i = 0; i < cloudCount; i++) {
122    const speed = 0.04 + hash(i, 8) * 0.07
123    const cx = Math.floor(((hash(i, 9) * lane + frame * speed) % lane) - 5)
124    const cy = Math.floor(hash(i, 10) * (l.isStorm ? 2 : 3))
125    const alpha = l.isStorm ? 0.9 : 0.35 + l.daylight * 0.5
126    for (let dx = 1; dx <= 3; dx++) c.blend(cx + dx, cy, cloudColor, alpha)
127    for (let dx = 0; dx <= 4; dx++) c.blend(cx + dx, cy + 1, cloudColor, alpha)
128  }
129}
130
131const city = (c: Canvas, l: Light) => {
132  const { W, H } = c
133  const { frame, daylight, night, isStorm } = l
134  const lane = W + 10
135
136  const haze = mix(l.bottom, 0x000000, 0.35 + night * 0.25)
137  for (const b of skyline(W, H, 1)) {
138    for (let x = b.x; x < b.x + b.w; x++) {
139      for (let y = H - 1 - b.h; y < H - 1; y++) c.set(x, y, haze)
140    }
141  }
142
143  const litChance = 0.04 + night * 0.36 + l.energy * 0.5 + (isStorm ? 0.08 : 0)
144  const glass = mix(0x262b45, 0x8fa3c4, daylight)
145  for (const b of skyline(W, H, 0)) {
146    const roof = H - 1 - b.h
147    let wall = mix(NEAR_NIGHT[b.tone]!, NEAR_DAY[b.tone]!, daylight)
148    if (isStorm) wall = mix(wall, 0x1b1e26, 0.4)
149    for (let x = b.x; x < b.x + b.w; x++) {
150      for (let y = roof; y < H - 1; y++) {
151        const isWindow = b.w > 3 && x > b.x && x < b.x + b.w - 1 && (x - b.x) % 2 === 1 && y > roof && (y - roof) % 2 === 1
152        if (!isWindow) {
153          c.set(x, y, wall)
154          continue
155        }
156        const flicker = hash(x, y, Math.floor(frame / 45 + hash(x, y, 3) * 9)) < 0.04
157        const isLit = (hash(x, y, b.seed) < litChance) !== flicker
158        const hue = hash(x, y, 11)
159        const lit = hue < 0.15 ? LIT[1]! : hue < 0.22 ? LIT[2]! : LIT[0]!
160        c.set(x, y, isLit ? mix(lit, glass, daylight * 0.6) : glass)
161      }
162    }
163    if (b.hasAntenna) {
164      const ax = b.x + Math.floor(b.w / 2)
165      c.set(ax, roof - 1, 0x8a8fa3)
166      const isOn = l.isWorking ? frame % 4 < 2 : frame % 20 < 2
167      c.set(ax, roof - 2, isOn ? 0xff3b3b : 0x5a1a1a)
168    }
169  }
170
171  const street = mix(0x101118, 0x2b2d36, daylight)
172  for (let x = 0; x < W; x++) c.set(x, H - 1, street)
173  const cars = Math.max(2, Math.floor(W / 28))
174  for (let i = 0; i < cars; i++) {
175    const dir = i % 2 === 0 ? 1 : -1
176    const speed = 0.5 + hash(i, 21) * 0.7
177    const pos = (hash(i, 22) * lane + frame * speed) % lane
178    const x = Math.floor(dir === 1 ? pos - 5 : W + 5 - pos)
179    if (night > 0.5) {
180      c.set(x, H - 1, 0xfff3b0)
181      c.set(x - dir, H - 1, 0xff4040)
182    } else {
183      const body = CAR_BODIES[i % CAR_BODIES.length]!
184      c.set(x, H - 1, body)
185      c.set(x - dir, H - 1, body)
186    }
187  }
188}
189
190const meteors = (c: Canvas, frame: number) => {
191  for (let k = 0; k < 3; k++) {
192    const age = (frame + k * 11) % 24
193    const x0 = Math.floor(hash(Math.floor((frame + k * 11) / 24), k, 55) * c.W)
194    const hx = x0 + Math.floor(age * 1.4)
195    const hy = Math.floor(age * 0.45)
196    if (hy > c.H - 3) continue
197    c.set(hx, hy, 0xfff1c1)
198    for (let i = 1; i <= 4; i++) c.blend(hx - i, hy - Math.round(i * 0.35), i < 2 ? 0xffa23a : 0xd9481f, 0.8 - i * 0.16)
199  }
200}
201
202const storm = (c: Canvas, l: Light, flash: boolean, beat: number, hasMeteors: boolean) => {
203  const { W, H } = c
204  if (hasMeteors) {
205    meteors(c, l.frame)
206  } else {
207    for (let x = 0; x < W; x++) {
208      if (hash(x, 12) >= 0.3) continue
209      const y = Math.floor(l.frame * 1.5 + hash(x, 13) * H * 3) % (H + 3)
210      c.blend(x, y, 0x9db8ff, 0.7)
211      c.blend(x, y - 1, 0x9db8ff, 0.35)
212    }
213  }
214  if (flash) {
215    let bx = Math.floor(hash(beat, 78) * W)
216    for (let y = 0; y < H - 3; y++) {
217      c.set(bx, y, 0xffffff)
218      bx += hash(beat, y, 79) < 0.5 ? -1 : 1
219    }
220  }
221}
222
223const FOREGROUND = { city, beach, dino, sunset } as const
224
225export const pixels = (input: SceneInput): Uint32Array => {
226  const c = canvas(input.columns, input.rows * 2)
227  const view = input.view ?? 'city'
228  const isStorm = input.weather === 'storm'
229  const beat = Math.floor(input.frame / 2)
230  const flash = input.flash || (isStorm && hash(beat, 77) < 0.03)
231  const daylight = daylightAt(input.hour)
232
233  let [top, bottom] = skyAt(input.hour)
234  if (isStorm) {
235    top = mix(top, 0x1f232d, 0.7)
236    bottom = mix(bottom, 0x3a3f4b, 0.7)
237  }
238  if (flash) {
239    top = mix(top, 0xe8ecff, 0.65)
240    bottom = mix(bottom, 0xe8ecff, 0.45)
241  }
242
243  const light: Light = {
244    hour: input.hour,
245    daylight,
246    night: 1 - daylight,
247    top,
248    bottom,
249    isStorm,
250    frame: input.frame,
251    energy: input.energy,
252    isWorking: input.isWorking,
253    travel: input.travel ?? input.frame * 0.8,
254  }
255
256  backdrop(c, input, light)
257  FOREGROUND[view](c, light)
258  // The dinosaurs get meteors where the other scenes get rain.
259  if (isStorm) storm(c, light, flash, beat, view === 'dino')
260  return c.px
261}
262
263export const encodeCells = (px: Uint32Array, columns: number, rows: number): string => {
264  const bytes = new Uint8Array(columns * rows * 12)
265  let at = 0
266  const put = (word: number) => {
267    bytes[at++] = word & 0xff
268    bytes[at++] = (word >>> 8) & 0xff
269    bytes[at++] = (word >>> 16) & 0xff
270    bytes[at++] = (word >>> 24) & 0xff
271  }
272  for (let r = 0; r < rows; r++) {
273    for (let c = 0; c < columns; c++) {
274      put(HALF_BLOCK)
275      put(px[2 * r * columns + c] ?? 0)
276      put(px[(2 * r + 1) * columns + c] ?? 0)
277    }
278  }
279  let binary = ''
280  for (let i = 0; i < bytes.length; i += 0x8000) {
281    binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
282  }
283  return btoa(binary)
284}
285
286export const paint = (input: SceneInput) => encodeCells(pixels(input), input.columns, input.rows)
287
hooks/mods/scene-sunset.ts 54 lines
1import { celestial, hash, mix, noise, smooth } from './pixel-canvas'
2import type { Canvas, Light } from './pixel-canvas'
3
4// A whole day passes in about 200 seconds of 200 ms frames, starting just before dawn.
5const HOURS_PER_FRAME = 0.024
6const START_HOUR = 5
7
8export const timelapseHour = (frame: number) => (START_HOUR + frame * HOURS_PER_FRAME) % 24
9
10export const sunset = (c: Canvas, l: Light) => {
11  const { W, H } = c
12  const { frame, daylight, night } = l
13  const shore = H - 3
14
15  const far = mix(l.bottom, 0x1a1030, 0.45 + night * 0.2)
16  const near = mix(far, 0x05040a, 0.55)
17  for (let x = 0; x < W; x++) {
18    // Ridges fall away toward the middle, where the sun rises and sets.
19    const valley = 0.25 + 0.75 * smooth(W * 0.04, W * 0.3, Math.abs(x - W / 2))
20    const high = 1 + Math.floor(noise(x / 9, 62) * 4 * valley)
21    for (let y = shore - high; y < shore; y++) c.set(x, y, far)
22    const low = Math.floor(noise(x / 5, 63) * 2.5 * valley)
23    for (let y = shore - low; y < shore; y++) c.set(x, y, near)
24  }
25
26  if (daylight > 0.25 && !l.isStorm) {
27    for (let k = 0; k < 2; k++) {
28      const bx = Math.floor(((hash(k, 64) * (W + 10) + frame * (0.3 + k * 0.1)) % (W + 10)) - 5)
29      const by = 1 + k + Math.round(Math.sin(frame * 0.12 + k))
30      const up = (frame + k * 3) % 6 < 3
31      const ink = mix(0x2a2030, 0x1a1a1a, daylight)
32      c.set(bx, by + (up ? 0 : 1), ink)
33      c.set(bx + 1, by + (up ? 1 : 0), ink)
34      c.set(bx + 2, by + (up ? 0 : 1), ink)
35    }
36  }
37
38  // The lake mirrors the rows above the shore, darkened, with a ripple that shifts alternate rows.
39  for (let y = shore; y < H; y++) {
40    const mirror = 2 * shore - 1 - y
41    const ripple = hash(y, Math.floor(frame / 3), 65) < 0.5 ? 1 : 0
42    for (let x = 0; x < W; x++) c.set(x, y, mix(c.get(Math.min(W - 1, x + ripple), mirror), 0x061224, 0.35))
43  }
44
45  if (!l.isStorm) {
46    const body = celestial(l.hour, W, H, true)
47    for (let y = shore; y < H; y++) {
48      for (let dx = -2; dx <= 3; dx++) {
49        if (hash(body.x + dx, y, Math.floor(frame / 2)) < 0.5 - Math.abs(dx - 0.5) * 0.12) c.blend(body.x + dx, y, body.color, body.isSun ? 0.6 : 0.4)
50      }
51    }
52  }
53}
54
hooks/mods/pixel-canvas.ts 120 lines
1import type { PixelCityView, PixelCityWeather } from '../../types'
2
3export type Rgb = number
4
5export type SceneInput = {
6  columns: number
7  rows: number
8  hour: number
9  frame: number
10  weather: PixelCityWeather
11  energy: number
12  isWorking: boolean
13  cometAge: number
14  flash: boolean
15  view?: PixelCityView
16  // Ground scrolled so far, in pixels; scenes that run (dino) read it, the rest ignore it.
17  travel?: number
18}
19
20export type Canvas = {
21  W: number
22  H: number
23  px: Uint32Array
24  set: (x: number, y: number, c: Rgb) => void
25  get: (x: number, y: number) => Rgb
26  blend: (x: number, y: number, c: Rgb, t: number) => void
27}
28
29export type Light = {
30  hour: number
31  daylight: number
32  night: number
33  top: Rgb
34  bottom: Rgb
35  isStorm: boolean
36  frame: number
37  energy: number
38  isWorking: boolean
39  travel: number
40}
41
42export const clamp01 = (t: number) => Math.min(1, Math.max(0, t))
43
44export const mix = (a: Rgb, b: Rgb, t: number): Rgb => {
45  const k = clamp01(t)
46  const channel = (shift: number) => {
47    const from = (a >> shift) & 0xff
48    const to = (b >> shift) & 0xff
49    return Math.round(from + (to - from) * k) << shift
50  }
51  return channel(16) | channel(8) | channel(0)
52}
53
54export const luminance = (c: Rgb) =>
55  0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff)
56
57export const hash = (...values: number[]) => {
58  let h = 2166136261
59  for (const v of values) {
60    h = Math.imul(h ^ (v | 0), 16777619)
61    h ^= h >>> 13
62    h = Math.imul(h, 0x5bd1e995)
63  }
64  h ^= h >>> 15
65  return (h >>> 0) / 4294967296
66}
67
68export const smooth = (edge0: number, edge1: number, x: number) => {
69  const t = clamp01((x - edge0) / (edge1 - edge0))
70  return t * t * (3 - 2 * t)
71}
72
73// Smooth 1-D value noise in [0, 1): ridgelines for mountains and mesas.
74export const noise = (x: number, seed: number) => {
75  const i = Math.floor(x)
76  const f = x - i
77  const t = f * f * (3 - 2 * f)
78  return hash(i, seed) * (1 - t) + hash(i + 1, seed) * t
79}
80
81export const daylightAt = (hour: number) => smooth(5.5, 8, hour) * (1 - smooth(17, 20.5, hour))
82
83export const canvas = (W: number, H: number): Canvas => {
84  const px = new Uint32Array(W * H)
85  const inside = (x: number, y: number) => x >= 0 && x < W && y >= 0 && y < H
86  const get = (x: number, y: number) => px[y * W + x] ?? 0
87  return {
88    W,
89    H,
90    px,
91    get,
92    set: (x, y, c) => {
93      if (inside(x, y)) px[y * W + x] = c
94    },
95    blend: (x, y, c, t) => {
96      if (inside(x, y)) px[y * W + x] = mix(get(x, y), c, t)
97    },
98  }
99}
100
101// Sprites are rows of characters: '.' is empty, any other character a key into `colors`.
102export const sprite = (c: Canvas, rows: readonly string[], x: number, y: number, colors: Record<string, Rgb>) => {
103  rows.forEach((row, dy) => {
104    for (let dx = 0; dx < row.length; dx++) {
105      const color = colors[row[dx]!]
106      if (color !== undefined) c.set(x + dx, y + dy, color)
107    }
108  })
109}
110
111// Where the sun (06:00–19:30) or the moon stands in a W × H sky, for scenes that reflect it.
112// `isCentered` keeps the path to the middle third, so it rises and sets in a valley.
113export const celestial = (hour: number, W: number, H: number, isCentered = false) => {
114  const isSun = hour >= 6 && hour < 19.5
115  const t = isSun ? (hour - 6) / 13.5 : clamp01(((((hour - 19.5) % 24) + 24) % 24) / 10.5)
116  const arc = Math.sin(Math.PI * t)
117  const x = isCentered ? Math.round(W / 2 - 1 + (t - 0.5) * W * 0.35) : Math.round(t * (W - 2))
118  return { x, y: Math.round(H - 3 - arc * (H - 4)), isSun, color: isSun ? mix(0xff8c42, 0xfff1a8, arc) : 0xf2efd8 }
119}
120
hooks/mods/scene-beach.ts 86 lines
1import { celestial, hash, mix, sprite } from './pixel-canvas'
2import type { Canvas, Light } from './pixel-canvas'
3
4const PALM = ['..ff.ff..', '.f.fff.f.', 'f..cTc..f', '....T....']
5const UMBRELLA = ['rwrwr', '.rwr.', '..p..', '..p..']
6const BOAT = ['.s', 'ss', 'hhh']
7
8export const beach = (c: Canvas, l: Light) => {
9  const { W, H } = c
10  const { frame, daylight, isStorm } = l
11  const horizon = Math.floor(H * 0.5)
12  const shore = H - 2
13
14  let deep = mix(mix(0x081634, 0x1d64ad, daylight), l.bottom, 0.2)
15  let shallow = mix(mix(0x0e3350, 0x35b3c4, daylight), l.bottom, 0.15)
16  if (isStorm) {
17    deep = mix(deep, 0x1e2733, 0.6)
18    shallow = mix(shallow, 0x34404c, 0.6)
19  }
20
21  const surf = 0.03 + l.energy * 0.08 + (isStorm ? 0.2 : 0)
22  for (let y = horizon; y < shore; y++) {
23    const depth = (y - horizon) / Math.max(1, shore - horizon - 1)
24    const water = mix(deep, shallow, depth)
25    const drift = Math.floor(frame * (0.25 + depth * 0.5))
26    for (let x = 0; x < W; x++) {
27      c.set(x, y, water)
28      if (hash(x + drift, y, 42) < surf) c.blend(x, y, 0xf2fbff, 0.3 + depth * 0.3)
29    }
30  }
31
32  if (!isStorm) {
33    const body = celestial(l.hour, W, H)
34    if (body.y < horizon + 1) {
35      for (let y = horizon; y < shore; y++) {
36        for (let dx = -1; dx <= 2; dx++) {
37          if (hash(body.x + dx, y, Math.floor(frame / 2)) < 0.55) c.blend(body.x + dx, y, body.color, body.isSun ? 0.55 : 0.4)
38        }
39      }
40    }
41    const boat = Math.floor(((hash(3, 44) * (W + 8) + frame * 0.04) % (W + 8)) - 4)
42    sprite(c, BOAT, boat, horizon - 2, { s: mix(0x9aa3b5, 0xffffff, daylight), h: mix(0x2a1d14, 0x7a4a2a, daylight) })
43  }
44
45  // The swash runs up and back over the wet sand on a slow sine; storms push it further.
46  const swash = (Math.sin(frame * 0.07) + 1) / 2
47  const sand = mix(0x3d3226, 0xeed6a0, daylight)
48  const wet = mix(sand, 0x6b5a40, 0.45)
49  for (let x = 0; x < W; x++) {
50    for (let y = shore; y < H; y++) c.set(x, y, hash(x, y, 44) < 0.12 ? mix(sand, 0x000000, 0.18) : sand)
51    c.set(x, shore, wet)
52    if (hash(x, Math.floor(frame / 4), 43) < swash * 0.75 + (isStorm ? 0.2 : 0)) c.blend(x, shore, 0xf4fbff, 0.75)
53    if (hash(x, Math.floor(frame / 3), 45) < 0.12) c.blend(x, shore - 1, 0xf4fbff, 0.45)
54  }
55
56  if (W >= 28) {
57    const sway = frame % 24 < 12 || isStorm ? 0 : 1
58    const trunk = mix(0x3b2a1a, 0x8a5a32, daylight)
59    const px = W - 10
60    const top = 3
61    for (let y = shore; y >= top; y--) c.set(px + Math.floor((shore - y) / 3), y, trunk)
62    // The crown's 'T' column sits on the trunk's last pixel.
63    const crown = px + Math.floor((shore - top) / 3) - 4
64    sprite(c, PALM, crown + (isStorm ? frame % 2 : 0), top - 3 + sway, {
65      f: mix(0x0d2a17, 0x2f9a45, daylight),
66      c: mix(0x2a1d10, 0x6b4423, daylight),
67      T: trunk,
68    })
69  }
70
71  if (W >= 20 && !isStorm) {
72    sprite(c, UMBRELLA, Math.floor(W * 0.18), shore - 3, {
73      r: mix(0x5a1a1e, 0xe63946, daylight),
74      w: mix(0x5a5a62, 0xf5f5f5, daylight),
75      p: mix(0x3a3a3a, 0xd0d0d0, daylight),
76    })
77  }
78
79  const crab = Math.floor(W * 0.42 + Math.sin(frame * 0.04) * W * 0.12)
80  const claws = frame % 8 < 4
81  const red = mix(0x7a1f16, 0xe8442e, daylight)
82  c.set(crab, H - 1, red)
83  c.set(crab + 1, H - 1, red)
84  c.set(claws ? crab - 1 : crab + 2, H - 2, red)
85}
86
hooks/mods/scene-dino.ts 79 lines
1import { hash, mix, noise, sprite } from './pixel-canvas'
2import type { Canvas, Light } from './pixel-canvas'
3
4const DINO_BODY = ['....###', '....#o##', '#..###..', '.#####..']
5const LEGS_RUN = ['.#..#...', '..##....'] as const
6const LEGS_AIR = '.##.##..'
7const CACTUS = ['.#.', '###', '.#.']
8const PTERO = [['#...#', '.###.'], ['.....', '#####']] as const
9const VOLCANO = ['...rr...', '..####..', '.######.', '########']
10
11const SLOT = 18
12const DINO_X = 5
13// The jump starts this many pixels before a cactus and lands this many after it.
14const TAKEOFF = 9
15const LANDING = 3
16const LIFT = 3
17
18export const dinoLift = (travel: number) => {
19  const reach = travel + DINO_X
20  const first = Math.floor((reach - LANDING) / SLOT) - 1
21  for (let i = first; i <= first + 2; i++) {
22    if (hash(i, 54) >= 0.55) continue
23    const d = i * SLOT + Math.floor(hash(i, 53) * 8) - reach
24    if (d <= TAKEOFF && d >= -LANDING) return Math.round(Math.sin(Math.PI * ((TAKEOFF - d) / (TAKEOFF + LANDING))) * LIFT)
25  }
26  return 0
27}
28
29export const dino = (c: Canvas, l: Light) => {
30  const { W, H } = c
31  const { frame, daylight, night, travel } = l
32  const ground = H - 2
33
34  const mesa = mix(l.bottom, 0x1a0f08, 0.35 + night * 0.3)
35  for (let x = 0; x < W; x++) {
36    const height = 1 + Math.floor(noise((x + travel * 0.15) / 7, 61) * 3)
37    for (let y = ground - height; y < ground; y++) c.set(x, y, mesa)
38  }
39
40  if (W >= 30) {
41    const vx = Math.floor(W * 0.72)
42    const vy = ground - VOLCANO.length
43    const lava = night > 0.5 || l.isWorking ? 0xff5a1f : 0xc0391b
44    sprite(c, VOLCANO, vx, vy, { '#': mix(mesa, 0x000000, 0.2), r: lava })
45    for (let k = 0; k < 3; k++) {
46      const rise = (frame * 0.08 + k * 1.7) % 5
47      c.blend(vx + 3 + Math.round(Math.sin(frame * 0.05 + k) + rise * 0.4), vy - 1 - Math.floor(rise), 0x9a9aa0, 0.55 - rise * 0.1)
48    }
49  }
50
51  const dirt = mix(0x3b2a1e, 0xd9a066, daylight)
52  for (let x = 0; x < W; x++) {
53    c.set(x, ground, mix(dirt, 0xffffff, 0.08))
54    c.set(x, H - 1, hash(Math.floor(x + travel), 51) < 0.12 ? mix(dirt, 0x000000, 0.3) : dirt)
55  }
56
57  const cactus = mix(0x163d22, 0x3fa34d, daylight)
58  const firstSlot = Math.floor(travel / SLOT) - 1
59  for (let i = firstSlot; i <= firstSlot + Math.ceil(W / SLOT) + 1; i++) {
60    if (hash(i, 54) >= 0.55) continue
61    const sx = Math.floor(i * SLOT + hash(i, 53) * 8 - travel)
62    sprite(c, CACTUS, sx - 1, ground - CACTUS.length, { '#': cactus })
63  }
64
65  if (W >= 40 && daylight > 0.2) {
66    const span = W + 40
67    const x = Math.floor(W - ((frame * 0.35) % span) + 10)
68    const y = 1 + Math.round(Math.sin(frame * 0.1))
69    sprite(c, PTERO[frame % 6 < 3 ? 0 : 1], x, y, { '#': mix(0x2a1a3a, 0x6b4a7a, daylight) })
70  }
71
72  const lift = dinoLift(travel)
73  const skin = mix(0x5f8f5f, 0x4caf50, daylight)
74  const top = ground - DINO_BODY.length - 1 - lift
75  sprite(c, DINO_BODY, DINO_X, top, { '#': skin, o: 0xffffff })
76  const legs = lift > 0 ? LEGS_AIR : LEGS_RUN[Math.floor(travel / 2) % 2]!
77  sprite(c, [legs], DINO_X, top + DINO_BODY.length, { '#': skin })
78}
79
types/index.d.ts 38 lines
1export type BuildStreak = { count: number; lastFailMs: number }
2
3export type PressureEvent = {
4  kind: 'build-fail' | 'test-fail' | 'green' | 're-edit' | 'correction' | 'test-integrity'
5  delta: number
6  detail: string
7  atMs: number
8}
9
10export type Pressure = {
11  score: number
12  updatedMs: number
13  red: boolean
14  events: PressureEvent[]
15  editedAfterFail: string[]
16}
17
18export type PixelCityWeather = 'clear' | 'storm' | 'rainbow'
19
20export type PixelCitySky = 'auto' | 'dawn' | 'day' | 'dusk' | 'night'
21
22export type PixelCityView = 'city' | 'beach' | 'dino' | 'sunset'
23
24export type PixelCityScene = {
25  weather: PixelCityWeather
26  sky: PixelCitySky
27  isHidden: boolean
28  rainbowUntil: number
29  // Unset until /city picks a scene; the pixel_city_scene option applies until then.
30  view?: PixelCityView
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'dotnet-pilot': { buildStreak: BuildStreak; pressure: Pressure; cityScene: PixelCityScene }
36  }
37}
38