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

A .NET development assistant plugin for Claude Code
Roslyn-backed DI verification · EF Core migration safety · Clean-architecture enforcement · Convention-aware scaffolders
16 commands · 9 specialized agents · 15 skill packs · 16 Roslyn MCP tools
/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.
AI coding tools make these .NET mistakes constantly — DotnetPilot fixes them at the source:
| Without DotnetPilot | With DotnetPilot |
|---|---|
| Creates services, forgets DI registration | dnp-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 layer | dnp-architect enforces clean architecture in real time |
Skips dotnet build verification | Build hook verifies after every scaffold |
| Ignores existing patterns in your codebase | Every scaffolder reads your conventions before writing code |
/plugin marketplace add zdanovichnick/dotnet-pilot
/plugin install dotnet-pilot@dotnet-pilot-marketplace
/reload-plugins
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-pluginsto pick up changes without restarting.
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.
In Claude Code, enable the Context7 MCP server at the account level — agents use it for live NuGet / ASP.NET Core / EF Core documentation.
/dotnet-pilot:utility:help → should list 16 commands
/dotnet-pilot:dotnet:health-check → validates build, tests, DI, architecture
/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.
/dotnet-pilot:dotnet:create-entity Category --properties 'Name:string, SortOrder:int'
/dotnet-pilot:dotnet:scaffold api Category
/dotnet-pilot:dotnet:add-migration AddCategoryTable
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:helpprints a "Did you mean" table mapping every old command name to its replacement;CHANGELOG.mdhas the full list.
| Command | Usage | What 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 |
| Command | Usage | What 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-entity | create-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-endpoint | add-endpoint <controller> <method> <route> [--with-dto] | Add an endpoint to an existing controller or endpoint group | |||||
dotnet:add-migration | add-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-tests | run-tests [project] [--coverage] [--filter ...] | Run tests with coverage reporting and failure diagnosis | |||||
dotnet:health-check | health-check [--fix] | Validate full solution health — build, tests, NuGet, project references, DI completeness |
| Command | Usage | What 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-architecture | Scan for clean architecture layer violations — forbidden project references, DI issues, package placement | |||||
quality:security-scan | /dotnet-pilot:quality:security-scan | Three-phase audit: dotnet list package --vulnerable → dnp-security-auditor OWASP scan → combined CRITICAL findings report | |||||
quality:de-sloppify | de-sloppify [--scope path] | Safe refactoring pass — dead code removal, naming normalization, duplication elimination. Requires tests passing first |
quality:reviewis 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 quickstops after the scouts (≤6 agents),standardadds one confirmer per finding (≤11),deepadds four lens sweeps (≤20). Artifacts land in${CLAUDE_PLUGIN_DATA}/review/<runId>/; if Claude Code prompts on every run, allow the permission ruleWorkflow(dnp-review).
| Command | Usage | What it does |
|---|---|---|
utility:help | /dotnet-pilot:utility:help | Print 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:statusline | statusline [--manual] | Install the .NET-aware statusline and wire it into ~/.claude/settings.json (backs up any existing statusLine) |
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.
| Agent | Model | Effort | Role |
|---|---|---|---|
dnp-tdd-developer-easy | Sonnet | low | Fast 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-hard | Opus | medium | Deep 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-cleaner | Sonnet | high | Dead code removal, naming normalization, duplication elimination — behavior preserved, verified by tests after each step |
| Agent | Model | Effort | Role |
|---|---|---|---|
dnp-architect | Opus | high | Solution 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-planner | Sonnet | low | Plans safe EF Core migrations — detects breaking changes, validates chain integrity, targets correct DbContext (loads the ef-core-patterns skill) |
| Agent | Model | Effort | Role |
|---|---|---|---|
dnp-security-auditor | Sonnet | high | OWASP 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-analyst | Sonnet | high | Async 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-checker | Sonnet | low | Cross-references constructor injection against DI registrations — finds missing services and captive dependencies; judges a single routed finding when given a focused brief |
dnp-nuget-auditor | Sonnet | low | Scans 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:tddfirst asksdnp-architectand lists its pick as the recommended option.Every agent runs on Sonnet or Opus with an explicit
effort:; the mechanical ones sit ateffort: low. Thehaikualias 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 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.
| Hook | Trigger | What it does | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Global CLAUDE.md Sync | On session start/resume/clear/compact | Injects/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-Approve | Before git/gh commands from the Bash tool | Returns 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 Check | After writing/editing .cs files | New services missing DI registration | ||||||||||||||||
| Migration Guard | Before writing/editing migration files | Warns when manually editing EF migration files | ||||||||||||||||
| Project Scope Guard | After writing/editing any file | Warns when editing outside the current phase's focused projects (<Project>.Tests counts as inside); at most one advisory per project per hour | ||||||||||||||||
| Build Verify | After dotnet build / dotnet test runs — including ones that exit non-zero | Classifies 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 Verify | When Claude is about to stop after editing .cs/.csproj/.razor files | If 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 Result | When a dnp-* agent finishes | Surfaces [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 Format | After 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 Format | Before git commit (Bash or PowerShell tool) | Enforces type(scope): message conventional commit format | ||||||||||||||||
| Priority Router | Before spawning an Agent | Detects .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 Redirect | Before a code-analyzer MCP tool call | When 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 tool | Same 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 composition | In 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 prompts | Scores 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 prompt | Animated 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 Sync | On session start/resume/clear/compact | Refreshes 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 |
![]()
The pixel band's scenes, top to bottom: city at night, beach, dino, dino in a storm, sunset just after sunrise.
A compact, .NET-aware statusline. Install it with /dotnet-pilot:utility:statusline:
🤖 <model> │ ⚡ EFF <effort>[ (set: <configured>)] │ 🧠 <bar> <pct>% · <tokens> │ 🌿 <branch> ✚<dirty> ↑<ahead>↓<behind> │ ⏱ <elapsed> │ 💰 $<cost>📦 SLN <name> │ 🎯 TFM <framework> │ ❌ BUILD <n>x💡 TIP <rotating DotnetPilot command hint> — a slowly-rotating pointer to the plugin's commands, for discoveryEvery 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.
Skills are on-demand knowledge packs loaded by agents when needed — they encode .NET conventions that would otherwise require repeated p
hooks/mods/register.ts 15 lines1import 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}
15hooks/mods/build-status.ts 40 lines1import { 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}
40hooks/mods/pixel-city.tsx 201 lines1import { 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}
201hooks/mods/pressure.ts 171 lines1import { 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}
171hooks/mods/routing.ts 83 lines1import { 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}
83hooks/mods/build-classify.ts 41 lines1// 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}
41hooks/mods/city-scene.ts 287 lines1import { 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)
287hooks/mods/scene-sunset.ts 54 lines1import { 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}
54hooks/mods/pixel-canvas.ts 120 lines1import 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}
120hooks/mods/scene-beach.ts 86 lines1import { 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}
86hooks/mods/scene-dino.ts 79 lines1import { 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}
79types/index.d.ts 38 lines1export 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