Compare commits

...

320 commits

Author SHA1 Message Date
Anon
d50e90d860
fix: send dedicated attack packet on 26.1+
Some checks failed
Build MCC and Documents / determine-build (push) Has been cancelled
Build MCC and Documents / fetch-translations (push) Has been cancelled
Build MCC and Documents / create-tag (push) Has been cancelled
Build MCC and Documents / build (linux-arm) (push) Has been cancelled
Build MCC and Documents / build (linux-arm64) (push) Has been cancelled
Build MCC and Documents / build (linux-x64) (push) Has been cancelled
Build MCC and Documents / build (osx-arm64) (push) Has been cancelled
Build MCC and Documents / build (osx-x64) (push) Has been cancelled
Build MCC and Documents / build (win-arm64) (push) Has been cancelled
Build MCC and Documents / build (win-x64) (push) Has been cancelled
Build MCC and Documents / build (win-x86) (push) Has been cancelled
Build MCC and Documents / create-release (push) Has been cancelled
fix: send dedicated attack packet on 26.1+
2026-08-11 19:02:13 +02:00
Anon
196dc54222 fix: send dedicated attack packet on 26.1+ 2026-08-11 18:43:06 +02:00
Anon
a0710fd82f
feat: expose block state properties through MCP
feat: expose block state properties through MCP
2026-08-11 17:57:33 +02:00
Anon
cfb77088b6 feat: add block state data to all palettes 2026-08-11 16:44:16 +02:00
Anon
f1d844afbe feat: expose block state properties through MCP 2026-08-11 14:51:13 +02:00
Anon
1fd4b0244d
[skipci]Merge pull request #3202 from MCCTeam/dependabot/npm_and_yarn/docs/mermaid-11.16.1
chore(deps-dev): bump mermaid from 11.15.0 to 11.16.1 in /docs
2026-08-09 19:55:10 +02:00
Anon
844fa7af2e
[skipci]Merge pull request #3201 from MCCTeam/dependabot/npm_and_yarn/docs/fast-uri-3.1.5
chore(deps): bump fast-uri from 3.1.4 to 3.1.5 in /docs
2026-08-09 19:55:01 +02:00
Anon
9523275eb6
[skipci]Merge pull request #3200 from MCCTeam/dependabot/npm_and_yarn/docs/undici-7.29.0
chore(deps): bump undici from 7.28.0 to 7.29.0 in /docs
2026-08-09 19:54:51 +02:00
Anon
8e11304bf9
fix: Colored Timestamps
It solves the problem of timestamps being colored according to the last message's color.
2026-08-09 19:54:22 +02:00
dependabot[bot]
34e9fd46fd
chore(deps-dev): bump mermaid from 11.15.0 to 11.16.1 in /docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.15.0 to 11.16.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.15.0...mermaid@11.16.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.16.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 04:51:56 +00:00
dependabot[bot]
15dd55d10a
chore(deps): bump fast-uri from 3.1.4 to 3.1.5 in /docs
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 15:33:07 +00:00
dependabot[bot]
8f8e03b1c9
chore(deps): bump undici from 7.28.0 to 7.29.0 in /docs
Bumps [undici](https://github.com/nodejs/undici) from 7.28.0 to 7.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.28.0...v7.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 09:58:03 +00:00
Somersault
c9225aac58 Zaman damgalarının son mesaj rengine göre renklenme sorununu çözer. 2026-08-01 19:26:22 +03:00
Anon
7b100a3149
fix: accept semicolonless script using directives - #3196
fix: accept semicolonless script using directives
2026-07-31 19:17:40 +02:00
Anon
dbce402842 fix: accept semicolonless script using directives
Fixes #3195
2026-07-31 19:12:51 +02:00
Anon
503652a760
fix: decode modern chat type holders correctly 1.21.1+
fix: decode modern chat type holders correctly
2026-07-27 22:25:07 +02:00
Anon
3ae1d746fa fix: decode modern chat type holders correctly 2026-07-27 21:58:01 +02:00
Anon
4a68205f02
[skipci]Merge pull request #3190 from MCCTeam/dependabot/npm_and_yarn/docs/lodash-es-4.18.1
chore(deps): bump lodash-es from 4.17.23 to 4.18.1 in /docs
2026-07-27 19:14:14 +02:00
Anon
c377e52306
[skipci]Merge pull request #3191 from MCCTeam/dependabot/npm_and_yarn/docs/ws-8.21.1
chore(deps): bump ws from 8.20.0 to 8.21.1 in /docs
2026-07-27 19:14:04 +02:00
Anon
7fee87c98f
[skipci]Merge pull request #3192 from MCCTeam/dependabot/npm_and_yarn/docs/postcss-8.5.23
chore(deps): bump postcss from 8.5.14 to 8.5.23 in /docs
2026-07-27 19:13:57 +02:00
dependabot[bot]
07aa032214
chore(deps): bump postcss from 8.5.14 to 8.5.23 in /docs
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.14 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.14...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 16:57:56 +00:00
dependabot[bot]
6b0f1a8f77
chore(deps): bump ws from 8.20.0 to 8.21.1 in /docs
Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.21.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.21.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 16:57:44 +00:00
dependabot[bot]
a780312c19
chore(deps): bump lodash-es from 4.17.23 to 4.18.1 in /docs
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 16:57:40 +00:00
Anon
19f02dfc25
[skipci]chore: update build dependencies
chore: update build dependencies
2026-07-27 18:56:19 +02:00
Anon
dc472c3df6 chore: update build dependencies 2026-07-27 17:58:08 +02:00
Anon
90fda17365
Fix: prevent duplicate AutoRelog retries (#3186)
Fix: prevent duplicate AutoRelog retries (#3186)
2026-07-27 17:34:57 +02:00
Anon
5655e3fc89 Fix restart publication races (#3186)
Prepare offline routing before restart requests become observable, reject stale route owners, and wait for source-client cleanup before executing automatic retries.

Add deterministic publication-order, cleanup-gate, and stale-route regression tests.
2026-07-27 17:26:52 +02:00
Anon
456a548cbc Fix: make AutoRelog retries single-owner (#3186)
Login rejections could schedule and execute multiple restarts for one failure while leaving no usable console route during reconnect delays.

Changes:
- Bind failure and restart work to immutable connection attempts
- Coalesce automatic retries while allowing explicit settings replacement until commit
- Preserve held bots and offline command routing across failed logins
- Add deterministic retry, ownership, and routing regression tests

Fixes #3186
2026-07-27 17:26:52 +02:00
Anon
92212d2b95
Merge pull request #3187 from MCCTeam/fix/configuration-disconnect-reason
fix: preserve queued disconnect reason
2026-07-27 14:12:00 +02:00
Anon
eea631f69e fix: preserve queued disconnect reason 2026-07-27 00:35:18 +02:00
Anon
6e4bca5073
feat: Added first-time guided login + simplifed Yggdrasil configuration
feat: add guided login and authlib URL setup
2026-07-26 18:13:44 +02:00
Anon
630a5fabef feat: add guided login and authlib URL setup 2026-07-26 14:17:59 +02:00
Anon
47bc47786a
fix: preserve account selection across reconnects
fix: preserve account selection across reconnects
2026-07-26 01:31:50 +02:00
Anon
9b90b535d8 fix: isolate queued restart settings 2026-07-26 01:16:07 +02:00
Anon
1686d00f30 fix: use selected account for offline reconnect 2026-07-26 01:08:14 +02:00
Anon
23b83a45f5
[skipci]Merge pull request #3175 from MCCTeam/dependabot/npm_and_yarn/docs/linkify-it-5.0.2
chore(deps): bump linkify-it from 5.0.1 to 5.0.2 in /docs
2026-07-22 21:26:51 +02:00
Anon
97631acc55
[skipci]Merge pull request #3174 from MCCTeam/dependabot/npm_and_yarn/docs/fast-uri-3.1.4
chore(deps): bump fast-uri from 3.1.2 to 3.1.4 in /docs
2026-07-22 21:26:40 +02:00
Anon
03beb51fb7
[skipci]Merge pull request #3176 from MCCTeam/dependabot/npm_and_yarn/docs/webpack-dev-server-5.2.6
chore(deps): bump webpack-dev-server from 5.2.5 to 5.2.6 in /docs
2026-07-22 21:26:31 +02:00
Anon
9d1c7ac4bd
[skipci]Merge pull request #3177 from MCCTeam/dependabot/npm_and_yarn/docs/shell-quote-1.10.0
chore(deps): bump shell-quote from 1.8.4 to 1.10.0 in /docs
2026-07-22 21:26:21 +02:00
Anon
668cbf7e6a
[skipci]Merge pull request #3178 from MCCTeam/dependabot/npm_and_yarn/docs/immutable-5.1.9
chore(deps): bump immutable from 5.1.5 to 5.1.9 in /docs
2026-07-22 21:26:11 +02:00
Anon
bf99df7cde
[skipci]Merge pull request #3179 from MCCTeam/dependabot/npm_and_yarn/docs/svgo-4.0.2
chore(deps): bump svgo from 4.0.1 to 4.0.2 in /docs
2026-07-22 21:26:02 +02:00
Anon
4c6b2b3190
[skipci]chore(deps): bump dompurify from 3.4.11 to 3.4.12 in /docs
chore(deps): bump dompurify from 3.4.11 to 3.4.12 in /docs
2026-07-22 21:25:49 +02:00
dependabot[bot]
fd0c99b2d3
chore(deps): bump dompurify from 3.4.11 to 3.4.12 in /docs
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 18:57:02 +00:00
dependabot[bot]
ba134476af
chore(deps): bump shell-quote from 1.8.4 to 1.10.0 in /docs
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.4 to 1.10.0.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.10.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.10.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 18:57:01 +00:00
dependabot[bot]
1dc1d06f51
chore(deps): bump immutable from 5.1.5 to 5.1.9 in /docs
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.5 to 5.1.9.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.5...v5.1.9)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 18:57:01 +00:00
dependabot[bot]
78da20b4e4
chore(deps): bump svgo from 4.0.1 to 4.0.2 in /docs
Bumps [svgo](https://github.com/svg/svgo) from 4.0.1 to 4.0.2.
- [Release notes](https://github.com/svg/svgo/releases)
- [Commits](https://github.com/svg/svgo/compare/v4.0.1...v4.0.2)

---
updated-dependencies:
- dependency-name: svgo
  dependency-version: 4.0.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 18:57:01 +00:00
dependabot[bot]
a951b5b6c6
chore(deps): bump webpack-dev-server from 5.2.5 to 5.2.6 in /docs
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.5 to 5.2.6.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/v5.2.6/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.5...v5.2.6)

---
updated-dependencies:
- dependency-name: webpack-dev-server
  dependency-version: 5.2.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 18:56:57 +00:00
dependabot[bot]
4a2837d862
chore(deps): bump linkify-it from 5.0.1 to 5.0.2 in /docs
Bumps [linkify-it](https://github.com/markdown-it/linkify-it) from 5.0.1 to 5.0.2.
- [Changelog](https://github.com/markdown-it/linkify-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/linkify-it/compare/5.0.1...5.0.2)

---
updated-dependencies:
- dependency-name: linkify-it
  dependency-version: 5.0.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 18:56:54 +00:00
dependabot[bot]
85672cc0a2
chore(deps): bump fast-uri from 3.1.2 to 3.1.4 in /docs
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 18:56:53 +00:00
Anon
b1200af588
fix: Auto Relog reconnect lifecycle
fix: Auto Relog reconnect lifecycle
2026-07-22 17:54:46 +02:00
Anon
64c1dc55e0 Fix Auto Relog reconnect lifecycle 2026-07-19 14:42:10 +02:00
Anon
c19fdd6634
fix: Exit on failure setting
fix: Exit on failure setting
2026-07-19 11:26:45 +02:00
Anon
fceed9b4d7 chore: remove run-exit-on-failure-test.sh 2026-07-19 11:23:31 +02:00
Anon
25bbe35718 fix: exit on failure instead of prompting 2026-07-17 19:16:00 +02:00
Anon
419c140dc4
[skipci]Merge pull request #3168 from MCCTeam/dependabot/npm_and_yarn/docs/websocket-driver-0.7.5
chore(deps): bump websocket-driver from 0.7.4 to 0.7.5 in /docs
2026-07-17 07:48:46 +02:00
dependabot[bot]
720183955d
chore(deps): bump websocket-driver from 0.7.4 to 0.7.5 in /docs
Bumps [websocket-driver](https://github.com/faye/websocket-driver-node) from 0.7.4 to 0.7.5.
- [Changelog](https://github.com/faye/websocket-driver-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-driver-node/compare/0.7.4...0.7.5)

---
updated-dependencies:
- dependency-name: websocket-driver
  dependency-version: 0.7.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-15 22:09:19 +00:00
Anon
142fb107db
feat: Added Minecraft 26.2 support
Add Minecraft 26.2 support (protocol 776)
2026-07-04 21:58:48 +02:00
Anon
99f5744715
feat: Expose NBT/component data in inventory MCP tools
feat(inventory): expose NBT/component data in inventory MCP tools
2026-07-04 21:53:42 +02:00
Reed Pennock
d1df44aae5 chore: Update physics shapes, minimap data and version table for 26.2
- BlockShapeData.json: add the 28 new sulfur/cinnabar blocks by copying
  shape entries from analogous blocks (stone, stone_slab, stone_stairs,
  stone_brick_wall, pointed_dripstone); state counts verified against the
  26.2 server blocks.json. PrismarineJS minecraft-data has no 26.x data
  yet, so these should be regenerated once it does.
- MinimapBlockColors.json: add new blocks with MapColor values taken from
  the decompiled 26.2 Blocks.java (COLOR_YELLOW / GOLD / COLOR_RED).
  Merged by hand because gen_block_color_map.py cannot parse the new
  BlockItemIds-based registration format yet.
- MinimapEntityCategories.json: add SulfurCube as hostile
  (MobCategory.MONSTER in EntityTypes.java, which now holds entity
  registrations instead of EntityType.java).
- AGENTS.md: add the 26.2 row to the version support table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:43:44 -05:00
Reed Pennock
e4161af158 feat: Wire MC 26.2 (protocol 776) routing and adapt Teams packet parsing
- Add MC_26_2_Version = 776 and bump all upper-bound feature gates
- Route 26.2 to the new item/block/entity palettes and
  StructuredComponentsRegistry262
- Reuse PacketPalette261 (play/config packet IDs are unchanged in 26.2;
  the only registry change is spectate_entity renamed to spectator_action
  at the same ID, which MCC does not send) and EntityMetadataPalette261
  (serializer list is identical)
- Add a 26.2 branch for Set Player Team parsing: field order changed to
  displayName, prefix, suffix, visibility, collision rule, then color as
  Optional<TeamColor> (absent maps to -1) and options byte last
- Register protocol 776 in both supported-protocol lists and the
  version string mappings; bump MCHighestVersion to 26.2

JoinGame gained a trailing onlineMode bool and LoginFinished a trailing
session UUID in 26.2; both sit after every field MCC reads, so parsing
is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:43:31 -05:00
Reed Pennock
22685a720c feat: Add MC 26.2 palettes, registries and new sulfur/cinnabar content
Generated from the 26.2 server data reports (registries.json / blocks.json):
- ItemPalette262 (1537 items, 31 new; IDs shifted by early sulfur/cinnabar inserts)
- Palette262 blocks (1196 blocks, 32366 states, 28 new)
- EntityPalette262 (158 entities, new sulfur_cube)
- StructuredComponentsRegistry262 with new sulfur_cube_content component
  at ID 78 (shifts IDs 78-109 up by one); wire format is one ItemStackTemplate
- New ItemType/Material/EntityType enum entries for the sulfur and
  cinnabar families, MusicDiscBounce, SulfurSpike and SulfurCube

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:43:20 -05:00
Ítalo Seara
f34b8ba989 feat(inventory): expose NBT/component data in inventory MCP tools
Add a nullable `Nbt` field to `MccInventorySnapshotSlot`,
`MccInventorySearchMatch`, and `MccItemStackSnapshot` so that callers
can access the full item metadata beyond just material and count.

For 1.20.6+ servers the field is a map of component name
(e.g. "minecraft:custom_data") to the serialized component object.
For legacy servers it is the raw NBT dictionary. The field is omitted
entirely for vanilla items that carry no extra data, so there is no
noise for plain items like Stone or Dirt.

Implementation:
- `StructuredComponent`: add `ComponentName` property (set by the
  registry during parse) and mark both it and `TypeId` with
  `[JsonIgnore]` so they are excluded from component serialization.
- `StructuredComponentRegistry.ParseComponent`: assign `ComponentName`
  after instantiation.
- `MccGameCommon.BuildNbt`: new helper that serializes components by
  runtime type (fixing the polymorphism issue with System.Text.Json)
  keyed by component name, falling back to the legacy NBT dictionary.
- Snapshot and search query builders updated to call `BuildNbt`.
2026-06-27 14:41:41 -03:00
Anon
95031338bf
feat: Added Data Path utility for C# scripts/ChatBots
Adds a utility class that allows C# scripts to init a configuration folder for them and get it's path.
2026-06-25 23:10:18 +02:00
Anon
d3c87fcd36
[skipci]chores: Updated skills
chores: Updated skills
2026-06-25 23:06:35 +02:00
Anon
3c452dfcc5 Updated skills 2026-06-25 23:02:43 +02:00
Andy
aee35c3f58
Add English translations to DataPath comments
Updated comments to include English translations for clarity.
2026-06-21 10:53:11 +08:00
Anon
c23a71c489
[skipci]Merge pull request #3159 from MCCTeam/dependabot/npm_and_yarn/docs/webpack-dev-server-5.2.5
chore(deps): bump webpack-dev-server from 5.2.4 to 5.2.5 in /docs
2026-06-20 23:05:44 +02:00
dependabot[bot]
800f757804
chore(deps): bump webpack-dev-server from 5.2.4 to 5.2.5 in /docs
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.4 to 5.2.5.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.4...v5.2.5)

---
updated-dependencies:
- dependency-name: webpack-dev-server
  dependency-version: 5.2.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 21:01:34 +00:00
Anon
e0ba34f7fe
[skipci]Merge pull request #3156 from MCCTeam/dependabot/npm_and_yarn/docs/undici-7.28.0
chore(deps): bump undici from 7.24.5 to 7.28.0 in /docs
2026-06-20 23:00:36 +02:00
Anon
9edf53250a
[skipci]Merge pull request #3158 from MCCTeam/dependabot/npm_and_yarn/docs/dompurify-3.4.11
chore(deps): bump dompurify from 3.4.10 to 3.4.11 in /docs
2026-06-20 23:00:26 +02:00
dependabot[bot]
3501ea142f
chore(deps): bump dompurify from 3.4.10 to 3.4.11 in /docs
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.10 to 3.4.11.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.10...3.4.11)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 17:15:53 +00:00
dependabot[bot]
e8afeea3d6
chore(deps): bump undici from 7.24.5 to 7.28.0 in /docs
Bumps [undici](https://github.com/nodejs/undici) from 7.24.5 to 7.28.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.24.5...v7.28.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.28.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 10:57:02 +00:00
Andy
aa1b6f968e
Add DataPath.cs and revert CSharpRunner.cs changes
# Submission Instructions

## Content of this submission

### 1.  Synchronize official CSharpRunner.cs
- Synchronize `CSharpRunner.cs` to the latest official version
- **Removed** extended support for non-standard metadata formats (XML, C# style, JS style)
- Restore to the official standard metadata parsing method

### 2.  Migrate DataPath.cs
- Migrate `DataPath.cs` to the new version of the code repository
- Maintain the original functionality unchanged:
  - `DataPath.Init("ScriptName")` - create a configuration folder
  - `DataPath.Get()` - Gets the path of the configuration folder

## Reason for change
1. **Facilitate official merging**: Retaining custom metadata formats increases the risk of conflicts with the official codebase. Removing them allows for smoother acceptance of official updates
2. **Reduce maintenance costs**: The official CSharpRunner.cs undergoes frequent changes, making the maintenance of custom branches a significant workload
3. **Maintain consistency**: Keep consistent with the official version to avoid compatibility issues caused by format differences

## Scope of impact
- Previously supported non-standard metadata formats (XML, C# style, JS style) will no longer be recognized
- Only supports official standard metadata formats
- The functionality of DataPath.cs remains unaffected, and the configuration management function is still operational and available

## Remarks
- DataPath.cs and CSharpRunner.cs have previously been modified, and this time it is only for migration and synchronization purposes
- AI-assisted participation was involved in the previous modification process
2026-06-19 13:18:04 +08:00
Anon
25a2f5a4a3
bugfix: handle null/empty profile name in ProfileComponent serialization
fix: handle null/empty profile name in ProfileComponent serialization
2026-06-17 11:31:17 +02:00
Anon
85fe6b451b fix: handle null/empty profile name in ProfileComponent serialization
Prevents NullReferenceException crash when receiving player head items
with an empty or absent profile name. The Minecraft protocol permits
empty names in the profile component; the serialization path now
falls back to an empty string instead of throwing.
2026-06-17 11:27:23 +02:00
Anon
72200fc1ef
bugfix: restore full color depth for hex color codes (§#RRGGBB)
bugfix: restore full color depth for hex color codes (§#RRGGBB)
2026-06-17 11:07:59 +02:00
Anon
c05c1f3c02
[skipci]Merge pull request #3148 from MCCTeam/dependabot/npm_and_yarn/docs/launch-editor-2.14.1
chore(deps): bump launch-editor from 2.13.2 to 2.14.1 in /docs
2026-06-17 11:06:04 +02:00
Anon
73377685b4 fix: restore full color depth for hex color codes (§#RRGGBB)
After the dialog system PR (#3143), ResolveHexColors in ClassicConsoleBackend
mapped every §#RRGGBB hex color to the nearest of 16 standard Minecraft colors
via NearestMcColor. This meant all server-sent hex colors were downgraded to
4-bit ANSI regardless of the user's ConsoleColorMode setting.

Replaced the nearest-color lookup with ColorHelper.GetColorEscapeCode, which
generates the right output for each mode: 24-bit ANSI in vt100_24bit mode, 8-bit
in vt100_8bit, 4-bit in vt100_4bit, ConsoleColor in legacy_4bit.

Fixes #3146.
2026-06-17 11:04:06 +02:00
Anon
e6b461e7b2
[skipci]Merge pull request #3149 from MCCTeam/dependabot/npm_and_yarn/docs/markdown-it-14.2.0
chore(deps): bump markdown-it from 14.1.1 to 14.2.0 in /docs
2026-06-17 10:49:23 +02:00
dependabot[bot]
327d3a1c37
chore(deps): bump markdown-it from 14.1.1 to 14.2.0 in /docs
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.1.1 to 14.2.0.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.1.1...14.2.0)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 08:40:45 +00:00
dependabot[bot]
ef1c8fe740
chore(deps): bump launch-editor from 2.13.2 to 2.14.1 in /docs
Bumps [launch-editor](https://github.com/vitejs/launch-editor) from 2.13.2 to 2.14.1.
- [Commits](https://github.com/vitejs/launch-editor/compare/v2.13.2...v2.14.1)

---
updated-dependencies:
- dependency-name: launch-editor
  dependency-version: 2.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 08:40:37 +00:00
Anon
31876d485a
[skipci]Merge pull request #3147 from MCCTeam/dependabot/npm_and_yarn/docs/dompurify-3.4.10
chore(deps): bump dompurify from 3.4.0 to 3.4.10 in /docs
2026-06-17 10:39:13 +02:00
dependabot[bot]
3f71f7afef
chore(deps): bump dompurify from 3.4.0 to 3.4.10 in /docs
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.0 to 3.4.10.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.0...3.4.10)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 05:39:12 +00:00
Anon
aaa4908f7d
feat: Implement the Dialog System (1.21.6+)
feat: Implement the Dialog System (1.21.6+)
2026-06-14 02:31:50 +02:00
Anon
7f2a71f9fe Messaging improvements + Added auto show dialogs in classic console mode 2026-06-14 02:28:13 +02:00
Anon
c0d983272b Cosmetic changes, UX/UI improvements in TUI mode, added colored text support in dialogs 2026-06-14 02:11:04 +02:00
Anon
2aa85cbb17 Added Docs, Added Translations for Dialog Types 2026-06-14 01:25:18 +02:00
Anon
bd29c7fc5c Fixes to Dialogs + Full integration testing script, Fully Tested in CLI mode 2026-06-14 01:05:12 +02:00
Anon
35db711ea4
feat: Add script line and column details to C# compile errors
feat: Add script line and column details to C# compile errors
2026-06-14 00:06:25 +02:00
Anon
3d988c932f Dialog System First iteration, not tested 2026-06-13 23:59:44 +02:00
copilot-swe-agent[bot]
a1b542dad6
Tidy script diagnostic helpers 2026-06-13 21:49:12 +00:00
copilot-swe-agent[bot]
7d7b880a7c
Preserve script compile source locations 2026-06-13 21:41:52 +00:00
Anon
37cf19c175
bugfix: Protocol gap fixes (Entity Effect Duration, Villager Trading on 1.20.6+, Enchanting effected)
bugfix: Protocol gap fixes (Entity Effect Duration, Villager Trading on 1.20.6+, Enchanting effected)
2026-06-12 01:14:28 +02:00
Anon
5e5fa539bb protocol gap fixes 2026-06-12 01:05:19 +02:00
Anon
981a996344
bugfix: Fixed and tested all structured components and issues with Empty packets sent by some servers
bugfix: Fixed and tested all structured components and issues with Empty packets sent by some servers
2026-06-11 22:05:35 +02:00
Anon
98dfde6cc2 Gate empty-packet log under debug, add packet exclusion list
- Change empty packet log from log.Warn to log.Debug so it only
  shows when DebugMessages is enabled
- Add PacketDebugExclusions config (List<string>) to suppress
  specific packet types like KeepAlive, Ping from packet debug output
- Check exclusion in both LogIncomingPacket and LogOutgoingPacket
- Add ConfigComments resource entry for the new setting
2026-06-11 22:03:36 +02:00
Anon
28e845d1ca Add structured component test reference to AGENTS.md and integration testing skill
- AGENTS.md: instruct to never generate config files in repo root
- SKILL.md: add structured components test mode (section 4) with
  usage example and script reference
2026-06-11 21:56:35 +02:00
Anon
de96462659 Fix structured components: audit fixes, codec helpers, and serialize implementations
- Fix 5 class naming typos: Unbrekable->Unbreakable, Blook->Book,
  Omnious->Ominous, FoodComponentComponent->FoodComponent
- Fix IntangibleProjectileComponent: extends EmptyComponent (Unit type)
- Delete dead code JukeBoxPlayableComponent.cs (duplicated)
- Implement proper Serialize() on BlocksAttacksComponent,
  KineticWeaponComponent, PiercingWeaponComponent
- Extract shared SoundEventHolder/HolderSet codec helpers into
  StructuredComponentCodecHelpers
- Add TypedEntityDataComponent/TypedBlockEntityDataComponent base classes
  for 1.21.9+ typed entity data format
- Fix variable name: uses1218AttributeAndEquippableFormats ->
  uses1216AttributeAndEquippableFormats
- Add entity data version gate (1.21.9+) to v1215 registry
- Fix chicken/variant and zombie_nautilus/variant component types
- Fix empty packet hang in Protocol18 ReadNextPacket loop
- Add structured components integration test harness
2026-06-11 21:33:48 +02:00
Anon
02b34a8d13
bugfix: Custom model data decoding for 1.21.4+
bugfix: Custom model data decoding for 1.21.4+
2026-06-11 16:26:28 +02:00
Anon
20756b351e
[skipci]Merge pull request #3136 from MCCTeam/dependabot/npm_and_yarn/docs/shell-quote-1.8.4
chore(deps): bump shell-quote from 1.8.3 to 1.8.4 in /docs
2026-06-11 16:25:01 +02:00
Anon
16637f7eba Fix custom model data decoding for 1.21.4 2026-06-11 16:20:42 +02:00
dependabot[bot]
d38afe8e1e
chore(deps): bump shell-quote from 1.8.3 to 1.8.4 in /docs
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-10 17:32:15 +00:00
Anon
883ed12ccd
feat: Patch the client to support Hypixel
feat: Patch the client to support Hypixel
2026-06-10 17:28:01 +02:00
Anon
98d4781d2c add packet debug toggle 2026-06-10 17:24:25 +02:00
Anon
e744ff206b hypixel book fixes 2026-06-10 17:16:30 +02:00
Anon
61c2cbf7c3
bugfix: Fixed Telegram Bridge not displaying player names properly for players with an underscore in their nick names
Update TelegramBridge.cs
2026-06-07 14:11:54 +02:00
werfrag
2e383403c3
Update TelegramBridge.cs
The names of players containing the "_" symbol were not read.
2026-06-07 01:36:38 +02:00
Anon
80c07634e2
bugfix: Fixed not being able to send messages on legacy versions (1.4.6 - 1.6.4)
bugfix: Fixed not being able to send messages on legacy versions (1.4.6 - 1.6.4)
2026-06-06 14:14:52 +02:00
Anon
eeb89d676b Formatting 2026-06-06 14:12:28 +02:00
Anon
9980e08bac Fixed chat on legacy versions 2026-06-06 14:09:54 +02:00
Anon
861084db9b
bugfix: Fixed inventory sync and Script Scheduler dupliating instances of bots on reconnects
bugfix: Fixed inventory sync and Script Scheduler dupliating instances of bots on reconnects
2026-06-06 13:14:29 +02:00
Anon
71f935eaeb
Merge pull request #23 from milutinke/bugfix/script-scheduler-reconnect
bugfix: Fixed Script Scheduler bug
2026-06-06 12:12:11 +02:00
Anon
a5423211ad Fixed Script Scheduler bug 2026-06-06 12:09:36 +02:00
Anon
fc74cc066c
Merge branch 'MCCTeam:master' into master 2026-06-06 11:12:31 +02:00
Anon
5b13e740ef
bugfix: Player inventory state sync from the Container inventory
bugfix: Player inventory state sync from the Container inventory
2026-06-06 10:35:53 +02:00
Anon
b1d02edd99 Remove local inventory report from branch 2026-06-06 10:27:20 +02:00
Anon
6bfcd9e9ca Fix mirrored player inventory sync 2026-06-06 10:22:45 +02:00
Anon
3c59bfe13a Experimental inventory sync 2026-06-06 09:13:19 +02:00
Anon
654d9d4dd7
Revert inventory sync changes
chores: Revert inventory sync changes
2026-06-06 09:05:40 +02:00
Anon
0a514d1351 Revert inventory sync changes 2026-06-06 09:02:02 +02:00
Anon
913c0f0f72
bugfix: Fix offline LAN encrypted login
bugfix: Fix offline LAN encrypted login
2026-06-05 22:14:10 +02:00
Anon
4bbc751cc1 Fix offline LAN encrypted login 2026-06-05 22:09:52 +02:00
Anon
5f0ca8837f
bugfix: Fixed crashing and bugs on 1.9, 1.9.1, 1.9.2, 1.16.2, 1.21.10 and Player Inventory
bugfix: Fixed crashing and bugs on 1.9, 1.9.1, 1.9.2, 1.16.2, 1.21.10 and Player Inventory
2026-06-05 21:46:49 +02:00
Anon
f25fe53881
Merge pull request #20 from milutinke/fix/inventory-version-regressions
chores: Improved skills, pulled from master, formatted, removed unused imports.
2026-06-05 21:19:24 +02:00
Anon
82555bd823 Keep version adaptation skill generalized 2026-06-05 21:17:09 +02:00
Anon
29b0087a3a Merge branch 'master' of https://github.com/MCCTeam/Minecraft-Console-Client into fix/inventory-version-regressions
# Conflicts:
#	MinecraftClient/Mapping/World.cs
2026-06-05 21:13:59 +02:00
Anon
b10bd81bee Keep inventory sweep generalized 2026-06-05 21:00:39 +02:00
Anon
5545aabb60 Add reusable inventory sweep tooling 2026-06-05 20:57:49 +02:00
Anon
c98c2a8f83
Merge pull request #19 from milutinke/fix/inventory-version-regressions
bugfix: Fixed crashing and bugs on 1.9, 1.9.1, 1.9.2, 1.16.2, 1.21.10 and Player Inventory
2026-06-05 20:52:49 +02:00
Anon
b698f122cf
Merge branch 'master' into fix/inventory-version-regressions 2026-06-05 20:52:35 +02:00
Anon
5228670131 Fix inventory handling across protocol versions 2026-06-05 20:26:29 +02:00
Anon
c4df73ab6a
bugfix: Script Scheduler crashing after multiple reconnects
Fix Script bots accumulating across reconnects causing command spam
2026-06-05 08:47:54 +02:00
copilot-swe-agent[bot]
8757533fb9
Fix Script bots accumulating on reconnect by unloading on disconnect
Script ChatBots were persisted in the static botsOnHold list across
reconnects. When ScriptScheduler triggered new scripts on login, the
old Script bots were still running, causing duplicate commands to be
sent simultaneously. This led to command spam and crashes.

Adding OnDisconnect to the Script ChatBot ensures scripts are removed
from the bots list before they can be saved to botsOnHold, preventing
accumulation of duplicate script instances on reconnect.
2026-06-05 06:29:43 +00:00
copilot-swe-agent[bot]
2b05b7420e
Initial plan 2026-06-05 06:23:11 +00:00
Anon
701441d424
[skipci]chores: Documented the new change in Auto Attack bot
Document AutoAttack cooldown range and RandomMode options
2026-06-04 23:54:28 +02:00
copilot-swe-agent[bot]
546345e816
Update AutoAttack bot translation for Cooldown_Time 2026-06-04 19:31:21 +00:00
copilot-swe-agent[bot]
d835da76d2
Update AutoAttack bot Cooldown_Time documentation to reflect new Min/Max/RandomMode options 2026-06-04 18:06:25 +00:00
Anon
6db8c817e0
feat: Auto Attack chat bot -> Add a random cooldown range option
feat: Auto Attack chat bot -> Add a random cooldown range option
2026-06-04 11:48:18 +02:00
Anon
c6ca3dc13d Formatted the code and removed logs from Auto Attack 2026-06-04 11:42:41 +02:00
Anon
a4ff7ae438 feat(AutoAttack): implement random cooldown range for attack timing 2026-06-04 11:20:24 +02:00
Anon
45734ba294
[skipci]chores: Updated Android installation guide
[skipci]chores: Updated Android installation guide
2026-06-03 21:10:47 +02:00
Anon
6670685f51 Updated Android installation guide 2026-06-03 21:08:13 +02:00
Anon
de7af06d11
[skipci]Revert docs changes to fix search
docs: remove committed translation files, keep search freeze fix only
2026-06-03 12:07:47 +02:00
copilot-swe-agent[bot]
59e98867b3
chore: remove generated translation files and revert unrelated changes
Translation files under docs/translations/ are generated by the CI pipeline
and should not be committed. Also reverts unrelated changes to
docs/.vuepress/translations/en.json, docs/package-lock.json, and docs/yarn.lock.
2026-06-03 10:04:54 +00:00
copilot-swe-agent[bot]
a7dd9fff09
fix: remove getExtraFields from searchPlugin to fix search freeze regression
The getExtraFields callback added in 6456149 dumped entire page content
as a single extraFields string. plugin-search joins title+extraFields
with .join(' ') and runs a RegExp against the result for every page on
every keystroke — multi-kilobyte strings per page caused 2–3 s blocking
tasks, freezing the page.

Fix: remove getExtraFields (and the now-unused normalizeSearchText /
htmlEntityMap helpers), restoring the pre-regression search coverage
(page titles + section headings).
2026-06-03 09:32:19 +00:00
copilot-swe-agent[bot]
1796e7f80c
Plan: restore search by removing getExtraFields regression 2026-06-03 09:19:30 +00:00
Anon
e331eae03c
bugfix: Fix Auto Relog not working in certain scenarios
bugfix: Fix Auto Relog not working in certain scenarios
2026-06-02 12:31:49 +02:00
Anon
4cc5fc00c3
bugfix: /useitem offhand handling for edible items
Fix `/useitem` offhand handling for edible items
2026-06-02 10:47:13 +02:00
Anon
03e2e0d3fd
Merge pull request #18 from milutinke/bugfix/autorelog-retry-accounting
bugfix: Fix AutoRelog retry accounting
2026-06-02 10:29:52 +02:00
Anon
47de24cf1d Fix AutoRelog retry accounting 2026-06-02 10:24:59 +02:00
copilot-swe-agent[bot]
04a975d011
fix: support offhand food useitem fallback 2026-06-02 07:57:13 +00:00
copilot-swe-agent[bot]
2d31199f97
Initial plan 2026-06-02 07:43:21 +00:00
Anon
f39fe2d067
Merge pull request #3111 from milutinke/fix/autodig-mining-timing
bugfix: Fixed mining timing and block hadress values
2026-05-23 23:43:27 +02:00
Anon
8e26e55650 Removed a temporary tool tools/verify_mining_data.py 2026-05-23 23:40:41 +02:00
Anon
75f8f29d1a
[skipci]Merge pull request #3110 from MCCTeam/copilot/fix-search-index-details-tag
Index collapsed docs content in site search and fix the ItemType source link
2026-05-23 12:08:07 +02:00
Anon
b74364d465 Fix AutoDig mining timing 2026-05-23 10:56:06 +02:00
copilot-swe-agent[bot]
6456149d39
Fix docs search indexing
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/d698c0ee-5e22-482e-a065-7cc71068340a

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-23 07:53:01 +00:00
copilot-swe-agent[bot]
858f88d9ec
Plan docs search fix
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/d698c0ee-5e22-482e-a065-7cc71068340a

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-23 07:51:54 +00:00
Anon
a2dabe1aa8
[skipci]Merge pull request #3109 from MCCTeam/dependabot/npm_and_yarn/docs/webpack-dev-server-5.2.4
chore(deps): bump webpack-dev-server from 5.2.3 to 5.2.4 in /docs
2026-05-23 09:45:46 +02:00
dependabot[bot]
67bd944179
chore(deps): bump webpack-dev-server from 5.2.3 to 5.2.4 in /docs
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.3 to 5.2.4.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.3...v5.2.4)

---
updated-dependencies:
- dependency-name: webpack-dev-server
  dependency-version: 5.2.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 11:10:16 +00:00
Anon
b13511f201
[skipci]Merge pull request #3105 from MCCTeam/dependabot/npm_and_yarn/docs/postcss-8.5.14
chore(deps): bump postcss from 8.5.8 to 8.5.14 in /docs
2026-05-13 11:40:23 +02:00
dependabot[bot]
f91319cdfe
chore(deps): bump postcss from 8.5.8 to 8.5.14 in /docs
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.14.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:53:36 +00:00
Anon
44e3685454
[skipci]Merge pull request #3102 from MCCTeam/dependabot/npm_and_yarn/docs/fast-uri-3.1.2
chore(deps): bump fast-uri from 3.1.0 to 3.1.2 in /docs
2026-05-12 19:20:08 +02:00
Anon
5c1361169c
[skipci]Merge pull request #3104 from MCCTeam/dependabot/npm_and_yarn/docs/mermaid-11.15.0
chore(deps-dev): bump mermaid from 11.13.0 to 11.15.0 in /docs
2026-05-12 19:19:47 +02:00
dependabot[bot]
b282476607
chore(deps-dev): bump mermaid from 11.13.0 to 11.15.0 in /docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.13.0 to 11.15.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.13.0...mermaid@11.15.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.15.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 20:59:28 +00:00
dependabot[bot]
ea66dd4c13
chore(deps): bump fast-uri from 3.1.0 to 3.1.2 in /docs
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-09 00:05:05 +00:00
Anon
3f64a9158c
bugfix: Fixed Structured Components on 26.1 causing crashing with Shulkers 2026-05-07 20:26:08 +02:00
Anon
6331478e30
Merge pull request #17 from milutinke/fix/shulker-crash 2026-05-07 19:59:30 +02:00
Anon
d55fd75520 Fixed shulker crashing on 26.1 (Structured Components) 2026-05-07 19:57:23 +02:00
Anon
6673b398b1
Merge pull request #3098 from MCCTeam/copilot/bugfix-out-of-bounds-exception 2026-05-07 00:10:23 +02:00
copilot-swe-agent[bot]
93d9939378
fix: guard sky-limit block updates
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/c65d23bd-cf8c-4c62-8195-db980026eb94

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-06 20:31:28 +00:00
copilot-swe-agent[bot]
a57fb7f61f
Initial plan 2026-05-06 20:18:44 +00:00
Anon
ba04636d06
feat: Load Forge mod translations from local mod jars 2026-05-04 22:23:11 +02:00
copilot-swe-agent[bot]
21a52b3bcc
fix: streamline forge translation preload
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/3b2dd335-d070-4067-b864-5584bb94571b

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 20:15:56 +00:00
copilot-swe-agent[bot]
7ce4f82871
feat: discover forge translation mod sources
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/3b2dd335-d070-4067-b864-5584bb94571b

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 20:11:43 +00:00
copilot-swe-agent[bot]
67066a145d
refactor: parse forge mods toml for translations
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/3d9090fb-cdf3-48e7-a7ce-575b54161f0e

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 19:08:22 +00:00
copilot-swe-agent[bot]
d256517e21
feat: load forge mod translations from local jars
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/3d9090fb-cdf3-48e7-a7ce-575b54161f0e

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 19:02:44 +00:00
copilot-swe-agent[bot]
90a3777dac
Initial plan 2026-05-04 18:52:10 +00:00
Anon
effc05aec3
feat: Load translation keys from server resource packs in chat parsing 2026-05-04 20:17:38 +02:00
copilot-swe-agent[bot]
b543825200
chore: finalize resource pack cache toggle
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/70cfa855-cff8-495e-ac06-9a65bb5cc913

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 12:47:41 +00:00
copilot-swe-agent[bot]
b87b5abbe2
feat: add cached resource pack translation setting
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/70cfa855-cff8-495e-ac06-9a65bb5cc913

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 12:32:13 +00:00
copilot-swe-agent[bot]
9f974da00d
chore: finalize resource pack translation support
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/d7467a13-d1ff-4cd6-a332-1ac389c445da

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 12:21:28 +00:00
copilot-swe-agent[bot]
34f45543c3
feat: load chat translations from server resource packs
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/d7467a13-d1ff-4cd6-a332-1ac389c445da

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 12:05:55 +00:00
copilot-swe-agent[bot]
9c47456455
Initial plan 2026-05-04 11:59:00 +00:00
Anon
92ab2f7378
feat: Add console chat visibility controls and clear-console commands 2026-05-04 13:42:17 +02:00
copilot-swe-agent[bot]
e515921df2
Document console chat visibility commands
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/c900b80f-7e2c-486b-abbf-2c190a9bb750

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 11:40:00 +00:00
copilot-swe-agent[bot]
1a1caec30a
Polish console chat visibility implementation
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/159bd460-7950-4afe-9e69-4892220665e1

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 11:36:01 +00:00
copilot-swe-agent[bot]
7bf342baab
Implement console chat visibility controls
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/159bd460-7950-4afe-9e69-4892220665e1

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-05-04 11:17:45 +00:00
copilot-swe-agent[bot]
9c994de2ee
Initial plan 2026-05-04 11:09:33 +00:00
Anon
03d3d6f950
feat: modernize ReplayCapture 2026-05-04 13:04:15 +02:00
Anon
98a1551f6a feat: modernize ReplayCapture
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 11:49:44 +02:00
Anon
acabe95801
feat: Added Written Book Support 2026-05-03 20:22:45 +02:00
Anon
12ab759f3b Fix TUI book controls
Polish TUI book navigation and signed-book handling, update localized shortcut text, and document the final PageUp/PageDown interaction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 20:12:52 +02:00
Anon
41b33b6599
feat: Enable Farmer bot support for legacy 1.8 to 1.12.2
feat: Enable Farmer bot support for legacy 1.8 to 1.12.2
2026-05-03 16:43:47 +02:00
Anon
c282f2783c Fix legacy Farmer placement support 2026-05-03 15:46:16 +02:00
Anon
72001554a8 Complete 1.13-1.14 item palettes 2026-05-02 19:22:08 +02:00
Anon
8e31a37ea9 Document book command support
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 18:51:40 +02:00
Anon
c94be261fd Added Book Support 2026-05-02 17:52:17 +02:00
Anon
b5af631a40
bugfix: Fix structured components on 26.1 2026-04-30 16:25:52 +02:00
Anon
05741da9f1 Rename 26.1 structured component folder 2026-04-30 16:22:43 +02:00
Anon
5ad0d55705 Fix 26.1 structured component decoding 2026-04-30 16:18:59 +02:00
copilot-swe-agent[bot]
5279609561
Polish legacy Farmer validation logic
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/395596c2-cc40-4295-8100-a8408243f45b

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-17 08:00:41 +00:00
copilot-swe-agent[bot]
9549743c6d
Implement legacy Farmer support updates
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/395596c2-cc40-4295-8100-a8408243f45b

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-17 07:55:25 +00:00
Anon
8ba95c6140
[skipci]Merge pull request #3086 from MCCTeam/dependabot/npm_and_yarn/docs/dompurify-3.4.0 2026-04-16 10:41:27 +02:00
dependabot[bot]
0852d54991
chore(deps): bump dompurify from 3.3.3 to 3.4.0 in /docs
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.3 to 3.4.0.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.3...3.4.0)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 03:36:32 +00:00
Anon
2bccb7f8e9
feat: Handle configuration-state code-of-conduct packets on 1.21.9+ servers 2026-04-15 22:57:27 +02:00
copilot-swe-agent[bot]
e564da3e09
chore: document code-of-conduct payload read
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/672da423-8a3f-412e-833d-a50c9a605b9d

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-15 20:51:35 +00:00
copilot-swe-agent[bot]
4dbce45676
fix: accept configuration code of conduct
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/672da423-8a3f-412e-833d-a50c9a605b9d

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-15 20:47:18 +00:00
Anon
5ec1bcd9db
feat: Add %date% variable 2026-04-15 22:24:25 +02:00
copilot-swe-agent[bot]
c1635a999f
Initial plan 2026-04-15 20:23:47 +00:00
copilot-swe-agent[bot]
4a28430ef5
Remove accidentally committed MCC debug config file
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/d60853e9-6f56-45f7-8346-a423657c5673

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-15 20:22:01 +00:00
copilot-swe-agent[bot]
c7053ec015
Add %date% variable returning file-safe yyyy-MM-dd format
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/d60853e9-6f56-45f7-8346-a423657c5673

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-15 20:21:25 +00:00
copilot-swe-agent[bot]
abffb58e5f
Initial plan 2026-04-15 20:00:09 +00:00
Anon
e511d33452
feat: Suppress duplicate active effect notifications 2026-04-15 18:42:14 +02:00
copilot-swe-agent[bot]
4bd0929ebf
Document ShowEffectMessages setting
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/88621e8e-27e0-4be5-9259-efcdf8730704

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-15 14:27:00 +00:00
copilot-swe-agent[bot]
443f4874df
Suppress duplicate active effect notifications
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/8649d41a-e2aa-4fdd-aee8-96f4b35c9be0

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
2026-04-15 13:46:57 +00:00
Anon
b445d2bdec
[skipci]Merge pull request #3080 from MCCTeam/dependabot/npm_and_yarn/docs/follow-redirects-1.16.0 2026-04-15 11:05:14 +02:00
BruceChen
58ad896021 [skipci] docs: update .gitignore and config for superpowers documentation 2026-04-14 17:11:09 +00:00
BruceChen
8dccdaa7aa [skipci] chore: increase Node.js memory limit for documentation build 2026-04-14 17:05:11 +00:00
dependabot[bot]
5890cdef50
chore(deps): bump follow-redirects from 1.15.6 to 1.16.0 in /docs
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.6 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.6...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 01:13:01 +00:00
BruceChen
4013d39068
[skipci] Merge pull request #3078 from BruceChenQAQ/bruce/shared-server-mcc-sessions
Add MCC wrappers and guards for build and publish
2026-04-13 02:39:32 +08:00
BruceChen
ab29180e39 Add MCC wrappers and guards for build and publish 2026-04-12 18:37:03 +00:00
BruceChen
248a7a2f6f
Merge pull request #3077 from BruceChenQAQ/bruce/shared-server-mcc-sessions
tools: isolate MCC sessions while sharing local servers
2026-04-12 23:51:25 +08:00
BruceChen
8e0db1402f chore: remove remaining CursorBot references 2026-04-12 23:43:54 +08:00
BruceChen
ca5c9f73e3 docs: describe shared server MCC sessions 2026-04-12 23:33:59 +08:00
BruceChen
af285866dc tools: harden shared server session workflow 2026-04-12 23:33:45 +08:00
BruceChen
b665ebfeac Add session-scoped dual-session smoke test and harness updates 2026-04-12 20:46:39 +08:00
BruceChen
e4d71d970b Make tmpfs dotnet env wrapper bash-3 compatible 2026-04-12 20:21:59 +08:00
BruceChen
0a041b37c5 Add worktree-isolated tmpfs build outputs 2026-04-12 20:07:15 +08:00
BruceChen
e0752d8d98 tools: harden task3 session debug scripts 2026-04-12 19:57:54 +08:00
BruceChen
0519eac4a8 tools: run file-input debug sessions in tmux 2026-04-12 19:47:36 +08:00
BruceChen
bf292ea1be tools: scope mcc debug runtime artifacts by session 2026-04-12 19:38:53 +08:00
BruceChen
afee31e918 Ensure mcc-cmd handles unquoted multi-token commands 2026-04-12 19:26:32 +08:00
BruceChen
e03b7de959 Preserve full command in mcc-cmd and broaden tests 2026-04-12 19:25:25 +08:00
BruceChen
1be001bdf8 Fix session parser robustness and test assertion 2026-04-12 19:21:07 +08:00
BruceChen
4c6d5b566b Make MCC helpers session-aware and add reset test 2026-04-12 19:10:00 +08:00
BruceChen
d9c6826443 tools: fix tmpfs build root test 2026-04-12 19:04:38 +08:00
BruceChen
fdb6e5db4c tools: harden session helper and tests 2026-04-12 19:00:42 +08:00
BruceChen
3b65085a66 tools: add MCC session and build root helpers 2026-04-12 18:52:17 +08:00
BruceChen
6b986dcddd chore: update ModelContextProtocol package version to 1.2.0 and adjust Consolonia and Sentry package versions 2026-04-12 18:46:57 +08:00
BruceChen
158a4b1b13 docs: add MCC worktree session design 2026-04-12 17:37:13 +08:00
acamq
f3e8f65fe5 fix: correct /bed translation typo 2026-04-12 16:44:51 +08:00
Anon
8c8962f1ea
bugfix: correct scoreboard packet parsing for UpdateScore and ScoreboardObjective 2026-04-09 15:35:40 +02:00
adsicmes
fbacc550e2 fix: correct scoreboard packet parsing for UpdateScore and ScoreboardObjective
UpdateScore Display Name was read with ReadNextString instead of ReadNextChat. In 1.20.4+ Text Components are NBT-encoded, causing ReadNextString to misinterpret the tag type byte as a string length prefix, corrupting the read offset and exhausting the packet queue.

Also fix incomplete Number Format consumption in both UpdateScore and ScoreboardObjective: the styled (Compound Tag) and fixed (Text Component) payloads after the VarInt enum were not being read, leaving stale bytes in the packet queue.
2026-04-09 17:15:55 +08:00
Anon
d96396f74d
Merge pull request #3072 from milutinke/bugfix/srv-resolution 2026-04-08 18:15:22 +02:00
Anon
e0b21dd1d2 fixed the MCC incorrectly restarts the console input reader after transfer in classic mode. 2026-04-08 18:11:26 +02:00
BruceChen
229c9eba0a
[skipci] fix: update translation links for help section across multiple languages 2026-04-08 22:39:16 +08:00
BruceChen
2ac7e84b37 fix: update translation links for help section across multiple languages
- Changed the link for the "help us translate" section from crwd.in to crowdin.com for consistency and accuracy in localization configurations for various languages.
2026-04-08 22:38:37 +08:00
BruceChen
5aa1816621
[skipci] Fix document site build fail
[skipci] Fix document site build fail
2026-04-08 22:30:45 +08:00
BruceChen
b10e332e31 docs: update usage guide for MCC instance and note formatting
- Removed unnecessary period from the MCC instance reference.
- Reorganized the note section to improve clarity, moving it to a more appropriate location and ensuring consistent formatting for examples involving single and double quotes.
2026-04-08 22:29:42 +08:00
BruceChen
906d66a713 feat: add vue-template-tolerant plugin for error handling in translation pages
- Implemented a new Vite plugin that detects HTML errors in Vue translation pages and logs detailed error messages to the console.
- The plugin replaces the content of pages with a placeholder if errors are found, improving the robustness of the translation process.
- Integrated the plugin into the Vite bundler configuration for enhanced localization support.
2026-04-08 22:29:42 +08:00
BruceChen
b69708c637 chore: add websocket guide translations to Crowdin configuration
- Included new translation paths for markdown files located in the /docs/guide/websocket directory, enhancing the localization support for websocket-related documentation.
2026-04-08 22:29:42 +08:00
BruceChen
5363ef7bc1
Merge pull request #3068 from BruceChenQAQ/master
refactor: simplify color handling in ClassicConsoleBackend
2026-04-08 03:10:07 +08:00
BruceChen
740de13d98 refactor: enhance XLIFF parsing in Crowdin translation script
- Added support for an `include_paths` parameter to filter files based on specified prefixes, taking priority over `exclude_paths`.
- Updated the `parse_xliff` and `process_language` functions to accommodate the new filtering logic.
- Enhanced command-line argument parsing to allow users to specify files for translation directly, improving flexibility in translation management.
2026-04-08 02:52:07 +08:00
BruceChen
edadd91016 chore: update subproject commit reference for ConsoleInteractive 2026-04-08 02:50:39 +08:00
BruceChen
ff00d01fd7 refactor: simplify color handling in ClassicConsoleBackend
- Removed hex color handling logic from ClassicConsoleBackend, streamlining the WriteLineFormatted method.
- Updated ColorHelper to ensure consistent formatting for color codes.
- Ensured console-specific settings are reapplied after backend initialization in Program.cs.
2026-04-08 02:05:26 +08:00
BruceChen
64c42bb4b5
[skipci] Merge pull request #3067 from BruceChenQAQ/master
Optimize translate script
2026-04-08 01:32:38 +08:00
BruceChen
698a562156 refactor: update output directory handling in Crowdin translation script
- Removed the default output directory constant and updated the logic to set the output directory based on the provided argument or default to a "translated" subdirectory within the bundle directory.
- Improved help text for the output directory argument to clarify its default behavior.
2026-04-08 01:30:35 +08:00
BruceChen
7ec9d64b5b refactor: optimize translation unit handling in Crowdin script
- Introduced a new variable to track successfully translated units, improving clarity and efficiency in the processing logic.
- Updated the logic for combining existing and new translation units, ensuring accurate output and preventing unnecessary uploads when no new translations are available.
2026-04-08 01:30:35 +08:00
Anon
756bc1e11f
bugfix: Fixed a non-existant configuration packet from crashing the client. 2026-04-07 18:25:43 +02:00
Anon
5a5165e89f Fixed a non-existant configuration packet from crashing the client. 2026-04-07 17:00:01 +02:00
Anon
0e15806722
bugfix: Fixed SRV resolution during transfer 2026-04-07 15:43:12 +02:00
Anon
37271c5988 Fixed SRV resolution during transfer 2026-04-07 15:41:12 +02:00
BruceChen
35fd34b19d
Merge pull request #3062 from BruceChenQAQ/master
feat: add Crowdin translation automation script
2026-04-07 02:15:28 +08:00
BruceChen
ad065225ba feat: enhance game language support in settings
- Added documentation for mapping system CultureInfo names to Minecraft game language codes.
- Included support for Tagalog (tl and tl-PH) language codes in the game settings.
2026-04-07 02:03:02 +08:00
BruceChen
0445d95c23 feat: add support for additional game languages in settings
- Added support for Filipino (fil and fil-PH) and Norwegian (no) language codes in the game settings.
- Corrected formatting for existing language codes to ensure consistency and prevent potential issues with language recognition.
2026-04-07 01:58:29 +08:00
BruceChen
37bda82828 feat: add deduplication for cached domain prompt translations
- Implemented a new function to remove duplicate paragraphs from cached translations, addressing issues with the API returning repeated content.
- Updated the caching logic to apply deduplication both when loading from cache and after generating new translations, ensuring cleaner and more accurate cached data.
2026-04-07 00:52:18 +08:00
BruceChen
5dde778b53 refactor: improve XLIFF file handling in translation script
- Enhanced the `find_xliff_files` function to preserve the order of locales when mapping Crowdin locales to XLIFF paths.
- Updated the logic to return all available locales when no specific locales are provided, ensuring better handling of XLIFF files.
- Simplified the iteration over XLIFF files by removing unnecessary sorting in the final loop.
2026-04-07 00:50:24 +08:00
BruceChen
47f13a815c feat: add Crowdin translation automation script
- Introduced a new Python script for automating the translation of Crowdin XLIFF bundles using the Alibaba Cloud Qwen-MT API.
- Updated .gitignore to include the Crowdin translation working directory, ensuring that temporary files are not tracked.
- The script supports downloading, parsing, translating, and uploading XLIFF files, streamlining the localization process.
2026-04-07 00:30:52 +08:00
BruceChen
f432637710 fix: update GitHub link in MccBannerPanelBuilder
- Changed the GitHub link in the MccBannerPanelBuilder to use the full URL format for better accessibility.
2026-04-06 20:36:12 +08:00
BruceChen
e81700bf0e Fix warnings 2026-04-06 20:36:12 +08:00
BruceChen
b6bfecc622 fix: update build command in GitHub Actions workflow
- Modified the build command in the GitHub Actions workflow to use the .csproj file directly instead of the solution file, ensuring a more accurate build process for the specified project.
2026-04-06 20:36:12 +08:00
BruceChen
44e7f205c8 fix: disable transitive Avalonia compilation in project file
- Added a property to the MinecraftClient.csproj to disable the default Avalonia compilation, preventing file-lock races during parallel builds, as the project does not utilize XAML resources.
2026-04-06 20:36:12 +08:00
BruceChen
3073137006 fix: conditionally upload sources to Crowdin based on repository
- Updated the GitHub Actions workflow to conditionally upload sources to Crowdin only for the MCCTeam/Minecraft-Console-Client repository, enhancing the workflow's flexibility.
2026-04-06 20:14:02 +08:00
BruceChen
5c323772b3 fix: update Crowdin project URL in documentation and settings
- Corrected the Crowdin project URL in README.md, Settings.cs, and contributing.md to the official link.
2026-04-06 19:57:45 +08:00
BruceChen
0f32771075 fix: prevent concurrent restart flood on rapid /reco (#3046)
- Add reentrant guard to Program.Restart: track the active restart
  thread and silently drop concurrent calls from other threads while
  allowing the restart thread itself to chain a new restart (needed
  for AutoRelog retry flow).
- Fix double AutoRelog.OnDisconnectStatic invocation in HandleFailure:
  the method was called once in the error-message branch and again in
  the interactive-mode branch for the same failure event, doubling the
  retry counter increment and potentially spawning duplicate restarts.

Made-with: Cursor
2026-04-06 19:51:17 +08:00
BruceChen
e6ea29c914
[skipci] Merge pull request #3055 from BruceChenQAQ/master
[skipci] feat: Enhance GitHub Actions workflow with secret checks for deployment
2026-04-06 01:36:22 +08:00
BruceChen
e1f3dd5fdb
feat: Enhance GitHub Actions workflow with secret checks for deployment
- Added a job to check for the presence of required secrets (GH_PAGES_TOKEN, Crowdin secrets) before proceeding with documentation deployment and translation downloads.
- Updated the deployment job to conditionally run based on the results of the secret checks.
- Upgraded the Crowdin GitHub Action to version 2.4.0 for improved functionality.
2026-04-06 01:33:34 +08:00
BruceChen
7fd70ccf38 fix: Support hex RGB colors (#RRGGBB) in chat messages (fixes #2054)
Minecraft 1.16+ servers can send custom hex colors in JSON text
components ("color": "#RRGGBB"). MCC's ChatParser.Color2tag() only
recognized the 16 named colors, silently dropping hex values and
leaving gradient/custom-colored chat as uncolored plain text.

- ChatParser: recognize hex color values and emit internal §#rrggbb
  encoding; extend ColorCodeRegex to match the new format
- ClassicConsoleBackend: resolve §#rrggbb to ANSI escape codes via
  ColorHelper before passing to ConsoleInteractive (adapts to the
  configured ConsoleColorMode: 24-bit, 8-bit, 4-bit, or disable)
- McColorParser (TUI): parse §#rrggbb into exact SolidColorBrush
  for full RGB fidelity in Avalonia
- ChatBot.GetVerbatim(): skip the full 8-char §#rrggbb sequence
  instead of only 2 chars, preventing hex digits from leaking into
  stripped text

Made-with: Cursor
2026-04-06 01:19:19 +08:00
BruceChen
914c56fc6e
Merge pull request #3053 from BruceChenQAQ/master
Fix: chat colors not displayed in the TUI inventory screen
2026-04-06 00:36:23 +08:00
BruceChen
7f3b2c26da chore: Update Sentry package reference to version 6.3.0 2026-04-06 00:35:15 +08:00
BruceChen
49b7b98ba8 chore: Update package references for Consolonia and Magick.NET to latest versions 2026-04-06 00:35:15 +08:00
BruceChen
7405aad68f refactor: Simplify MainTuiView by removing architecture check for default return value 2026-04-06 00:35:15 +08:00
BruceChen
b083336bf6 refactor: Replace TextBlock creation with McColorParser for improved text rendering in chat display 2026-04-06 00:35:15 +08:00
BruceChen
a40188cb09
Merge pull request #3049 from BruceChenQAQ/master
feat: Add TUI support for /map
2026-04-06 00:24:27 +08:00
BruceChen
c7166f1cbc fix: Update zoom display in MapOverlay to show one decimal place for improved readability 2026-04-05 18:11:05 +08:00
BruceChen
c19ce62e98 fix: Adjust zoom step in MapOverlay for finer control during zooming 2026-04-05 18:09:20 +08:00
BruceChen
98ad174999 refactor: Simplify zoom functionality in MapOverlay by introducing NextStepScale method for smoother zoom transitions 2026-04-05 18:08:08 +08:00
BruceChen
296070f7e4 feat: Update map overlay to support interactive zoom and pan, and enhance header with zoom percentage display 2026-04-05 18:03:10 +08:00
BruceChen
0dde017ecc Add TUI support for /map 2026-04-05 17:41:32 +08:00
BruceChen
871bc7651f feat: Enhance command input handling by adding support for multi-line text input and refining text change logic 2026-04-05 17:41:11 +08:00
BruceChen
a6c832b744 Replaces the 120-TextBlock Grid (one per pixel) with a 7-TextBlock StackPanel using Run inlines for coloring. This eliminates ~113 unnecessary composition visual nodes from the banner icon. 2026-04-05 17:40:51 +08:00
Anon
33144b16d4
bugfix: Fixed the explosion packet on 26.1 2026-04-04 23:13:23 +02:00
Anon
81be654601 Fixed the explosion packet on 26.1 2026-04-04 23:12:05 +02:00
Anon
0cc34e6e22
bugfix: Fixed teams packet for 1.8 - 1.12.3 + Added text formatting for names in the tab list 2026-04-04 22:27:34 +02:00
Anon
32d5ad1108 Added text parsing in the tab player list 2026-04-04 22:25:40 +02:00
Anon
16e3a69211 Fixed teams packet for 1.8 - 1.12.3 2026-04-04 21:34:52 +02:00
BruceChen
6e86edc632 feat: Refactor MapColors class to load color palette from JSON resource 2026-04-05 01:17:57 +08:00
Anon
76c3403700
feat: Added a /tab command to display the list of online players 2026-04-04 16:21:06 +02:00
Anon
c8286f60c1 Added a /tab command 2026-04-04 16:17:49 +02:00
Anon
23262de6c2
bugfix: Fixed teams packet crash 2026-04-04 14:32:12 +02:00
Anon
a12ba29c45 Fixed teams packet crash 2026-04-04 14:30:44 +02:00
Anon
1ca023be36 Added a Github star reminder 2026-04-03 21:15:02 +02:00
Anon
0fda37b017
[skipci]Merge pull request #3043 from MCCTeam/dependabot/npm_and_yarn/docs/lodash-4.18.1 2026-04-03 20:59:39 +02:00
dependabot[bot]
75df2e0c71
Bump lodash from 4.17.23 to 4.18.1 in /docs
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 18:53:12 +00:00
Anon
d6b3dec8ad
feat: Implemented Model Context Protocol (MCP) into MCC 2026-04-03 20:51:54 +02:00
Anon
4b86e93764 Improved the documentation for MCP 2026-04-03 20:49:03 +02:00
Anon
8906f69839 Added documentation 2026-04-03 20:37:38 +02:00
Anon
f4c160979c Merge origin/master into feat/mcp-server 2026-04-03 20:03:37 +02:00
Anon
691057fe13 Bugfixes to the Looking and Movement. Added General prompt engineer. 2026-04-03 19:54:26 +02:00
Anon
dfef24ad10 Refactored to share the new utilities between the MCP and the Chat Bot API 2026-04-03 19:06:43 +02:00
milutinke
a88ca3cb71 Improved the guidance prompt 2026-04-01 13:55:18 +02:00
milutinke
fc05d2c98c Fixed item dropping issue. 2026-04-01 13:45:12 +02:00
milutinke
ee6eb84bd8 Improved the Web Based Harness 2026-04-01 13:45:12 +02:00
Anon
c3c57c058a Moved the operator prompt from a skill to an embedded resourcce 2026-03-30 23:21:36 +02:00
Anon
968800b95a Added a bunch of new useful MCP Tools 2026-03-30 23:14:28 +02:00
Anon
7b415d5388 Added a Skill for MCP 2026-03-29 20:57:16 +02:00
Anon
cf382122e9 Added inventory manipulation to the MCP, improved the test harness 2026-03-28 16:44:22 +01:00
Anon
7f9023e7bb More tools, added debug tools, fixed issues with some tools. 2026-03-28 04:12:37 +01:00
Anon
79b091cab6 Implemented a first version of an MCP server as a Chat Bot 2026-03-28 02:08:47 +01:00
394 changed files with 104204 additions and 5277 deletions

16
.codex/hooks.json Normal file
View file

@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_mcc_build_guard.py\"",
"statusMessage": "Checking MCC build command policy"
}
]
}
]
}
}

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
import json
import re
import sys
RAW_DOTNET_BUILD_RE = re.compile(r"(^|[\s;&|()])dotnet\s+build(\s|$)")
ABSOLUTE_DOTNET_BUILD_RE = re.compile(r"(^|[\s;&|()])/\S*dotnet\s+build(\s|$)")
RAW_DOTNET_PUBLISH_RE = re.compile(r"(^|[\s;&|()])dotnet\s+publish(\s|$)")
ABSOLUTE_DOTNET_PUBLISH_RE = re.compile(r"(^|[\s;&|()])/\S*dotnet\s+publish(\s|$)")
def main() -> int:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
command = payload.get("tool_input", {}).get("command", "")
if not isinstance(command, str) or not command:
return 0
if ABSOLUTE_DOTNET_BUILD_RE.search(command) or ABSOLUTE_DOTNET_PUBLISH_RE.search(command):
return 0
if RAW_DOTNET_BUILD_RE.search(command):
response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Raw 'dotnet build' is blocked in this repository. "
"Use 'source tools/mcc-env.sh && mcc-build' instead so MCC temp-build routing stays active. "
"If you intentionally need the raw .NET CLI, call it by absolute path such as '/usr/bin/dotnet build ...' to bypass this guard."
),
},
"systemMessage": (
"Blocked raw 'dotnet build'. Use 'source tools/mcc-env.sh && mcc-build'. "
"If you intentionally need raw .NET CLI behavior, call '/usr/bin/dotnet build ...' explicitly."
),
}
json.dump(response, sys.stdout)
sys.stdout.write("\n")
elif RAW_DOTNET_PUBLISH_RE.search(command):
response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Raw 'dotnet publish' is blocked in this repository. "
"Use 'source tools/mcc-env.sh && mcc-publish --rid <RID>' instead so MCC publish defaults stay aligned with the repo workflow. "
"If you intentionally need the raw .NET CLI, call it by absolute path such as '/usr/bin/dotnet publish ...' to bypass this guard."
),
},
"systemMessage": (
"Blocked raw 'dotnet publish'. Use 'source tools/mcc-env.sh && mcc-publish --rid <RID>'. "
"If you intentionally need raw .NET CLI behavior, call '/usr/bin/dotnet publish ...' explicitly."
),
}
json.dump(response, sys.stdout)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -73,7 +73,7 @@ jobs:
uses: crowdin/github-action@v2.4.0
if: steps.cache-check.outputs.cache-hit != 'true' && steps.crowdin-check.outputs.available == 'true'
with:
upload_sources: false
upload_sources: ${{ github.repository == 'MCCTeam/Minecraft-Console-Client' }}
upload_translations: false
download_translations: true
@ -193,7 +193,7 @@ jobs:
sed -i -e 's|SentryDSN = "";|SentryDSN = "${{ secrets.SENTRY_DSN }}";|g' ${{ env.project-path }}/Program.cs
- name: Build Target
run: dotnet publish ${{ env.project-path }}.sln -f ${{ env.target-version }} -r ${{ matrix.target }} ${{ env.compile-flags }}
run: dotnet publish ${{ env.project-path }}/${{ env.PROJECT }}.csproj -f ${{ env.target-version }} -r ${{ matrix.target }} ${{ env.compile-flags }}
env:
DOTNET_NOLOGO: true

View file

@ -6,8 +6,27 @@ on:
workflow_dispatch:
jobs:
check-secrets:
runs-on: ubuntu-latest
outputs:
has-deploy-token: ${{ steps.check.outputs.has-deploy-token }}
steps:
- name: Check required secrets
id: check
run: |
if [ -z "$GH_PAGES_TOKEN" ]; then
echo "has-deploy-token=false" >> $GITHUB_OUTPUT
echo "::warning::GH_PAGES_TOKEN is not set, skipping documentation deployment."
else
echo "has-deploy-token=true" >> $GITHUB_OUTPUT
fi
env:
GH_PAGES_TOKEN: ${{ secrets.GH_PAGES_TOKEN }}
Build:
runs-on: ubuntu-latest
needs: check-secrets
if: ${{ needs.check-secrets.outputs.has-deploy-token == 'true' }}
steps:
@ -17,8 +36,22 @@ jobs:
fetch-depth: 0
submodules: 'true'
- name: Check Crowdin secrets
id: crowdin-check
run: |
if [ -z "$CROWDIN_PROJECT_ID" ] || [ -z "$CROWDIN_PERSONAL_TOKEN" ]; then
echo "available=false" >> $GITHUB_OUTPUT
echo "::warning::Crowdin secrets not set, skipping translation download."
else
echo "available=true" >> $GITHUB_OUTPUT
fi
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
- name: Download translations from crowdin
uses: crowdin/github-action@v1.6.0
if: ${{ steps.crowdin-check.outputs.available == 'true' }}
uses: crowdin/github-action@v2.4.0
with:
upload_sources: false
upload_translations: false
@ -40,7 +73,7 @@ jobs:
ACCESS_TOKEN: ${{ secrets.GH_PAGES_TOKEN }}
TARGET_REPO: MCCTeam/MCCTeam.github.io
TARGET_BRANCH: master
BUILD_SCRIPT: yarn --cwd ./docs/ && yarn --cwd ./docs/ docs:build
BUILD_SCRIPT: export NODE_OPTIONS=--max-old-space-size=8192 && yarn --cwd ./docs/ && yarn --cwd ./docs/ docs:build
BUILD_DIR: docs/.vuepress/dist
COMMIT_MESSAGE: Build from ${{ github.sha }}
CNAME: https://mccteam.github.io

4
.gitignore vendored
View file

@ -423,6 +423,7 @@ FodyWeavers.xsd
/docs/l10n/
/docs/.vuepress/public/MCC-README/
/docs/superpowers/
# Floder to store the decompiled Minecraft official source code
/MinecraftOfficial/
@ -441,3 +442,6 @@ FodyWeavers.xsd
/Sentry/
/downloads/
server.pid
# Crowdin translation automation working directory
/.crowdin-translate/

View file

@ -1,14 +1,46 @@
---
name: csharp-best-practices
name: dotnet-csharp-best-practices
description: >
C# 14 / .NET 10 coding conventions, idiomatic patterns, and performance best practices
for the Minecraft Console Client codebase. Use when writing, reviewing, or modifying C# code.
C# coding conventions, idiomatic patterns, performance, and async best practices for both .NET 8 (C# 12) and .NET 10 (C# 14). Use when writing, reviewing, refactoring, or designing C# code — including async code that uses Task, Task<T>, ValueTask, CancellationToken, Task.WhenAll/WhenAny, Task.Run, ConfigureAwait, async void, or fire-and-forget. Trigger on `.Result`, `.Wait()`, deadlocks, cancellation propagation, ASP.NET Core background work, UI responsiveness, exception flow, and performance-sensitive async API design.
metadata:
category: technique
platform: ".NET 8 (C# 12) and .NET 10 (C# 14)"
triggers:
- c#
- csharp
- .net
- .net 8
- .net 10
- async
- task
- valuetask
- cancellationtoken
- configureawait
- .result
- .wait()
- async void
- fire-and-forget
- task.run
- whenall
- whenany
- asp.net core
- deadlock
---
# C# 14 / .NET 10 Best Practices
# C# Best Practices — .NET 8 + .NET 10
Target: **.NET 10**, **C# 14**, nullable enabled.
Sources: [MS C# Conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions) · [.NET Runtime Style](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md) · [C# 14 Proposals](https://github.com/dotnet/csharplang/blob/main/Language-Version-History.md) · [C# 13 Docs](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13)
Target: **.NET 8 (C# 12)** *and* **.NET 10 (C# 14)**, nullable enabled. GrECo has no .NET 9 projects.
## Step 0 — Detect the target framework
**Before** emitting code, follow `../../references/detect-target-framework.md`. The detection result decides which examples below apply:
- `net8.0` → emit the **.NET 8 / C# 12** code block in every side-by-side pair; **never** use the C# 14-only syntax (`field`, `extension(...)`, `?.` assignment, partial constructors) or .NET 10-only APIs (`HybridCache`, `AddValidation`, EF Core named filters, first-party `Microsoft.AspNetCore.OpenApi`, Identity passkeys).
- `net10.0` → prefer the **.NET 10 / C# 14** code block.
- Multi-target (`<TargetFrameworks>net8.0;net10.0</TargetFrameworks>`) → emit the .NET 8 version, or wrap .NET 10-only code in `#if NET10_0_OR_GREATER`.
- Unknown → ask the user.
Sources: [MS C# Conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions) · [.NET Runtime Style](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md) · [C# language versioning](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-versioning) · [C# 14 What's New](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14) · [C# 12 What's New](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12)
## Naming
@ -21,7 +53,7 @@ Sources: [MS C# Conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fun
| Thread-static field | `t_camelCase` | `t_cachedBuffer` |
| Local, parameter | camelCase | `packetId` |
| Type parameter | `T` + PascalCase | `TResult` |
| Namespace | PascalCase | `MinecraftClient.Protocol` |
| Namespace | PascalCase | `SomeNamespace.SomeClasses` |
| Async methods | Suffix `Async` | `ConnectAsync()`, `ReadPacketAsync()` |
```csharp
@ -40,14 +72,16 @@ public int packet_count { get; set; } // snake_case
public async Task<bool> Connect(CancellationToken ct) { } // missing Async suffix
```
## C# 14 Features
## C# 14 Features (.NET 10 only — with .NET 8 / C# 12 fallbacks)
### Extension Members (C# 14)
Every feature in this section requires `<TargetFramework>net10.0</TargetFramework>`. On `net8.0` use the fallback shown alongside. Authoritative reference: [C# 14 what's new](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14).
Declare extension methods, properties, and operators inside `extension(...)` blocks. Replaces `this`-parameter pattern for new extensions.
### Extension Members
Declare extension methods, properties, and operators inside `extension(...)` blocks.
```csharp
// CORRECT: extension property + method (C# 14)
// .NET 10 / C# 14 — extension property + method
public static class EntityExtensions
{
extension(Entity entity)
@ -63,19 +97,27 @@ public static class EntityExtensions
```
```csharp
// WRONG: classic extension method when C# 14 extension block is available
public static bool IsAlive(this Entity entity) => entity.Health > 0;
// .NET 8 / C# 12 — classic static extension class (only option)
public static class EntityExtensions
{
public static bool IsAlive(this Entity entity) => entity.Health > 0;
public static void Heal(this Entity entity, int amount)
=> entity.Health = Math.Min(entity.Health + amount, 20);
public static bool IsEmpty<T>(this IEnumerable<T> items)
=> !items.GetEnumerator().MoveNext();
}
// Extension properties do not exist in C# 12 — expose them as methods or compute inline.
```
### `field` Keyword in Properties (C# 14)
### `field` Keyword in Properties
Access the auto-generated backing field without declaring it. Mix auto and full accessors.
Access the auto-generated backing field without declaring it.
```csharp
// CORRECT: lazy init with field keyword
// .NET 10 / C# 14 — field keyword
public string DisplayName => field ??= ComputeDisplayName();
// CORRECT: INotifyPropertyChanged pattern
public bool IsConnected
{
get;
@ -89,128 +131,170 @@ public bool IsConnected
```
```csharp
// WRONG: manual backing field when field keyword suffices
// .NET 8 / C# 12 — manual backing field (only option)
private string? _displayName;
public string DisplayName => _displayName ??= ComputeDisplayName();
private bool _isConnected;
public bool IsConnected
{
get => _isConnected;
set
{
if (_isConnected == value) return;
_isConnected = value;
OnPropertyChanged();
}
}
```
### Null-Conditional Assignment (C# 14)
Assign through `?.` — RHS is only evaluated when receiver is non-null.
### Null-Conditional Assignment
```csharp
// CORRECT: null-conditional assignment
// .NET 10 / C# 14 — assign / compound-assign through ?.
player?.Health = 20;
connection?.OnDisconnect += HandleDisconnect;
inventory?[slot] = newItem;
```
```csharp
// WRONG: manual null check for simple assignment
if (player is not null)
player.Health = 20;
// .NET 8 / C# 12 — manual null check
if (player is not null) player.Health = 20;
if (connection is not null) connection.OnDisconnect += HandleDisconnect;
if (inventory is not null) inventory[slot] = newItem;
```
### Simple Lambda Parameters with Modifiers (C# 14)
Omit types on lambda parameters while still applying modifiers.
### Simple Lambda Parameters with Modifiers
```csharp
// CORRECT: modifiers without explicit types
// .NET 10 / C# 14 — modifiers without explicit types
TryParse<int> parse = (text, out result) => int.TryParse(text, out result);
ReadOnlySpan<int> data = [1, 2, 3];
ProcessSpan((scoped span) => span.Length);
```
```csharp
// WRONG: fully explicit types just for a modifier
// .NET 8 / C# 12 — full parameter types required when modifiers are present
TryParse<int> parse = (string text, out int result) => int.TryParse(text, out result);
ProcessSpan((scoped ReadOnlySpan<int> span) => span.Length);
```
### First-Class Span Types (C# 14)
Implicit conversions between `T[]`, `Span<T>`, and `ReadOnlySpan<T>` — no explicit cast needed. Extension methods on `ReadOnlySpan<T>` apply to arrays and spans automatically.
### First-Class Span Types
```csharp
// CORRECT: pass array where ReadOnlySpan<T> is expected (C# 14)
// .NET 10 / C# 14 — implicit T[] → ReadOnlySpan<T>
int[] data = [1, 2, 3];
bool found = data.StartsWith(1); // ReadOnlySpan<int> extension resolved
bool found = data.StartsWith(1); // ReadOnlySpan<int> extension auto-resolves
ReadOnlySpan<byte> span = stackalloc byte[4];
```
### Unbound Generics in `nameof` (C# 14)
```csharp
// .NET 8 / C# 12 — call .AsSpan() explicitly at the boundary
int[] data = [1, 2, 3];
bool found = data.AsSpan().StartsWith(stackalloc int[] { 1 }); // explicit conversion
ReadOnlySpan<byte> span = stackalloc byte[4]; // stackalloc → ReadOnlySpan already works
```
### Unbound Generics in `nameof`
```csharp
// CORRECT: no need to pick a dummy type argument
string name = nameof(Dictionary<,>); // "Dictionary"
string prop = nameof(List<>.Count); // "Count"
// .NET 10 / C# 14
string name = nameof(Dictionary<,>); // "Dictionary"
string prop = nameof(List<>.Count); // "Count"
```
```csharp
// WRONG: arbitrary type argument just to satisfy nameof
string name = nameof(Dictionary<object, object>);
// .NET 8 / C# 12 — pick any closed type argument
string name = nameof(Dictionary<object, object>); // "Dictionary"
string prop = nameof(List<int>.Count); // "Count"
```
### Partial Events and Constructors (C# 14)
Separate declaration from implementation for source-generator scenarios.
### Partial Events and Constructors
```csharp
// CORRECT: partial constructor for source-gen interop
// .NET 10 / C# 14 — partial constructor for source-gen interop
partial class ServerConnection
{
partial ServerConnection(string host, int port);
}
partial class ServerConnection
{
partial ServerConnection(string host, int port) { /* generated */ }
partial ServerConnection(string host, int port) { /* generated body */ }
}
```
### `#:` Ignored Directives (C# 14)
```csharp
// .NET 8 / C# 12 — partial constructors do not exist.
// Either declare a regular constructor and call a private generated helper,
// or put the constructor body in a single file:
partial class ServerConnection
{
public ServerConnection(string host, int port) => InitGenerated(host, port);
private partial void InitGenerated(string host, int port); // partial methods are C# 9+
}
partial class ServerConnection
{
private partial void InitGenerated(string host, int port) { /* generated body */ }
}
```
For file-based `dotnet run app.cs` programs — ignored by the compiler.
### `#:` Ignored Directives / file-based programs
C# 14 / .NET 10 SDK only — no .NET 8 equivalent. `dotnet run app.cs` requires the .NET 10 SDK.
```csharp
// .NET 10 only — file-based program with inline package reference
#!/usr/bin/dotnet run
#:package System.CommandLine@2.0.0-*
Console.WriteLine("Hello");
```
## C# 13 Features
```csharp
// .NET 8 — create a full project (dotnet new console -f net8.0) and reference
// System.CommandLine in the .csproj. There is no inline-package syntax.
```
### `Lock` Object (C# 13)
## C# 13 Features — require .NET 9+ (NOT available on .NET 8)
Use `System.Threading.Lock` instead of `lock(obj)` on arbitrary objects.
GrECo has no .NET 9 projects, so the only way to use C# 13 features in production is to be on .NET 10. On .NET 8, use the .NET 8 fallback shown below.
### `Lock` Object
```csharp
// CORRECT: dedicated Lock type
private readonly Lock _lock = new();
public void Enqueue(ChatMessage msg) { lock (_lock) _queue.Add(msg); }
// .NET 10 / C# 13+ — dedicated System.Threading.Lock type
private readonly Lock _gate = new();
public void Enqueue(ChatMessage msg) { lock (_gate) _queue.Add(msg); }
```
```csharp
// WRONG: locking on an object reference
private readonly object _syncRoot = new();
lock (_syncRoot) { }
// .NET 8 / C# 12 — lock on a plain object reference (the only option)
private readonly object _gate = new();
public void Enqueue(ChatMessage msg) { lock (_gate) _queue.Add(msg); }
```
### `params` Collections (C# 13)
### `params` Collections (`params ReadOnlySpan<T>`)
`params` now works with `ReadOnlySpan<T>`, `Span<T>`, `IEnumerable<T>`, and other collection types.
The runtime overloads accepting `params ReadOnlySpan<T>` ship in the .NET 9 BCL. On .NET 8 use `params T[]`.
```csharp
// CORRECT: params span avoids array allocation
// .NET 10 / C# 13+ — params span avoids the array allocation
public void Log(params ReadOnlySpan<string> messages)
{
foreach (var msg in messages) Console.WriteLine(msg);
}
```
### Partial Properties (C# 13)
```csharp
// .NET 8 / C# 12 — params array (one heap allocation per call)
public void Log(params string[] messages)
{
foreach (var msg in messages) Console.WriteLine(msg);
}
```
### Partial Properties
```csharp
// CORRECT: partial property for source generators
// .NET 10 / C# 13+ — partial property for source generators
partial class Config
{
public partial string Host { get; set; }
@ -222,7 +306,16 @@ partial class Config
}
```
## C# 12 Features
```csharp
// .NET 8 / C# 12 — partial properties do not exist; declare a normal property
// and let the source generator emit the backing field or a helper method.
partial class Config
{
public string Host { get; set; } = "";
}
```
## C# 12 Features (.NET 8+ — available on both targets)
### Primary Constructors
@ -292,16 +385,16 @@ var greet = (string name, string prefix = "Player") => $"{prefix} {name}";
```csharp
// CORRECT: file-scoped namespace — one per file, less nesting
namespace MinecraftClient.ChatBots;
namespace SomeNamespace.SomeClasses;
public class MyBot : ChatBot { }
public class SomeClass : SomeInterface { }
```
```csharp
// WRONG: block-scoped namespace adds unnecessary nesting
namespace MinecraftClient.ChatBots
namespace SomeNamespace.SomeClasses
{
public class MyBot : ChatBot { }
public class SomeClass : SomeInterface { }
}
```
@ -512,6 +605,34 @@ public string GetName(Player? player)
## Async / Await
<priority-order>
1. correctness and cancellation semantics
2. context-specific API design (library vs app, UI vs ASP.NET Core)
3. concurrency behavior and failure handling
4. performance tuning only when the hot path is real
</priority-order>
Treat blanket advice as suspect. Separate official runtime behavior from expert interpretation and from your own recommendation for the case at hand.
### Review defaults
Start from these defaults unless case-specific evidence says otherwise:
| Topic | Default judgment |
|---|---|
| Blocking on async (`.Result`, `.Wait`, `GetAwaiter().GetResult()`) | usually a defect or interop boundary smell |
| `async void` | only acceptable for event handlers |
| `ValueTask` | avoid by default; justify with measurements or a very hot path |
| `ConfigureAwait(false)` | good library default, not an app-wide default |
| `Task.Run` | use to offload CPU work when needed, not to fake async I/O |
| Fire-and-forget | assume unsafe until lifecycle, scope, and exception handling are explicit |
| `Task.WhenAll` | prefer for independent concurrent operations |
| `Task.WhenAny` | always inspect the winner task and define what happens to losers |
| Cancellation | accept and propagate the token until the point of no cancellation |
| Method naming | `Async` suffix for awaitable-returning methods (unless an interface/event contract dictates otherwise) |
### Concrete patterns
```csharp
// CORRECT: propagate CancellationToken through every async I/O call
public async Task<string> FetchDataAsync(Uri uri, CancellationToken ct = default)
@ -531,13 +652,16 @@ public async Task<string> FetchDataAsync(Uri uri)
```
```csharp
// CORRECT: ValueTask when result is often available synchronously
// CORRECT: ValueTask when result is often available synchronously and the call is on a hot path
public ValueTask<int> GetCachedCountAsync()
{
if (_cache.TryGetValue("count", out int count))
return ValueTask.FromResult(count);
return new ValueTask<int>(LoadCountFromDbAsync());
}
// Note: do not await the same ValueTask twice, do not call AsTask() multiple times,
// and do not mix consumption techniques on the same instance — undefined behavior.
```
```csharp
@ -551,19 +675,15 @@ public async Task<int> GetCachedCountAsync()
```
```csharp
// CORRECT: async Task for async event handlers
// CORRECT: async Task for async event handlers exposed as awaitable
public async Task HandleEventAsync(GameEvent e, CancellationToken ct)
{
await notificationService.SendAsync(e.PlayerId, ct);
}
=> await notificationService.SendAsync(e.PlayerId, ct);
```
```csharp
// WRONG: async void — exceptions are unobservable, cannot be awaited
public async void HandleEvent(GameEvent e)
{
await notificationService.SendAsync(e.PlayerId, default);
}
=> await notificationService.SendAsync(e.PlayerId, default);
```
```csharp
@ -572,15 +692,25 @@ var packet = await reader.ReadPacketAsync(ct);
```
```csharp
// WRONG: .Result / .Wait() causes deadlocks
// WRONG: .Result / .Wait() / GetAwaiter().GetResult() — deadlocks and thread pool starvation
var packet = reader.ReadPacketAsync(ct).Result;
var packet2 = reader.ReadPacketAsync(ct).GetAwaiter().GetResult();
```
```csharp
// CORRECT: ConfigureAwait(false) in library code
// CORRECT: ConfigureAwait(false) in general-purpose library code
var data = await stream.ReadAsync(buffer, ct).ConfigureAwait(false);
```
```csharp
// CORRECT: Task.WhenAll for independent concurrent I/O
var userTask = repo.GetUserAsync(id, ct);
var ordersTask = repo.GetOrdersAsync(id, ct);
await Task.WhenAll(userTask, ordersTask);
return new Dashboard(await userTask, await ordersTask);
```
```csharp
// CORRECT: IAsyncEnumerable for streaming
public async IAsyncEnumerable<ChatMessage> ReadChatStreamAsync(
[EnumeratorCancellation] CancellationToken ct = default)
@ -593,6 +723,53 @@ public async IAsyncEnumerable<ChatMessage> ReadChatStreamAsync(
await using var conn = new McConnection(host, port);
```
### Common traps
- Calling `.Result`, `.Wait()`, or `GetAwaiter().GetResult()` inside normal async-capable code
- Recommending `ConfigureAwait(false)` everywhere because "it is .NET Core" or "it prevents deadlocks"
- Recommending `Task.Run` inside ASP.NET Core request code just to make code "more async" — request code already runs on the thread pool
- Recommending `ValueTask` for every hot-looking method without checking completion behavior, call frequency, or single-consumer assumptions
- Ignoring cancellation after plumbing a `CancellationToken` (accepting it but never checking or propagating)
- Using `Task.WhenAny` without awaiting the returned winner task or handling the remaining tasks
- Treating fire-and-forget as harmless when it touches scoped services, `HttpContext`, or unobserved failures
- Awaiting the same `ValueTask` twice, or mixing `AsTask()` with `await` on the same instance
### Rationalization traps
| Rationalization | Better reasoning |
|---|---|
| "It works, so `.Result` is fine." | Lack of failure under one context does not make blocking safe or scalable. |
| "`ValueTask` is always faster." | It trades simplicity for niche allocation wins and stricter consumption rules. |
| "`ConfigureAwait(false)` everywhere is modern guidance." | Library and app code have different constraints. Blanket rules are weak. |
| "`Task.Run` makes server code asynchronous." | It only queues work; it does not turn blocking I/O into true async I/O. |
| "Fire-and-forget is okay because logging exists." | Logging does not solve scope lifetime, shutdown, retries, or error propagation. |
### Hard boundaries
- Do not endorse sync-over-async as a normal design choice
- Do not suggest `async void` except for event handlers
- Do not suggest `ValueTask` unless single-consumer / hot-path / measurement constraints are met
- Do not claim `ConfigureAwait(false)` is always needed or always unnecessary
- Do not approve fire-and-forget unless ownership, exception handling, and lifetime are explicit
### Output contract for review and design
When you review or design async code, label your reasoning:
- **Fact** — official runtime or API behavior
- **Expert guidance** — interpretation from strong experts (Stephen Toub, Stephen Cleary, Andrew Arnott) when it adds design meaning
- **Synthesis** — your recommendation for this exact case
Do not present contextual advice as a universal law.
### References for deep async work
Load on demand:
- [references/async-core-guidance.md](references/async-core-guidance.md) — fact / expert / synthesis for `Task` vs `ValueTask`, blocking, cancellation, exception flow, `WhenAll` / `WhenAny`
- [references/async-context-and-tradeoffs.md](references/async-context-and-tradeoffs.md) — library vs app, UI vs ASP.NET Core, `Task.Run` boundaries, fire-and-forget alternatives, `ConfigureAwait` strong vs weak recommendations, throttling
- [references/async-source-notes.md](references/async-source-notes.md) — source attribution and authority breakdown
## LINQ
### Prefer Method Syntax for Most Operations
@ -699,7 +876,11 @@ for (int i = 0; i < data.Length; i++)
int found = data.ToArray().Count(b => b == target);
```
## Performance (.NET 8+)
## Performance (.NET 8+ — works on both targets)
All APIs below ship in .NET 8 and continued unchanged in .NET 10. For benchmarks and rationale see Stephen Toub's deep dives:
[Performance Improvements in .NET 8](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/) ·
[Performance Improvements in .NET 10](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/) (covers JIT array-interface devirtualisation that speeds up many LINQ paths in .NET 10).
### Span\<T\> / Memory\<T\>
@ -803,7 +984,7 @@ foreach (var s in items) combined += s + ", ";
| Immutable snapshots | `ImmutableDictionary<K,V>` | Persistent structure |
| Membership test | `HashSet<T>` / `FrozenSet<T>` | FrozenSet for static |
| Priority queue | `PriorityQueue<E,P>` | .NET 6+ |
| Synchronization | `System.Threading.Lock` | C# 13; prefer over `lock(obj)` |
| Synchronization | `System.Threading.Lock` / `lock(object)` | `Lock` is .NET 9+ only — on .NET 8 use `private readonly object _gate = new();` |
| Producer-consumer | `Channel<T>` | Over `BlockingCollection<T>` |
| Temp buffer | `ArrayPool<T>` / `stackalloc` | Zero/low alloc |
@ -960,10 +1141,14 @@ public bool IsAlive => Health > 0;
_ = int.TryParse(s, out int result);
(_, int y, _) = GetCoordinates();
// CORRECT: nameof for resilient refactoring (unbound generics in C# 14)
// CORRECT: nameof for resilient refactoring
throw new ArgumentException("Invalid value", nameof(packetId));
LogToConsole($"{nameof(AutoEat)}: eating {item.Name}");
string typeName = nameof(Dictionary<,>); // "Dictionary"
// .NET 10 / C# 14 — unbound generic in nameof
string typeName10 = nameof(Dictionary<,>); // "Dictionary"
// .NET 8 / C# 12 — use any closed generic instead
string typeName8 = nameof(Dictionary<object, object>); // "Dictionary"
// CORRECT: static lambdas prevent accidental closure allocations
list.Sort(static (a, b) => a.Id.CompareTo(b.Id));

View file

@ -0,0 +1,82 @@
---
description: >-
Context-specific async guidance for library code, ui apps, asp.net core,
background work, task.run, configureawait, and performance-sensitive design.
metadata:
tags: [configureawait, task.run, asp.net core, ui, library, performance]
source: mixed
---
# Context and Tradeoffs
## Library code versus app code
### General-purpose library code
- Prefer APIs that expose true async for I/O-bound work.
- Do not add async wrappers around purely compute-bound methods just to look modern. Expose sync compute APIs and let callers decide whether to offload.
- `ConfigureAwait(false)` is a strong default when the library does not need the callers context.
- Avoid ambient assumptions about a UI thread, request context, or test framework behavior.
### App code
- Prefer the style that fits the app model.
- UI code often needs the original context after `await`.
- ASP.NET Core request code normally does not need `Task.Run` just to stay responsive, because it already runs on thread pool threads.
- Do not present “ASP.NET Core has no synchronization context” as proof that every `ConfigureAwait(false)` discussion is obsolete.
## `Task.Run` boundaries
### Good uses
- Offload CPU-bound work so a UI thread can stay responsive.
- Offload CPU work from a caller when that scheduling boundary is deliberate.
### Weak uses
- Wrapping synchronous I/O to pretend it is true async I/O.
- Calling `Task.Run` and immediately awaiting it in ASP.NET Core request handling when no CPU offload goal exists.
- Using `Task.Run` to hide blocking APIs instead of fixing the underlying API choice.
## Fire-and-forget
### Assume unsafe until proven otherwise
A background task needs answers for all of these:
- Who owns its lifetime?
- How are exceptions observed?
- How does shutdown cancel it?
- Does it touch scoped services or request-bound objects?
- Does work need retries, backpressure, or queueing?
### Safer alternatives
- Await the task normally.
- Queue work to an owned background component.
- In ASP.NET Core, prefer hosted services or a dedicated background queue pattern for long-lived work.
- If scoped services are required in background processing, create an explicit scope instead of capturing request scope objects.
## `ConfigureAwait`
### Strong recommendation
- In general-purpose libraries, use `ConfigureAwait(false)` unless the continuation must run in the captured context.
### Weak recommendation
- “Always use it in app code.”
- “Never use it on .NET Core.”
- “Use it once at the first await and you are done.”
### Review note
If code after the `await` needs a specific context, say so explicitly. If it does not, the recommendation depends on whether the code is app-level or general-purpose library code.
## Performance guidance
### Correctness first
Do not trade API clarity for speculative micro-optimizations.
### `ValueTask` is performance-specialized
Recommend it only when most of these are true:
1. the method is called very frequently
2. it often completes synchronously or from a reusable source
3. allocation reduction matters on measurements
4. consumers can respect single-consumer semantics
5. task combinator ergonomics are not central to the API
### Throttling and concurrency control
- `Task.WhenAll` expresses concurrency; it does not limit it.
- For bounded concurrency, use an async gate such as `SemaphoreSlim.WaitAsync`, or platform helpers such as `Parallel.ForEachAsync` when the workload fits.
- Always define what happens to remaining work after the first completion or first failure.

View file

@ -0,0 +1,105 @@
---
description: >-
Source-backed core guidance for task, valuetask, cancellation, exception flow,
blocking, and concurrency in c# async code reviews and implementations.
metadata:
tags: [csharp, async, task, valuetask, cancellation, exceptions, concurrency]
source: mixed
---
# Core Guidance
## Facts from official .NET documentation
### 1. Return types and `async void`
- Async methods should normally return `Task` or `Task<T>`.
- `async void` is intended for event handlers; callers cannot await it and exception handling differs.
- TAP methods that return awaitable types conventionally use the `Async` suffix.
### 2. Blocking on async
- `Task<T>.Result` is blocking. Prefer `await` in most cases.
- Blocking can deadlock in context-bound environments and reduces scalability even when it does not deadlock.
- `await` on a faulted task rethrows one exception directly; `.Wait()` and `.Result` wrap failures in `AggregateException`.
### 3. `Task` versus `ValueTask`
- Default to `Task` or `Task<T>` unless there is a demonstrated reason not to.
- `ValueTask` has stricter usage rules. A given instance should generally be awaited only once.
- Do not await the same `ValueTask` multiple times, call `AsTask()` multiple times, or mix consumption techniques on the same instance.
- For synchronously successful `Task`-returning methods, `Task.CompletedTask` is the normal zero-result completion value.
### 4. Cancellation
- If a TAP method supports cancellation, expose a `CancellationToken`.
- Pass the token to nested operations that should participate in cancellation.
- If an async method throws `OperationCanceledException` associated with the methods token, the returned task transitions to `Canceled`.
- After a method has completed its work successfully, do not report cancellation instead of success.
### 5. Exception flow and task combinators
- `Task.WhenAll` does not block the calling thread.
- If any supplied task faults, the `WhenAll` task faults and aggregates the unwrapped exceptions from the component tasks.
- If none fault and at least one is canceled, the `WhenAll` task is canceled.
- `Task.WhenAny` returns a task that completes successfully with the first completed task as its result, even when that winning task itself is faulted or canceled.
- After `WhenAny`, await the returned winner task to propagate its outcome.
- The remaining tasks continue unless you cancel or otherwise handle them.
## Expert guidance that is strong and technically grounded
### Stephen Toub
- Use `ConfigureAwait(false)` as the general default for general-purpose library code, because library code should not depend on an app models context.
- App-level code is different. UI code often needs the captured context. ASP.NET Core also changes the deadlock discussion because it does not install the classic ASP.NET style synchronization context, but that does not make blanket `ConfigureAwait` advice strong.
- `ValueTask<T>` exists mainly to avoid allocations on frequently synchronous success paths. It is not a general replacement for `Task<T>` because `Task` is more flexible for multiple awaits, caching, and combinators.
### Andrew Arnott
- Propagate the token until the point of no cancellation.
- Validate arguments before cancellation checks when argument validation should always run.
- Prefer catching `OperationCanceledException` rather than `TaskCanceledException` in general-purpose logic.
- Keep `CancellationToken` last in the parameter list; make it optional mainly on public APIs, not necessarily on internal methods.
### Stephen Cleary
- “Async all the way” is a strong design guideline, not an absolute law of physics. Sync bridges exist, but they are specialized boundary decisions, not a normal code review recommendation.
- `async void` and sync-over-async both create real observability and composition problems even when a sample appears to work.
## Naming and testability
### Naming
- TAP methods that return awaitable types conventionally use the `Async` suffix. Do not force renames when an interface, base class, or event pattern already dictates the name.
### Testability
- Favor awaitable APIs over hidden work so tests can await completion, assert faults, and drive cancellation deterministically.
- Prefer explicit background components, injected clocks, and owned queues over ad hoc fire-and-forget logic that tests cannot observe.
## Synthesis for agents
### Code review defaults
- Treat `.Result`, `.Wait()`, and `GetAwaiter().GetResult()` as likely defects unless the code is a deliberate sync boundary and the caller explicitly cannot be async.
- Prefer `Task`/`Task<T>` for API design. Require an explicit reason before recommending `ValueTask`.
- Require cancellation behavior to be coherent: accepted, propagated, and not silently dropped.
- Prefer `await Task.WhenAll(...)` for independent operations started before awaiting.
- Treat `Task.WhenAny(...)` as incomplete until the winner is awaited and losers are canceled, observed, or intentionally left running.
### Minimal examples
#### Avoid sync-over-async
```csharp
// bad
var user = client.GetUserAsync(id).Result;
// better
var user = await client.GetUserAsync(id);
```
#### Use `Task.WhenAll` for parallel I/O
```csharp
var userTask = repo.GetUserAsync(id, ct);
var ordersTask = repo.GetOrdersAsync(id, ct);
await Task.WhenAll(userTask, ordersTask);
return new Dashboard(await userTask, await ordersTask);
```
#### Be conservative with `ValueTask`
```csharp
// default
Task<Item?> GetAsync(string key, CancellationToken ct);
// specialized hot path only when justified
ValueTask<Item?> TryGetCachedAsync(string key);
```

View file

@ -0,0 +1,59 @@
---
description: >-
Authority notes and citations for the c# async best practices skill, separating
official documentation, expert interpretation, and synthesized guidance.
metadata:
tags: [sources, citations, authority, notes]
source: external
---
# Source Notes
## Official facts
- Microsoft Learn, "Implementing the Task-based Asynchronous Pattern"
- https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/implementing-the-task-based-asynchronous-pattern
- Return types, cancellation behavior, `Task.Run` boundaries, and TAP implementation guidance.
- Microsoft Learn, "Consuming the Task-based Asynchronous Pattern"
- https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/consuming-the-task-based-asynchronous-pattern
- `await`, `WhenAll`, `WhenAny`, cancellation propagation, and exception behavior.
- Microsoft Learn, "Async return types"
- https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types
- `Task`, `Task<T>`, `async void`, generalized async return types.
- Microsoft Learn, `ValueTask` API reference
- https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask
- single-consumer warnings and default-to-`Task` guidance.
- Microsoft Learn, ASP.NET Core best practices
- https://learn.microsoft.com/en-us/aspnet/core/fundamentals/best-practices
- avoid blocking calls, avoid unnecessary `Task.Run`, background-work cautions.
- Microsoft Learn, hosted services in ASP.NET Core
- https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services
- safe long-lived background work and cancellation during shutdown.
## Expert guidance used only when technically grounded
- Stephen Toub, ".NET Blog: ConfigureAwait FAQ"
- https://devblogs.microsoft.com/dotnet/configureawait-faq/
- best source for context capture semantics and library-vs-app guidance.
- Stephen Toub, ".NET Blog: Understanding the Whys, Whats, and Whens of ValueTask"
- https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/
- performance rationale and tradeoffs behind `ValueTask<T>`.
- Stephen Toub, ".NET Blog: Await, and UI, and deadlocks! Oh my!"
- https://devblogs.microsoft.com/dotnet/await-and-ui-and-deadlocks-oh-my/
- canonical deadlock explanation for context-bound code.
- Stephen Toub, ".NET Blog: Task Exception Handling in .NET 4.5"
- https://devblogs.microsoft.com/dotnet/task-exception-handling-in-net-4-5/
- explains `await` versus blocking exception shape and why `WhenAll` matters.
- Andrew Arnott, "Recommended patterns for CancellationToken"
- https://devblogs.microsoft.com/premier-developer/recommended-patterns-for-cancellationtoken/
- practical cancellation design heuristics; useful, but not treated as a language/runtime spec.
- Stephen Cleary, "Async/Await - Best Practices in Asynchronous Programming"
- https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming
- useful design interpretation, but older and treated as contextual guidance rather than current official policy.
## Where the skill is intentionally cautious
- `ConfigureAwait`: strong guidance exists for libraries, weaker guidance for app code. Blanket rules are rejected.
- `Task.Run`: valid for deliberate CPU offload, weak as a server-side patch for blocking I/O.
- `ValueTask`: supported and useful, but easy to misuse. The skill defaults to `Task` unless evidence is present.
- Fire-and-forget: acceptable only with explicit ownership and lifecycle design, especially in server code.

View file

@ -1,166 +0,0 @@
---
name: csharp-dotnet-cli-optimization
description: >-
Use when diagnosing or optimizing generic C#/.NET performance, GC pressure,
allocations, heap or stack usage, LINQ overhead, boxing, Span/Memory,
stackalloc, pooling, or hot-path code with CLI-first tools such as
dotnet-counters, dotnet-trace, dotnet-stack, dotnet-gcdump, dotnet-dump, or
BenchmarkDotNet.
metadata:
category: technique
triggers:
- dotnet-counters
- dotnet-trace
- dotnet-dump
- dotnet-gcdump
- dotnet-stack
- benchmarkdotnet
- allocations
- gc pressure
- memory leak
- hot path
- linq
- stackalloc
- span
- memory
- boxing
- heap
- stack
- latency
- throughput
- slow
- hang
- deadlock
---
# C#/.NET CLI Optimization
CLI-first guidance for generic C# 14 / .NET 10 performance work.
Use the references on demand:
- Read [references/memory-model-gc.md](references/memory-model-gc.md) for stack vs heap, generations, LOH, pinning, server vs workstation GC, and GC tuning limits.
- Read [references/code-patterns.md](references/code-patterns.md) for LINQ, Span/Memory, stackalloc, structs, boxing, pooling, strings, and analyzer-backed code patterns.
- Read [references/README.md](references/README.md) for dated sources and freshness notes.
## When to Use
- A .NET process is slow, allocation-heavy, CPU-heavy, or memory-hungry
- A live process appears stuck, hung, or deadlocked
- The user asks how heap, stack, GC, boxing, or LINQ overhead actually works in .NET
- The user wants concrete bad vs good code patterns after measurement has identified a hot path
- The task needs a decision between counters, traces, stacks, GC dumps, dumps, or a benchmark
**NOT for:**
- ASP.NET Core, EF Core, MAUI, Orleans, Unity, Avalonia, WPF, WinForms, Blazor, or other framework-specific playbooks
- Visual Studio, Rider, VS Code, PerfView, speedscope, or any GUI-first workflow
- speculative rewrites such as "replace everything with Span" before measurement
## Iron Rule
ALWAYS measure first, change second, and re-measure third.
NEVER claim an optimization without before/after evidence from the same scenario.
| Rationalization | Reality |
|---|---|
| "This is obviously slow" | The runtime, JIT, and libraries often invalidate intuition. |
| "struct means stack" | Value types are stored inline. They are not "always on the stack". |
| "All LINQ is slow" | .NET 10 improved many LINQ paths. Measure before rewriting. |
| "GC.Collect will fix it" | Forced collection usually treats symptoms, not cause. |
## Investigation Order
1. Use `dotnet-counters` for live triage.
2. If the process is stuck, capture `dotnet-stack` immediately.
3. If CPU or allocation hot paths matter, collect `dotnet-trace`.
4. If heap growth matters more than call paths, collect `dotnet-gcdump`.
5. If you need SOS heap inspection or a postmortem, collect `dotnet-dump`.
6. Only after live evidence points to a candidate routine, apply patterns from the reference docs.
7. If the change is truly local and isolated, use BenchmarkDotNet to compare implementations.
8. Re-run the original live capture to prove the real workload improved.
## Which Reference to Load
| User question | Read first |
|---|---|
| "How do stack and heap really work in .NET?" | `references/memory-model-gc.md` |
| "Why is GC pausing or why is LOH churn hurting us?" | `references/memory-model-gc.md` |
| "How should I optimize this LINQ?" | `references/code-patterns.md` |
| "Can I move this to the stack with stackalloc or Span?" | `references/code-patterns.md` |
| "Should this be a struct, ref struct, readonly struct, or class?" | `references/code-patterns.md` and `references/memory-model-gc.md` |
| "Why is this boxing?" | `references/code-patterns.md` |
## Tool Selection
| Question | Tool | What it answers | Typical next step |
|---|---|---|---|
| Is the live process allocating, GCing, or saturating CPU? | `dotnet-counters` | Live counters and trend direction | Capture a trace or GC dump if suspicious |
| Is the process hung or deadlocked right now? | `dotnet-stack` | Current managed stack snapshot | Collect a dump if you need deeper postmortem evidence |
| Which call paths consume CPU or allocate heavily? | `dotnet-trace` | Sampled execution and runtime events | Confirm hot paths, then isolate code |
| Which object types dominate managed heap usage? | `dotnet-gcdump` | Heap composition and type totals | Decide whether to redesign lifetimes or collect a full dump |
| Do I need SOS heap inspection or thread state? | `dotnet-dump` | Full dump plus CLI analysis | Run `analyze -c` commands |
| Did a code change improve one isolated routine? | BenchmarkDotNet | Reproducible microbenchmark comparison | Re-run live diagnostics in the real scenario |
## Pattern Guardrails
- Do not answer "put it on the stack" as a blanket goal. Explain lifetime, copies, boxing, and escape rules instead.
- Do not suggest `stackalloc` for unbounded sizes, large buffers, or loop-carried allocations.
- Do not recommend `Span<T>` for data that must cross `await`, escape to the heap, or live in object fields. Switch to `Memory<T>` or `ReadOnlyMemory<T>` for that.
- Do not recommend converting every `class` to a `struct`. Large, mutable, identity-bearing, or frequently boxed types often get worse.
- Do not blanket-rewrite LINQ to loops. Use analyzer-backed fixes first, and remember .NET 10 substantially improved many LINQ paths.
- Do not recommend pooling without ownership rules. Returned pooled arrays must not be reused by the caller.
- Do not recommend `GC.Collect()` except for rare, justified lifecycle boundaries, and only with measurement.
## Analyzer Radar
When performance diagnostics point to code patterns rather than runtime configuration, consult the current performance analyzers, especially:
- `CA1826`, `CA1827`, `CA1829`, `CA1836`, `CA1851`, `CA1860` for LINQ and enumeration
- `CA1845`, `CA1846`, `CA1858` for string and span-friendly APIs
- `CA1834`, `CA1865-CA1867` for `StringBuilder` char overloads
- `CA1870` for cached `SearchValues<T>`
These rules are clues, not goals. Apply them where the measured hot path justifies it.
## Minimal Commands
```bash
dnx dotnet-counters monitor --process-id <PID>
dotnet-counters monitor -p <PID> --counters System.Runtime
dotnet-stack report -p <PID>
dotnet-trace collect -p <PID> --duration 00:00:30
dotnet-trace report <trace.nettrace> topN
dotnet-gcdump collect -p <PID>
dotnet-gcdump report <file.gcdump>
dotnet-dump collect -p <PID> --type Heap
dotnet-dump analyze <dump> -c "dumpheap -stat" -c "exit"
```
Minimal BenchmarkDotNet pattern:
```csharp
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class CandidateBench
{
[Benchmark(Baseline = true)]
public int Original() => OriginalImpl();
[Benchmark]
public int Candidate() => CandidateImpl();
}
```
```bash
dotnet run -c Release
```
## Output
When using this skill, report:
- the measured symptom and the evidence used to identify it
- the chosen tool or code pattern and why it fits this bottleneck
- the relevant tradeoff, such as allocation vs copy cost, deferred vs eager execution, or stack vs pool
- the before/after result, or say explicitly if the recommendation is still unverified

View file

@ -1,68 +0,0 @@
---
description: Dated source ledger for csharp-dotnet-cli-optimization.
metadata:
tags: [sources, diagnostics, gc, linq, span, stackalloc, boxing]
---
# Sources
## Current primary sources
- [dotnet-counters](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters), Microsoft Learn, updated `2025-10-02`
- Canonical CLI docs for live counters, `monitor`, `collect`, and `dnx` one-shot execution on .NET 10.0.100+.
- [dotnet-trace](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-trace), Microsoft Learn, updated `2026-03-20`
- Canonical CLI docs for `collect`, `report`, and the preview `collect-linux` path plus its limits.
- [dotnet-dump](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-dump), Microsoft Learn, updated `2026-03-04`
- Canonical CLI docs for dump collection, dump types, and `analyze -c`.
- [dotnet-gcdump](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-gcdump), Microsoft Learn, updated `2025-12-17`
- Canonical CLI docs for GC dump collection, `report`, and the induced full Gen 2 GC caveat.
- [Fundamentals of garbage collection](https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals), Microsoft Learn, updated `2025-10-22`
- Current official overview of generations, allocation, and managed heap behavior.
- [Runtime configuration options for garbage collection](https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector), Microsoft Learn, updated `2025-11-22`
- Current official source for server vs workstation GC, background GC, heap limits, LOH threshold, and modern GC configuration behavior.
- [stackalloc expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc), Microsoft Learn, updated `2026-01-24`
- Current official guidance for stack allocation limits, loop avoidance, initialization, and Span-based usage.
- [ref struct types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct), Microsoft Learn, updated `2026-01-20`
- Current official guidance for stack-only semantics and escape restrictions.
- [Structure types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct), Microsoft Learn, updated `2026-01-14`
- Current official source for readonly structs, pass-by-reference guidance, and boxing conversions.
- [Value types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/value-types), Microsoft Learn, updated `2026-01-20`
- Current official source for copy semantics and inline storage behavior.
- [Boxing and Unboxing](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/types/boxing-and-unboxing), Microsoft Learn, updated `2025-10-13`
- Current official source for boxing semantics and cost.
- [Memory<T> and Span<T> usage guidelines](https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelines), Microsoft Learn, updated `2025-04-11`
- Current official guidance for choosing `Span<T>` vs `Memory<T>` and ownership rules.
- [Lambda expressions](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions), Microsoft Learn, updated `2026-01-24`
- Current official source for capture semantics and `static` lambdas.
- [What's new in C# 14](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14), Microsoft Learn, updated `2025-11-19`
- Current official confirmation of first-class span conversions in C# 14.
- [Performance rules](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/performance-warnings), Microsoft Learn, updated `2025-10-29`
- Current official index of analyzer-backed performance rules, including `CA1870`.
- [Performance Improvements in .NET 10](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/), Stephen Toub, published `2025-09-10`
- High-trust expert source showing real .NET 10 runtime and LINQ improvements. Use it to avoid stale folklore such as "all LINQ is slow".
## Specific analyzer pages used for code-pattern guidance
- [CA1827](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1827), updated `2023-11-14`
- [CA1845](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1845), updated `2024-11-12`
- [CA1846](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1846), updated `2023-12-16`
- [CA1851](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1851), updated `2023-11-14`
- [CA1858](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1858), current official analyzer page
- [CA1860](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1860), current official analyzer page
## Older but still canonical sources used cautiously
- [Reduce memory allocations using new C# features](https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/performance/), Microsoft Learn, updated `2023-10-17`
- Still useful for `ref`, `in`, readonly struct, and copy-avoidance guidance, but older than the core 2025-2026 docs.
- [Intermediate materialization](https://learn.microsoft.com/en-us/dotnet/standard/linq/intermediate-materialization), Microsoft Learn, updated `2022-09-02`
- Still canonical for LINQ materialization semantics.
- [Deferred execution and lazy evaluation](https://learn.microsoft.com/en-us/dotnet/standard/linq/deferred-execution-lazy-evaluation), Microsoft Learn, updated `2022-09-29`
- Still canonical for LINQ deferred-execution semantics.
- [dotnet-stack](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-stack), Microsoft Learn, updated `2023-03-14`
- Used only for narrow stack-snapshot guidance because the page is stale compared to the other diagnostics docs.
## Explicit exclusions
- Framework-specific tutorials were intentionally excluded.
- GUI-first analysis flows were intentionally excluded.
- MCC-specific paths, code, and hot-path examples were intentionally excluded.

View file

@ -1,267 +0,0 @@
---
name: csharp-optimization
description: >-
Use when optimizing C# code in MCC, reducing GC pressure, profiling hot paths,
fixing latency spikes, or reviewing code for allocation or throughput issues.
metadata:
category: technique
triggers: performance, allocations, GC, hot path, latency, throughput,
memory pressure, optimize, slow, freeze, lag spike, packet processing speed
---
# C# Performance Optimization for MCC
Hands-on optimization recipes for Minecraft Console Client hot paths.
Complements `csharp-best-practices` (conventions) with measurement-driven
performance work.
## When to Use
- Profiling or reducing GC pressure in a running MCC session
- Optimizing per-packet code (`Protocol18.HandlePacket`, `DataTypes.ReadNext*`)
- Optimizing per-tick code (`PlayerPhysics.Tick`, `CollisionDetector.Collide`)
- Speeding up chunk decoding (`Protocol18Terrain.ProcessChunkColumnData`)
- Improving A* pathfinding (`Movement.CalculatePath`)
- Reviewing any code change for allocation or throughput regressions
**NOT for:**
- Login, config parsing, or one-shot command handlers (prefer clarity there)
- Style/convention questions (use `csharp-best-practices` instead)
---
## Iron Rule: Measure First
**NEVER optimize without profiling data.**
Guessing which code is slow is wrong more often than right. Measure, change,
re-measure. If you cannot show a before/after number, the optimization is not
justified.
| Rationalization | Reality |
|-----------------|---------|
| "This is obviously slow" | Obvious to you is not obvious to the JIT. Measure. |
| "I'll profile later" | Later never comes. Profile now or don't optimize. |
| "It's just one allocation" | On a 20 TPS tick, one allocation = 20 per second = GC pressure. Measure. |
| "AggressiveInlining everywhere" | The JIT already inlines small methods. Prove it helps before adding. |
---
## MCC Hot-Path Map
Know which code runs at which frequency before deciding where to invest:
| Frequency | Key paths (actual files) | Priority |
|---|---|---|
| Per-packet (100s/sec) | `Protocol/Handlers/Protocol18.cs` HandlePacket, `Protocol/Handlers/DataTypes.cs` ReadNext* | **High** |
| Per-tick (20/sec) | `Physics/PlayerPhysics.cs` Tick, `Physics/CollisionDetector.cs` Collide, ChatBot `Update()` | **High** |
| Per-chunk-load | `Protocol/Handlers/Protocol18Terrain.cs` ProcessChunkColumnData, ReadBlockStatesField | Medium |
| Per-pathfind | `Mapping/Movement.cs` CalculatePath (A*) | Medium |
| Per-connection | Login, registry sync, config | Low |
| Per-user-action | Commands, chat | Low |
---
## Profiling Recipes
### 1. Live GC monitoring
```bash
dotnet-counters ps # find MinecraftClient PID
dotnet-counters monitor --process-id <PID> \
--counters System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,alloc-rate]
```
Healthy idle MCC: near-zero Gen-1/Gen-2 collections. Frequent Gen-0 during idle
means a hot-path allocation needs attention.
### 2. Allocation tracking
```bash
dotnet-trace collect --process-id <PID> \
--providers Microsoft-Windows-DotNETRuntime:0x1:5
```
Open `.nettrace` in PerfView to find top-allocated types and call stacks.
### 3. Isolated benchmarks (BenchmarkDotNet)
Extract the hot method, add `[MemoryDiagnoser]`. Key columns: **Mean**,
**Allocated**, **Gen0**.
---
## Allocation Reduction (Highest Impact)
Reducing GC pressure directly reduces latency spikes in a long-running client.
### Pattern: Reuse per-tick buffers
```csharp
// BEFORE: new List every tick (20 allocations/sec)
var result = new List<Aabb>();
// AFTER: thread-local reuse (0 allocations/sec)
[ThreadStatic] private static List<Aabb>? t_buf;
var result = t_buf ??= new List<Aabb>(64);
result.Clear();
```
`[ThreadStatic]` works when single-threaded and non-reentrant (physics tick).
If reentrant: use `ObjectPool<T>`. If cross-thread: use `ArrayPool<T>`.
### Pattern: stackalloc for small fixed buffers
MCC already does this in `DataTypes.cs` for endian-swapped reads:
```csharp
Span<byte> rawValue = stackalloc byte[8];
for (int i = 7; i >= 0; --i) rawValue[i] = cache.Dequeue();
return BitConverter.ToDouble(rawValue);
```
Rules: under 512 bytes, known size at compile time, never inside loops or recursion.
### Pattern: Span slicing instead of array copies
```csharp
// BEFORE: allocates
byte[] sub = new byte[length];
Array.Copy(source, offset, sub, 0, length);
// AFTER: zero-copy
ReadOnlySpan<byte> sub = source.AsSpan(offset, length);
```
Critical in packet parsing where many fields are sliced from one buffer.
---
## Hot-Path Tuning
### MethodImpl attributes
MCC uses `[MethodImpl]` on its hottest paths. Match the attribute to the method:
| Attribute | When | MCC examples |
|---|---|---|
| `AggressiveInlining` | Tiny methods (< ~32 bytes IL), called millions of times | `Vec3d.Add`, `Aabb.Intersects`, `Chunk.SetWithoutCheck` |
| `AggressiveOptimization` | Larger critical-path methods | `ReadBlockStatesField`, `ProcessChunkColumnData` |
| Both | Medium methods, very high frequency | `DataTypes.ReadNextVarInt`, `ReadDataReverse` |
| Neither | Infrequent code | Login, config, commands |
**Do not scatter `AggressiveInlining` without profiling evidence.** The JIT
already inlines small methods.
### BinaryPrimitives over BitConverter
```csharp
// BEFORE: manual endian swap
(buf[0], buf[3]) = (buf[3], buf[0]);
int val = BitConverter.ToInt32(buf);
// AFTER: direct big-endian read, no branch
int val = BinaryPrimitives.ReadInt32BigEndian(buf);
```
### MemoryMarshal for bulk reads
Already used in chunk decoding for zero-copy packed-long reads:
```csharp
ReadOnlySpan<long> longs = MemoryMarshal.Cast<byte, long>(entryData);
```
---
## Data Structure Selection
### Frozen collections for palettes
Palette maps are built once and read millions of times. `FrozenDictionary`
gives ~50% faster reads than `Dictionary`:
```csharp
private static readonly FrozenDictionary<int, Material> s_palette =
new Dictionary<int, Material> { ... }.ToFrozenDictionary();
```
Apply to: `BlockPalettes/*.cs`, `EntityPalettes/*.cs`, `ItemPalettes/*.cs`,
`PacketPalettes/*.cs`, any `static readonly Dictionary` populated once.
### PriorityQueue for A*
`Movement.cs` has a custom `BinaryHeap`. The built-in `PriorityQueue<TElement,
TPriority>` (.NET 6+) is well-optimized and avoids maintenance burden.
### ConcurrentDictionary sizing
Pre-size `World.chunks` to avoid rehashing:
```csharp
new ConcurrentDictionary<(int, int), ChunkColumn>(
concurrencyLevel: Environment.ProcessorCount, capacity: 1024);
```
---
## Threading
### Minimize lock scope
Copy data out under the lock, process outside:
```csharp
List<Item> snapshot;
lock (_lock) { snapshot = [.. _items]; }
foreach (var item in snapshot) ExpensiveProcess(item);
```
### Batch InvokeOnMainThread
Each `InvokeOnMainThread()` call blocks until the main thread runs it.
In loops, batch into a single call:
```csharp
handler.InvokeOnMainThread(() =>
{
foreach (var entity in entities) UpdateEntity(entity);
});
```
### Channel\<T\> over BlockingCollection\<T\>
Lower overhead, async-friendly:
```csharp
var ch = Channel.CreateUnbounded<(int Id, Memory<byte> Data)>(
new UnboundedChannelOptions { SingleReader = true });
```
---
## Common Optimization Anti-Patterns
These are things agents (and humans) rationalize doing. Every one of them
makes performance worse or wastes effort.
| Anti-pattern | Why it's wrong |
|---|---|
| Adding `AggressiveInlining` to large methods | Bloats call sites, causes more cache misses, makes code *slower* |
| Optimizing login/config code | Runs once per session; clarity matters more than speed |
| Using `ConcurrentDictionary` where a plain `Dictionary` + lock suffices | Concurrent overhead on uncontested paths costs more than a lock |
| Replacing LINQ with manual loops on cold paths | No measurable gain, worse readability |
| Caching mutable state to avoid re-reads | Stale cache bugs are harder to diagnose than the perf hit |
| `Task.Result` / `.Wait()` on hot paths | Deadlock risk and thread-pool starvation |
---
## Pre-Commit Checklist
ALWAYS verify before submitting a performance change:
- [ ] Hot path identified with profiling data, not guesswork
- [ ] Before/after measurements recorded (allocation count, throughput, or latency)
- [ ] No new allocations inside per-tick or per-packet methods
- [ ] `[MethodImpl]` attributes match method call frequency and IL size
- [ ] Frozen collections used for any static lookup table
- [ ] Lock scopes contain no I/O or expensive work
- [ ] No `Task.Result`, `.Wait()`, or `GetAwaiter().GetResult()` on hot paths
- [ ] Thread safety preserved (checked existing lock/concurrent patterns)
- [ ] Optimization comments explain non-obvious choices
- [ ] Code still compiles and passes all existing checks

View file

@ -0,0 +1,342 @@
---
name: dotnet-performance-profiling-and-optimization
description: >-
Use when a .NET process is slow, hung, memory-heavy, or deadlocked, or when
analyzing C#/ASP.NET Core code for performance anti-patterns across memory,
async, LINQ, database, JSON, caching, DI, concurrency, HttpClient, exceptions,
response, strings, startup, and metrics.
metadata:
category: technique
triggers:
- dotnet-counters
- dotnet-trace
- dotnet-dump
- dotnet-gcdump
- dotnet-stack
- benchmarkdotnet
- allocations
- gc pressure
- memory leak
- hot path
- linq
- stackalloc
- span
- boxing
- heap
- latency
- throughput
- slow
- hang
- deadlock
- optimize
- performance
- async
- caching
- di lifetime
- ef core
- cosmosdb
- json serialization
- httpclient
- middleware
version: 1.1.0
platform: ".NET 8 and .NET 10 (no .NET 9 projects in scope)"
---
# .NET Performance: Diagnostic & Code Review
Unified C#/.NET performance skill targeting **.NET 8 and .NET 10**. Two modes: live process diagnostics (Mode A) and static code optimization review with fixes (Mode B).
## Step 0 — Detect the target framework
Before recommending APIs, follow `../../references/detect-target-framework.md`. Many .NET 9+ APIs (`HybridCache`, `MemoryExtensions.Split` for spans, `Dictionary.GetAlternateLookup`, `params ReadOnlySpan<T>`) do **not** exist on .NET 8 — the references below mark the floor for each pattern, and you must downgrade to the .NET 8 fallback when the target is `net8.0`. Stephen Toub's posts are the primary benchmark source:
[Performance Improvements in .NET 8](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/) ·
[Performance Improvements in .NET 10](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/).
## References
Load on demand:
- [references/memory-model-gc.md](references/memory-model-gc.md) — stack vs heap, generations, LOH, boxing, GC tuning
- [references/code-patterns.md](references/code-patterns.md) — LINQ, Span/Memory, stackalloc, structs, boxing, pooling, strings (conceptual framing)
- [references/categories.md](references/categories.md) — 14 optimization category definitions with checks and grep patterns (Mode B spine)
- [references/grep-patterns.md](references/grep-patterns.md) — consolidated anti-pattern grep library for Phase 1 scanning
- [references/measurement-guide.md](references/measurement-guide.md) — BenchmarkDotNet, k6, dotnet-counters, KPI targets, CI/CD
Pattern catalogs (with measured impact numbers, ❌/✅ pairs, and per-topic Detection recipes):
- [references/critical-patterns.md](references/critical-patterns.md) — 17 🔴 patterns: deadlocks, order-of-magnitude regressions, excessive allocations
- [references/async-patterns.md](references/async-patterns.md) — sync-over-async, ValueTask hot paths, Channels, false sharing
- [references/memory-and-strings.md](references/memory-and-strings.md) — `u8` literals, `Span.Split`/`TryWrite`, compound `+=`, chained `.Replace()`
- [references/collections-and-linq.md](references/collections-and-linq.md) — `FrozenDictionary`, `GetAlternateLookup`, `CollectionsMarshal.GetValueRefOrAddDefault`, hoisting static data
- [references/regex-patterns.md](references/regex-patterns.md) — `[GeneratedRegex]`, `IsMatch`, `EnumerateMatches`, `NonBacktracking`
- [references/io-and-serialization.md](references/io-and-serialization.md) — `HttpCompletionOption.ResponseHeadersRead`, `useAsync` `FileStream`, `Memory<byte>` overloads
- [references/structural-patterns.md](references/structural-patterns.md) — sealed-class devirtualization (absence pattern, scale-based severity)
Reference loading guide for Mode B by signal:
| Signal in Code | Load |
|---|---|
| `async`, `await`, `Task`, `ValueTask` | `async-patterns.md` |
| `Span<`, `Memory<`, `stackalloc`, `string.Substring`, `+=` in loops, `params` | `memory-and-strings.md` |
| `Regex`, `[GeneratedRegex]`, `Regex.Match`, `RegexOptions.Compiled` | `regex-patterns.md` |
| `Dictionary<`, `List<`, `.ToList()`, LINQ chains, `static readonly Dictionary<` | `collections-and-linq.md` |
| `JsonSerializer`, `HttpClient`, `Stream`, `FileStream` | `io-and-serialization.md` |
| Any code review on a hot path | always check `critical-patterns.md` first |
| Codebase-wide scans (sealed classes, static `Dictionary``FrozenDictionary`) | `structural-patterns.md` |
## Iron Rule
**Always measure first, change second, and re-measure third.**
Never claim an optimization without before/after evidence from the same scenario.
| Rationalization | Reality |
|---|---|
| "This is obviously slow" | The runtime, JIT, and libraries often invalidate intuition. |
| "struct means stack" | Value types are stored inline — not always on the stack. |
| "All LINQ is slow" | .NET 9+ improved many LINQ paths. Measure before rewriting. |
| "GC.Collect will fix it" | Forced collection treats symptoms, not cause. |
| "Too small to matter" | MEDIUM+ impact is cumulative across the request pipeline. |
| "I'll change the DI lifetime while I'm here" | DI lifetime changes require explicit user approval. |
| "Need to refactor to optimize" | Optimization fixes must be surgical. Refactoring is a separate task. |
| "Tests pass so fix is correct" | Tests passing = behavior preserved. Still verify the metric improved. |
## Mode Selection
| Situation | Mode |
|---|---|
| Live process: slow, high CPU/memory, hung, deadlocked, GC pauses | **A Diagnostic** |
| Asking how GC, heap, boxing, or LINQ overhead works in .NET | **A Diagnostic** (conceptual) |
| Code to analyze for anti-patterns, then fix | **B Code Review** |
| Both a running process AND code to fix | Start with **A**, then **B** on hot paths identified |
**Not for:** Visual Studio, Rider, PerfView, speedscope, or GUI-first workflows.
---
## Mode A: Diagnostic (Live Process)
### Investigation Order
1. **`dotnet-counters`** — always start here for live triage.
2. **`dotnet-stack`** — immediately if process is stuck, hung, or deadlocked.
3. **`dotnet-trace`** — if CPU or allocation hot paths matter.
4. **`dotnet-gcdump`** — if heap growth matters more than call paths.
5. **`dotnet-dump`** — if SOS heap inspection or postmortem analysis is needed.
6. After live evidence identifies a candidate routine, apply patterns from [references/code-patterns.md](references/code-patterns.md) and [references/memory-model-gc.md](references/memory-model-gc.md).
7. Use BenchmarkDotNet if the change is isolated and needs microbenchmark comparison.
8. Re-run the original live capture to prove the real workload improved.
### CLI Tool Selection
| Question | Tool | What it answers |
|---|---|---|
| Is the process allocating, GCing, or saturating CPU? | `dotnet-counters` | Live counters and trend direction |
| Is the process hung or deadlocked right now? | `dotnet-stack` | Current managed stack snapshot |
| Which call paths consume CPU or allocate heavily? | `dotnet-trace` | Sampled execution and runtime events |
| Which object types dominate managed heap? | `dotnet-gcdump` | Heap composition and type totals |
| Need SOS heap inspection or thread state? | `dotnet-dump` | Full dump plus CLI analysis |
| Did a code change improve an isolated routine? | BenchmarkDotNet | Reproducible microbenchmark comparison |
### Minimal CLI Commands
```bash
dotnet-counters monitor -p <PID> --counters System.Runtime
dotnet-counters monitor -n <ProcessName> --counters System.Runtime,Microsoft.AspNetCore.Hosting
dotnet-stack report -p <PID>
dotnet-trace collect -p <PID> --duration 00:00:30
dotnet-trace report <trace.nettrace> topN
dotnet-gcdump collect -p <PID>
dotnet-gcdump report <file.gcdump>
dotnet-dump collect -p <PID> --type Heap
dotnet-dump analyze <dump> -c "dumpheap -stat" -c "exit"
```
Minimal BenchmarkDotNet pattern:
```csharp
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
public class CandidateBench
{
[Benchmark(Baseline = true)]
public int Original() => OriginalImpl();
[Benchmark]
public int Candidate() => CandidateImpl();
}
```
```bash
dotnet run -c Release
```
### Reference Loading Guide
| User question | Load first |
|---|---|
| "How do stack and heap really work in .NET?" | `references/memory-model-gc.md` |
| "Why is GC pausing or why is LOH churn hurting us?" | `references/memory-model-gc.md` |
| "How should I optimize this LINQ?" | `references/code-patterns.md` |
| "Can I move this to the stack with stackalloc or Span?" | `references/code-patterns.md` |
| "Should this be a struct, ref struct, readonly struct, or class?" | Both |
| "Why is this boxing?" | `references/code-patterns.md` |
### Diagnostic Output
Report: measured symptom + evidence (counter values, trace hotspots, heap stats) · chosen tool and why · relevant tradeoff (allocation vs copy, deferred vs eager, stack vs pool) · before/after result, or explicitly state if still unverified.
---
## Mode B: Code Review (Static Analysis)
### Target
`$ARGUMENTS` is the optimization target:
- **File path**: Analyze that file and its close dependencies.
- **Directory**: Analyze all C# files in that directory.
- **"all"**: Scan the solution with Grep, deep-dive the worst offenders.
- **`--fix`** anywhere: Skip confirmation and apply fixes after analysis.
- **Empty**: Check `git diff --name-only HEAD~5 -- '*.cs'` for recently changed files. If none, ask the user.
### Phase 1: Discovery
1. **Glob** to find `.cs` files matching the target.
2. **Read** file contents. For files under 500 lines, read the whole file first — visual inspection catches patterns faster than grep, then grep confirms counts.
3. **Detect signals** in the code (async, Span, Regex, Dictionary, JsonSerializer, etc.) and **load matching pattern catalogs** from the per-topic references listed at the top of this file.
4. **Grep** for anti-patterns. Run the recipes in [references/grep-patterns.md](references/grep-patterns.md) plus the per-topic Detection sections in the catalogs you loaded.
5. **Emit a scan execution checklist** before classifying — list each recipe and the hit count. **0 hits is valid and valuable** (confirms good practice).
### Phase 2: Analysis (Read-Only)
Check each file against all 14 categories. Record per finding: **file path, line number, current pattern, recommended pattern, impact level, category**.
Read [references/categories.md](references/categories.md) for detailed check definitions.
#### Compound Allocation Check
Single-line grep recipes miss multi-allocation patterns. After running scan recipes, look for:
1. **Branched `.Replace()` chains** — methods that call `.Replace()` across multiple `if/else` branches. Report total allocation count across all branches, not just per-line.
2. **Cross-method chaining** — public method A calls B (which does 3 regex replaces) then calls C (which allocates). Report the total chain cost as one finding, not per-method.
3. **Compound `+=` with embedded allocating calls**`result += $"...{Foo().ToLower()}"` is 2+ allocations (interpolation + `ToLower` + concatenation). Flag the compound cost, not just `.ToLower()`.
4. **`string.Format` specificity** — distinguish resource-loaded format strings (not fixable) from compile-time literal format strings (fixable with interpolation). Enumerate only the actionable sites.
#### Cross-File Consistency Check
If an optimized pattern is found in one file, check whether sibling files (same directory, same interface, same base class) use the un-optimized equivalent. Flag as MEDIUM with the optimized file as evidence.
#### Verify-the-Inverse Rule
For absence patterns (e.g., unsealed classes, static `Dictionary` not converted to `FrozenDictionary`, `RegexOptions.Compiled` not migrated to `[GeneratedRegex]`), always count both sides and report the **N-of-M ratio**, not just the count of bad cases. The ratio determines severity:
- 0/185 sealed → systematic codebase-wide issue
- 12/15 sealed → consistency fix on the remaining 3
- 50/100 sealed → mid-migration; flag the laggards
| # | Category | Code | Focus |
|---|---|---|---|
| 1 | Memory Allocation | MEM | Span, ArrayPool, pooling, stackalloc, string optimization, collections |
| 2 | Async Anti-Patterns | ASYNC | Blocking, ValueTask, CancellationToken, IAsyncEnumerable, Channel |
| 3 | LINQ Inefficiencies | LINQ | Count vs Any, multiple enumeration, filter/project order |
| 4 | Database | DB | EF Core, CosmosDB patterns, N+1, partition keys, RU cost |
| 5 | JSON Serialization | JSON | Options reuse, source generators, serializer boundaries |
| 6 | Caching | CACHE | HybridCache, stampede protection, output cache, size limits |
| 7 | DI Lifetimes | DI | Captive dependencies, lifetime mismatches, IOptions patterns |
| 8 | Concurrency | CONC | Lock contention, throttling, thread safety, Channel patterns |
| 9 | HttpClient | HTTP | IHttpClientFactory, resilience, response disposal |
| 10 | Exception Control Flow | EXC | Try/catch for expected paths, broad catches |
| 11 | Response Optimization | RESP | Compression, pagination, ETags |
| 12 | String Optimization | STR | Concatenation loops, ToLower/ToUpper, String.Format |
| 13 | Startup & Pipeline | STARTUP | Middleware ordering, compression, health checks, PGO |
| 14 | Metrics & Observability | METRICS | IMeterFactory, histograms, tag cardinality, OpenTelemetry |
### Phase 3: Report
```
## Performance Analysis Report
### Summary
- Files analyzed: N
- Total findings: N
- Critical (HIGH): N | Moderate (MEDIUM): N | Minor (LOW): N
### Findings by Category
#### [CATEGORY_NAME] (N findings)
| # | Impact | File:Line | Issue | Recommendation |
|---|--------|-----------|-------|----------------|
| 1 | HIGH | `path/File.cs:42` | Current anti-pattern | Recommended fix |
### Prioritized Action List
1. [HIGH] Fix blocking async calls in X — thread pool starvation risk
2. [MEDIUM] Switch to ArrayPool in Z — reduces GC pressure on upload path
```
**Impact levels:**
- **HIGH**: Measurable gain, prevents starvation, fixes correctness, reduces P95 latency. Examples: blocking async, missing CancellationToken, N+1 queries, captive dependencies.
- **MEDIUM**: Reduces allocations, GC pressure, or unnecessary work. Examples: ArrayPool, StringBuilder, FrozenDictionary.
- **LOW**: Minor improvements, cold-path optimizations. Examples: initial collection capacity, Count() vs Any().
**Scale-based severity escalation.** When the same anti-pattern appears across many instances, escalate:
- 110 instances → report at the pattern's base severity
- 1150 instances → escalate LOW patterns to MEDIUM
- 50+ instances → MEDIUM with elevated priority; flag as a codebase-wide systematic issue
Always report **exact counts from scan recipes**, not estimates. Group findings by severity (HIGH → MEDIUM → LOW), not by file. Merge related findings that share the same fix (e.g., all `.ToLower()` calls in one finding, not split per file).
### Phase 4: Optimization (Apply Fixes)
After presenting the report:
- If `--fix` in `$ARGUMENTS`, proceed directly.
- Otherwise ask: "Would you like me to apply these optimizations? I'll work one category at a time, starting with HIGH impact. You can specify categories or findings (e.g., 'fix ASYNC and MEM' or 'fix #1, #3')."
**Before any fix:**
1. **Read actual code context** around the grep match — false positives exist (`.Result` in `Task.FromResult` is NOT blocking).
2. Confirm the finding is real. If uncertain, flag as "needs manual review."
**Applying fixes:**
1. One category at a time, highest impact first. Use Edit tool with brief before/after summary.
2. After each category: `dotnet build --no-restore`
3. After all changes: `dotnet test`
4. If build or tests fail, diagnose before continuing.
---
## Analyzer Radar
- `CA1826`, `CA1827`, `CA1829`, `CA1836`, `CA1851`, `CA1860` — LINQ and enumeration
- `CA1845`, `CA1846`, `CA1858` — string and span-friendly APIs
- `CA1834`, `CA1865``CA1867` — StringBuilder char overloads
- `CA1870` — cached `SearchValues<T>`
These are clues, not goals. Apply where measured hot paths justify it.
## Pattern Guardrails
- Do not say "put it on the stack" as a blanket goal. Explain lifetime, copies, boxing, and escape rules.
- Do not suggest `stackalloc` for unbounded sizes, large buffers, or loop-carried allocations.
- Do not recommend `Span<T>` for data that crosses `await`, escapes to the heap, or lives in object fields — use `Memory<T>`.
- Do not recommend converting every `class` to a `struct` — large, mutable, or frequently boxed types often get worse.
- Do not blanket-rewrite LINQ to loops — use analyzer-backed fixes first.
- Do not recommend pooling without ownership rules — returned pooled arrays must not be reused by the caller.
- Do not recommend `GC.Collect()` except for rare justified lifecycle boundaries with measurement.
## Red Flags — STOP and Confirm
Stop and ask before:
- Changing `Program.cs` or the middleware pipeline
- Adding a new NuGet package
- Changing any DI service lifetime registration
- Replacing the serializer in the HTTP pipeline
- Modifying API response shapes or route patterns
- Changing error handling patterns
## Constraints
- NEVER add NuGet packages without user approval
- NEVER change DI lifetimes without explaining implications and getting confirmation
- NEVER modify `Program.cs` or middleware pipeline without explicit approval
- NEVER change API contracts, route patterns, or response shapes
- ALWAYS preserve existing tests; update only if behavior intentionally changes
- ALWAYS use the Grep tool for searches, never bash `grep` or `find`
Consult the project's CLAUDE.md or AGENTS.md for project-specific rules and constraints.

View file

@ -0,0 +1,112 @@
# Async & Concurrency Patterns
### Don't Expose Async Wrappers for Sync Methods
🟡 **AVOID** wrapping sync methods with `Task.Run` in libraries | .NET Core+
```csharp
public Task<int> ComputeHashAsync(byte[] data) =>
Task.Run(() => ComputeHash(data));
```
```csharp
public int ComputeHash(byte[] data) { /* CPU-bound work */ }
// Consumer decides: var hash = await Task.Run(() => lib.ComputeHash(data));
```
**Impact: Eliminates unnecessary thread pool queue/dequeue overhead per call.**
### Don't Expose Sync Wrappers for Async Methods
🟡 **AVOID** creating sync wrappers that block on async implementations | .NET Core+
```csharp
public string GetData() => GetDataAsync().Result;
```
```csharp
public async Task<string> GetDataAsync() { /* ... */ }
```
**Impact: Prevents deadlocks and thread pool starvation from hidden sync-over-async blocking.**
### Use ValueTask for Hot Paths with Frequent Sync Completion
🟡 **DO** use `ValueTask<T>` on hot paths where sync completion is common | .NET Core 2.1+
```csharp
public async Task<int> ReadAsync(Memory<byte> buffer)
{
if (_bufferedCount > 0)
return ReadFromBuffer(buffer.Span);
return await ReadAsyncCore(buffer);
}
```
```csharp
public ValueTask<int> ReadAsync(Memory<byte> buffer)
{
if (_bufferedCount > 0)
return new ValueTask<int>(ReadFromBuffer(buffer.Span));
return new ValueTask<int>(ReadAsyncCore(buffer));
}
```
**Impact: Eliminates Task\<T\> allocation on synchronous completion — the struct stores results inline.**
### Use Channels for Producer/Consumer
🟡 **DO** use `System.Threading.Channels` for producer-consumer patterns | .NET Core 3.0+
```csharp
var queue = new BlockingCollection<WorkItem>();
var item = queue.Take();
```
```csharp
var channel = Channel.CreateUnbounded<WorkItem>();
// Producer
await channel.Writer.WriteAsync(item);
// Consumer
await foreach (var item in channel.Reader.ReadAllAsync())
Process(item);
```
**Impact: ~25% faster, ~95% fewer GC collections vs manual approaches.**
### Avoid False Sharing with Thread-Local State
🟡 **AVOID** adjacent mutable fields written by different threads | .NET 7+
```csharp
class SharedCounters
{
public long Counter1;
public long Counter2;
}
```
```csharp
[StructLayout(LayoutKind.Explicit, Size = 128)]
struct PaddedCounter
{
[FieldOffset(0)] public long Value;
}
```
**Impact: Eliminates cross-core cache invalidation — can improve multi-threaded throughput by 10x+.**
## Detection
Scan recipes for async anti-patterns. Run these and report exact counts.
```bash
# async void methods (correctness issue — crashes on exception)
grep -rn --include='*.cs' 'async void' --exclude-dir=bin --exclude-dir=obj . | grep -v 'event' | wc -l
```
### Patterns Requiring Manual Review
- **Sync-over-async** (`.Result`, `.Wait()`): `.Result` matches any property named Result — needs type context to confirm it's `Task.Result`

View file

@ -0,0 +1,297 @@
# Optimization Category Definitions
Complete check definitions for all 14 optimization categories. Each category lists specific checks to perform, with grep patterns at the end for automated scanning.
---
## Category 1: Memory Allocation (MEM)
Checks:
- **Unnecessary string allocations**: `Substring()` calls that could use `Span<char>` or `AsSpan()`. String concatenation with `+` inside loops (should use `StringBuilder` or `string.Create`).
- **Missing ArrayPool/MemoryPool usage**: `new byte[...]` for temporary buffers, especially in I/O paths. Should use `ArrayPool<byte>.Shared.Rent()` with try/finally Return.
- **Large Object Heap triggers**: Allocations of objects >= 85,000 bytes (arrays, large strings, `MemoryStream` without `RecyclableMemoryStream`).
- **Missing object pooling**: Frequently created/disposed objects (like `StringBuilder`) that could use `ObjectPool<T>`.
- **Record class vs record struct**: Small, immutable DTOs that are `record class` but could be `readonly record struct` to avoid heap allocation.
- **Boxing**: Value types cast to `object` or non-generic interfaces. Structs without `IEquatable<T>`.
- **Collection inefficiencies**: `new List<T>()` or `new Dictionary<K,V>()` without initial capacity when size is known or estimable. Double-lookup patterns (`TryGetValue` + indexer set) that could use `CollectionsMarshal.GetValueRefOrAddDefault`. Read-only dictionaries populated once that could be `FrozenDictionary<K,V>` (.NET 8+).
- **stackalloc for small buffers**: Flag `new byte[N]` where N <= 256 in synchronous methods. Recommend `Span<byte> buffer = stackalloc byte[N]` for short-lived stack allocation with zero GC pressure.
- **string.Create for pre-sized construction**: When output string length is known at call time, `string.Create(length, state, action)` avoids intermediate allocations by writing directly into the final buffer.
- **Interpolated strings in logging** *(Impact: LOW — only matters when the log level is inactive at runtime)*: `_logger.LogXxx($"...")` allocates the interpolated string even when the log level is disabled. Use structured logging parameters `_logger.LogXxx("Message {Param}", value)` or the `[LoggerMessage]` source generator for high-frequency hot paths.
- **ReadOnlySpan for string parsing**: Flag `.Split()` and `.Substring()` in hot paths where `AsSpan()` slicing avoids allocation. Common in string parsing, normalization, and URL handling.
- **CollectionsMarshal.GetValueRefOrAddDefault**: Flag the TryGetValue + indexer set double-lookup pattern. Single-lookup alternative reduces dictionary operations by 50%.
- **RecyclableMemoryStream**: Flag `new MemoryStream()` in I/O-heavy paths (blob upload/download, log writes). `Microsoft.IO.RecyclableMemoryStream` pools internal buffers and avoids LOH fragmentation.
Grep patterns:
```
\.Substring\(
new byte\[
new MemoryStream\(\)
new StringBuilder\(\)
new List<.*>\(\)
new Dictionary<.*>\(\)
_logger\.Log(Debug|Trace|Information|Warning|Error|Critical)\(\$"
\.Split\(
```
---
## Category 2: Async Anti-Patterns (ASYNC)
Checks:
- **Blocking async calls**: `.Result`, `.Wait()`, `.GetAwaiter().GetResult()` -- causes thread pool starvation. CRITICAL finding.
- **async void**: Methods declared `async void` (except event handlers) -- swallows exceptions and cannot be awaited.
- **Missing CancellationToken**: Async methods that do not accept or propagate `CancellationToken`. Every async method should have `CancellationToken cancellationToken = default`.
- **Missing ValueTask**: Methods that frequently return cached/synchronous results but use `Task<T>` instead of `ValueTask<T>`. Look for `if (cache.TryGetValue(...)) return Task.FromResult(...)`. Benchmark data: ValueTask 7.41ns/0B vs Task 15.23ns/72B.
- **Sequential awaits that could parallelize**: Multiple independent `await` calls in sequence that could use `Task.WhenAll`.
- **Async over sync**: Methods that use `Task.Run` to wrap synchronous code in an ASP.NET Core context (unnecessary and wastes a thread).
- **ConfigureAwait(false) in library projects**: LOW/INFO level. Not required in ASP.NET Core (no sync context), but recommended if library assemblies may be reused outside ASP.NET Core.
- **IAsyncEnumerable opportunities**: Methods returning `Task<List<T>>` where the caller iterates sequentially. If the caller processes items one-by-one, `IAsyncEnumerable<T>` reduces memory and improves time-to-first-byte.
- **Channel verification**: Verify `BoundedChannelOptions` has `SingleReader`/`SingleWriter` hints set for performance. Verify `CancellationToken` is propagated on `WriteAsync` and `ReadAllAsync`.
Grep patterns:
```
\.Result[^s]
\.Wait\(\)
\.GetAwaiter\(\)\.GetResult\(\)
async void
Task\.Run\(
\.WriteAsync\([^,]*\)
```
---
## Category 3: LINQ Inefficiencies (LINQ)
Checks:
- **Count() > 0 or Count() == 0**: Should use `Any()` or `!Any()`. `Count()` may enumerate the entire collection.
- **Multiple enumeration**: An `IEnumerable<T>` variable used more than once without materializing.
- **Filter after projection**: `.Select(...).Where(...)` -- should filter first, then project.
- **ToList() too early**: `.ToList().Where(...)` or `.ToList().Select(...)` -- materializes before filtering.
- **OrderBy before Where**: Sorting the full collection before filtering it down.
Grep patterns:
```
\.Count\(\) [><=!]
\.ToList\(\)\.Where\(
\.ToList\(\)\.Select\(
\.Select\(.*\)\.Where\(
\.OrderBy.*\.Where\(
```
---
## Category 4: Database (DB)
Check for both EF Core and CosmosDB patterns depending on what the project uses. Consult the project's CLAUDE.md or AGENTS.md for the data access strategy.
Checks:
- **N+1 queries**: Loops that call the database inside each iteration.
- **Missing AsNoTracking**: EF Core read-only queries without `.AsNoTracking()`.
- **Missing compiled queries**: Frequently executed EF Core queries on hot paths without `EF.CompileAsyncQuery`.
- **Full entity loading**: Fetching entire entities when only a few fields are needed (should project to DTOs).
- **Missing AsSplitQuery**: EF Core queries with multiple `.Include()` calls without `.AsSplitQuery()`.
- **CosmosDB partition key misuse**: Operations not specifying the partition key, or using cross-partition queries unnecessarily.
- **CosmosDB point reads**: Using queries instead of `ReadItemAsync` when both `id` and partition key are known.
- **RU cost awareness**: Flag discarded `ItemResponse<T>` without logging `RequestCharge`. Recommend tracking RU cost via metrics for cost visibility.
- **Cross-partition query detection**: `GetItemLinqQueryable()` without partition key option leads to fan-out queries. Verify all LINQ queryables specify the partition key.
- **Indexing policy review**: Flag if queries filter on fields that likely lack composite indexes.
- **Redundant round-trips**: Flag patterns where a query fetches an ID, then a separate point read fetches the full document. Recommend a single query.
- **EnableContentResponseOnWrite = false**: On write operations where the response body is not needed, setting this option reduces RU cost.
Grep patterns:
```
\.Include\(.*\.Include\(
await.*foreach.*await.*Async
ReadItemAsync
GetItemQueryIterator
GetItemLinqQueryable
\.RequestCharge
```
---
## Category 5: JSON Serialization (JSON)
Checks:
- **New JsonSerializerOptions per call**: `new JsonSerializerOptions { ... }` inside method bodies -- rebuilds the metadata cache every time. Should use a static readonly instance.
- **Missing source generators**: High-throughput serialization paths without `[JsonSerializable]` source generation context.
- **Newtonsoft.Json in hot paths**: If the project uses Newtonsoft.Json for MVC, flag any hot-path internal serialization that could benefit from `System.Text.Json` with source generators. NEVER suggest replacing the controller/DTO serializer without checking the project's documented constraints.
- **System.Text.Json source generators for internal serialization**: Internal paths (database serialization, audit logs, blob metadata) that don't affect API contracts are candidates for `System.Text.Json` with source generators.
- **JsonSerializerSettings singleton**: Flag `new JsonSerializerSettings()` in method bodies. The contract resolver cache is rebuilt each time. Use a static readonly instance or inject via DI.
- **CosmosDB SDK serializer**: The Cosmos SDK supports custom serializers. `CosmosSystemTextJsonSerializer` with source generators reduces allocation on read/write operations.
Grep patterns:
```
new JsonSerializerOptions
JsonConvert\.Serialize
JsonConvert\.Deserialize
new JsonSerializer
new JsonSerializerSettings
```
---
## Category 6: Caching (CACHE)
Checks:
- **Repeated expensive calls without caching**: Service methods that call external APIs on every request without caching the result.
- **Missing HybridCache pattern**: Look for manual cache-aside patterns that could use `HybridCache` for built-in stampede protection, two-level caching (L1 memory + L2 distributed), and tag invalidation. **HybridCache requires .NET 10 (or .NET 9) — the `Microsoft.Extensions.Caching.Hybrid` package does not target .NET 8.** On .NET 8 implement `IMemoryCache` (L1) + `IDistributedCache` (L2) manually with a `SemaphoreSlim` keyed by cache key for stampede protection. See [HybridCache GA announcement](https://devblogs.microsoft.com/dotnet/hybrid-cache-is-now-ga/).
- **Static data fetched repeatedly**: Configuration, taxonomies, or lookup data fetched from external APIs that rarely changes.
- **Missing output caching**: Read-only GET endpoints that return the same data for all callers -- candidates for `[OutputCache]`.
- **HybridCache upgrade path (.NET 10 only)**: Manual L1+L2 caching with `SemaphoreSlim` stampede protection can migrate to `HybridCache` `GetOrCreateAsync` with built-in stampede protection and tag invalidation **when the project target is `net10.0`**. On `net8.0` the manual pattern is the correct end state, not a stepping stone.
- **Cache stampede detection**: Cache-aside without locking -- `TryGetValue` followed by expensive call followed by `Set` without `SemaphoreSlim` or equivalent stampede guard.
- **Tag-based invalidation with RemoveByTagAsync**: When using HybridCache, group related entries by tag for efficient bulk invalidation instead of tracking individual keys.
- **IMemoryCache size limits**: Flag `AddMemoryCache()` without `SizeLimit` in `MemoryCacheOptions`. Unbounded in-memory cache can grow until the process runs out of memory.
Grep patterns:
```
GetAsync\(
SendAsync\(
_cache\.TryGetValue
DistributedCache
AddMemoryCache\(\)
```
---
## Category 7: DI Lifetime Issues (DI)
Consult the project's CLAUDE.md or AGENTS.md for the expected DI lifetime registrations.
Checks:
- **Transient services that should be Singleton**: Stateless, thread-safe services registered as Transient that have no per-request state (could be Singleton for zero allocation).
- **Scoped injected into Singleton**: A Scoped service captured in a Singleton constructor -- captive dependency bug.
- **IOptions vs IOptionsMonitor vs IOptionsSnapshot**: `IOptions<T>` in Singleton services that need to react to config changes should use `IOptionsMonitor<T>`. `IOptionsSnapshot<T>` in Singleton is a captive dependency.
---
## Category 8: Concurrency Issues (CONC)
Checks:
- **Lock contention**: `lock` statements that guard async operations (should use `SemaphoreSlim`).
- **Missing throttling**: Unbounded parallel calls to external APIs without `SemaphoreSlim` or concurrency limits.
- **Thread-unsafe patterns**: Shared mutable state without synchronization. `HttpContext` accessed from background threads.
- **Channel usage patterns**: If the project uses `Channel<T>` for background tasks, verify `SingleReader`/`SingleWriter` hints are set correctly for performance.
Grep patterns:
```
lock\s*\(
new SemaphoreSlim
HttpContext.*Task\.Run
```
---
## Category 9: HttpClient Misuse (HTTP)
Checks:
- **new HttpClient()**: Direct instantiation instead of `IHttpClientFactory`. Causes socket exhaustion and DNS caching issues.
- **Missing resilience**: HTTP calls without retry/circuit-breaker policies. Verify `Microsoft.Extensions.Http.Resilience` or Polly is applied to external API clients.
- **Missing response disposal**: `HttpResponseMessage` not disposed after reading.
Grep patterns:
```
new HttpClient\(
new HttpClient\b
```
---
## Category 10: Exception-Driven Control Flow (EXC)
Checks:
- **Try/catch for expected paths**: Using exceptions for normal control flow (e.g., catching `KeyNotFoundException` instead of `TryGetValue`, catching `FormatException` instead of `TryParse`).
- **Broad catch blocks**: `catch (Exception)` that swallow errors or use exceptions as branching logic.
- **Exception allocation in hot paths**: Throwing exceptions on paths that execute frequently.
Grep patterns:
```
catch\s*\(Exception\b
catch\s*\(KeyNotFoundException
catch\s*\(FormatException
catch\s*\(InvalidOperationException
```
---
## Category 11: Response Optimization (RESP)
Checks:
- **Missing compression**: No response compression middleware, or JSON responses served uncompressed.
- **Missing pagination**: Endpoints returning unbounded collections.
- **Missing ETags for conditional requests**: GET endpoints without ETag support where the data has a natural version (e.g., database ETags or row versions).
---
## Category 12: String Optimization (STR)
Checks:
- **String concatenation in loops**: `+=` on strings inside `for`/`foreach`/`while` loops.
- **String.Format in hot paths**: Could use interpolated string handlers or `StringBuilder`.
- **Repeated string operations**: Multiple `ToLower()`/`ToUpper()` calls on the same value. Should use `StringComparison.OrdinalIgnoreCase` instead.
Grep patterns:
```
\+= "
\+= \$"
\.ToLower\(\)
\.ToUpper\(\)
String\.Format\(
```
---
## Category 13: Startup & Pipeline Optimization (STARTUP)
Checks:
- **Middleware ordering**: Verify Program.cs follows the recommended sequence: ExceptionHandler, ResponseCompression, OutputCache, Routing, RateLimiter, CORS, Authentication, Authorization, MapControllers. Incorrect ordering degrades performance (e.g., compression after routing skips static responses).
- **Response compression**: Flag missing `AddResponseCompression`/`UseResponseCompression`. Without it, all JSON responses are uncompressed. Recommend Brotli (optimal ratio) + GZip (compatibility) providers with `EnableForHttps = true`.
- **Health check optimization**: `.ShortCircuit()` (.NET 8+) bypasses the entire middleware pipeline for health endpoints. `.DisableHttpMetrics()` prevents health check traffic from skewing request duration metrics.
- **PGO/ReadyToRun**: Check .csproj for `<TieredPGO>true</TieredPGO>` (dynamic PGO for runtime hot-path optimization) and `<PublishReadyToRun>true</PublishReadyToRun>` (pre-compiled code for faster startup). Both should be present for production builds.
- **Warm-up pattern**: `ApplicationStarted` callback to warm expensive singletons (database connections, cache, external API health). Cold-start latency without warm-up can spike P99 for the first requests after deployment.
Grep patterns:
```
UseResponseCompression
AddResponseCompression
ShortCircuit
AddOutputCache
UseOutputCache
TieredPGO
PublishReadyToRun
ApplicationStarted
```
---
## Category 14: Metrics & Observability (METRICS)
Checks:
- **IMeterFactory vs static new Meter()**: Services should inject `IMeterFactory` from DI rather than using `new Meter(...)`. `IMeterFactory` enables testability with `MetricCollector<T>` and proper meter lifecycle management.
- **Missing histograms**: If the project only has `Counter<long>` instruments, recommend `Histogram<double>` for request processing duration, external API latency, and database query duration. Histograms enable percentile analysis (P50/P95/P99).
- **Tag cardinality**: Verify metric tags have bounded cardinality. NEVER use request IDs, user IDs, or unbounded strings as metric tags. Tags like `operation_name`, `status_code`, `endpoint` are acceptable (bounded). Unbounded tags cause metric explosion and memory issues.
- **OpenTelemetry AddMeter() registration**: Verify custom meter names are registered with `.AddMeter("YourMeterName")` in the OpenTelemetry metrics configuration. Without this, custom counters are silently dropped.
Grep patterns:
```
new Meter\(
CreateCounter
CreateHistogram
AddMeter
\.Record\(
\.Add\(
```

View file

@ -6,6 +6,8 @@ metadata:
# Code Patterns
> **See also:** for pattern-by-pattern detection recipes with measured impact numbers and ❌/✅ pairs, see the topic catalogs: [critical-patterns.md](critical-patterns.md), [async-patterns.md](async-patterns.md), [memory-and-strings.md](memory-and-strings.md), [collections-and-linq.md](collections-and-linq.md), [regex-patterns.md](regex-patterns.md), [io-and-serialization.md](io-and-serialization.md), [structural-patterns.md](structural-patterns.md). This file covers the conceptual framing (when/why), those files cover the catalog (what/how-much).
Use this reference after a profile or benchmark identifies a hot path. Do not apply these patterns speculatively.
## Table Of Contents
@ -99,9 +101,10 @@ Keep deferred execution unless you need a snapshot, repeated traversal, indexing
### Do not blanket-rewrite LINQ to loops
- .NET 10 improved many LINQ operations substantially.
- .NET 10 improved many LINQ operations substantially through JIT array-interface devirtualisation — operations like `Skip`/`Take`/`Sum` on arrays and `ReadOnlyCollection<T>` got roughly 50% faster *for free*. See Stephen Toub, [Performance Improvements in .NET 10](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/).
- On `net8.0` LINQ has the historical performance characteristics described in [Performance Improvements in .NET 8](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/) — fast paths exist for `Count`, `ToList`, `ToArray` on `ICollection<T>`, but indexed access via `ElementAt`/`Skip`/`Take` is not as cheap as on .NET 10.
- Start with analyzer-backed fixes and measurement.
- Replace LINQ with hand-written loops only when a benchmark or trace shows that the remaining cost matters.
- Replace LINQ with hand-written loops only when a benchmark or trace shows that the remaining cost matters — on either target.
### Use `TryGetNonEnumeratedCount` when count is optional

View file

@ -0,0 +1,217 @@
# Collections & LINQ Patterns
### Use FrozenDictionary/FrozenSet for Read-Heavy Lookup Tables
🟡 **DO** use `FrozenDictionary`/`FrozenSet` for collections created once and read many times | .NET 8+
```csharp
private static readonly Dictionary<string, int> s_statusCodes = new()
{
["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500
};
```
```csharp
private static readonly FrozenDictionary<string, int> s_statusCodes =
new Dictionary<string, int>
{
["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500
}.ToFrozenDictionary();
```
**Impact: ~50% faster lookups than Dictionary, ~14x faster than ImmutableDictionary.**
### Use Dictionary Alternate Lookup for Span-Based Keys
🟡 **DO** use `GetAlternateLookup<ReadOnlySpan<char>>()` to avoid string allocation on lookups | **.NET 10 (or .NET 9) only — NOT available on .NET 8**
❌ (allocates on every lookup; the only option on .NET 8)
```csharp
string key = headerLine.Substring(0, colonIndex);
if (s_dict.TryGetValue(key, out int value)) { /* ... */ }
```
✅ .NET 10 / C# 14
```csharp
var lookup = s_dict.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> key = headerLine.AsSpan(0, colonIndex);
if (lookup.TryGetValue(key, out int value)) { }
```
✅ .NET 8 fallback — keep the allocation but minimise it
```csharp
// On net8.0 GetAlternateLookup does not exist (added in .NET 9 BCL).
// Pre-intern frequent keys, or accept the allocation. If the hot path is
// truly critical, store keys as ReadOnlyMemory<char> and write a custom
// IEqualityComparer<string> that compares against a span via string.Compare.
string key = headerLine.Substring(0, colonIndex);
if (s_dict.TryGetValue(key, out int value)) { /* ... */ }
```
**Impact: Avoids string allocation per lookup on .NET 10 — especially valuable in parser/protocol hot paths.**
### Use CollectionsMarshal.GetValueRefOrNullRef for Lookup-and-Update
🟡 **DO** use `CollectionsMarshal.GetValueRefOrAddDefault` for dictionary update patterns | .NET 6+
```csharp
_counts.TryGetValue(key, out int count);
_counts[key] = count + 1;
```
```csharp
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(_counts, key, out _);
count++;
```
**Impact: ~48% faster for lookup-and-update patterns (95µs → 49µs).**
### Use Collection Expressions [] for Zero-Allocation Span Creation
🟡 **DO** use collection expressions for `Span<T>` targets | C# 12 / .NET 8+
```csharp
int[] values = new int[] { a, b, c, d };
```
```csharp
Span<int> values = [a, b, c, d];
ReadOnlySpan<int> daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
```
**Impact: Zero heap allocation for span-targeted collection expressions.**
### Use EnsureCapacity on List/Stack/Queue Before Bulk Adds
🟡 **DO** call `EnsureCapacity` before bulk insertions | .NET 6+
```csharp
var list = new List<int>();
for (int i = 0; i < 10000; i++)
list.Add(i);
```
```csharp
var list = new List<int>();
list.EnsureCapacity(10000);
for (int i = 0; i < 10000; i++)
list.Add(i);
```
**Impact: Reduces reallocations and array copies during bulk operations.**
### Use TryGetNonEnumeratedCount for Pre-Sizing
🟡 **DO** use `TryGetNonEnumeratedCount` to pre-size destination collections | .NET 6+
```csharp
var results = new List<int>();
foreach (var item in source)
results.Add(Transform(item));
```
```csharp
var results = source.TryGetNonEnumeratedCount(out int count)
? new List<int>(count)
: new List<int>();
foreach (var item in source)
results.Add(Transform(item));
```
**Impact: Avoids O(n) enumeration for counting; eliminates resizing allocations.**
### Hoist Static Data Out of Method Bodies
🟡 **AVOID** creating collections with static/deterministic data inside method bodies | .NET Core+
```csharp
public string Convert(long number)
{
var groupsMap = new Dictionary<long, Func<long, string>>
{
{ 1_000_000_000, n => $"{Convert(n)} billion" },
{ 1_000_000, n => $"{Convert(n)} million" },
{ 1_000, n => $"{Convert(n)} thousand" },
};
}
```
```csharp
private static readonly FrozenDictionary<long, Func<long, string>> s_groupsMap =
new Dictionary<long, Func<long, string>>
{
{ 1_000_000_000, n => $"{Convert(n)} billion" },
{ 1_000_000, n => $"{Convert(n)} million" },
{ 1_000, n => $"{Convert(n)} thousand" },
}.ToFrozenDictionary();
public string Convert(long number)
{
// ... use s_groupsMap
}
```
**Impact: Eliminates collection + internal storage + closure allocations per call. For a Dictionary with N entries, saves ~N+3 allocations per invocation.**
### Add Overloads to Avoid params Array Allocation
🟡 **DO** add 1- and 2-argument overloads for methods that accept `params T[]`. On .NET 10 also expose a `params ReadOnlySpan<T>` overload | works on .NET 8 and .NET 10
❌ (single `params T[]` overload allocates a new array on every call, including the common 1-argument case)
```csharp
public static string Transform(this string input, params IStringTransformer[] transformers) =>
transformers.Aggregate(input, (current, t) => t.Transform(current));
"hello".Transform(To.TitleCase);
```
✅ Option A — explicit overloads for common arities (works on .NET 8 and .NET 10)
```csharp
public static string Transform(this string input, IStringTransformer transformer) =>
transformer.Transform(input);
public static string Transform(this string input, IStringTransformer t1, IStringTransformer t2) =>
t2.Transform(t1.Transform(input));
public static string Transform(this string input, params IStringTransformer[] transformers) =>
transformers.Aggregate(input, (current, t) => t.Transform(current));
```
✅ Option B — `.NET 10 / C# 14` adds a span overload (eliminates the allocation for all arities)
```csharp
public static string Transform(this string input, params ReadOnlySpan<IStringTransformer> transformers)
{
foreach (var t in transformers)
input = t.Transform(input);
return input;
}
```
⚠️ Option B does **not** compile on `net8.0`: `params ReadOnlySpan<T>` requires C# 13 (default on .NET 9+). On .NET 8 ship only Option A.
**Impact: Option A eliminates the array allocation for 1- and 2-argument calls on every target. Option B eliminates it for all arities on .NET 10.**
## Detection
Scan recipes for collection and LINQ anti-patterns. Run these and report exact counts.
```bash
# Static Dictionary not using FrozenDictionary (read-only after init)
grep -rn --include='*.cs' 'static readonly Dictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Static FrozenDictionary (already optimized — verify the inverse)
grep -rn --include='*.cs' 'static readonly FrozenDictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Per-call List allocation (inside method bodies, not static/readonly fields)
grep -rn --include='*.cs' 'new List<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l
# Per-call Dictionary allocation (inside method bodies, not static/readonly fields)
grep -rn --include='*.cs' 'new Dictionary<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l
# StringComparer.CurrentCulture usage (almost always wrong in library code — use Ordinal)
grep -rn --include='*.cs' 'StringComparer.CurrentCulture' --exclude-dir=bin --exclude-dir=obj . | wc -l
# LINQ chains in extension/hot-path files (.Select, .Where, .Cast, .Take, .Aggregate)
grep -rn --include='*.cs' -E '\.(Select|Where|Cast|Take|Aggregate)\(' --exclude-dir=bin --exclude-dir=obj . | wc -l
```
For the LINQ chain recipe: any hit in a file whose name ends in `Extensions.cs`, `Formatter.cs`, or implements a method called from a public extension method is a hot-path candidate. Inspect each hit in these files and flag LINQ chains that allocate delegates, enumerators, or intermediate collections on every call. Hits in localization converters or one-time initialization are lower priority.
### Patterns Requiring Manual Review
- **ContainsKey + indexer double-lookup**: Requires verifying the same key is used in a subsequent indexer access — multi-line/multi-statement context
- **LINQ on hot paths**: The LINQ chain recipe above catches call sites, but distinguishing hot-path from cold-path requires context. Prioritize hits in `*Extensions.cs` and `*Formatter.cs` files, which are typically called on every user invocation
- **`new Dictionary/List<` in method bodies vs fields**: The grep heuristic (`grep -v 'static\|readonly'`) catches most cases but may include false positives from field initializers without `static`/`readonly` — spot-check flagged lines

View file

@ -0,0 +1,288 @@
# Critical .NET Performance Anti-Patterns
17 patterns that cause deadlocks, order-of-magnitude regressions, or excessive allocations.
## Async / Tasks
### Never Block on Async (Sync-over-Async)
🔴 **AVOID** | .NET Core+
```csharp
public string GetData()
=> GetDataAsync().Result;
```
```csharp
public async Task<string> GetDataAsync()
=> await GetDataInternalAsync();
```
**Impact: Deadlocks or thread pool starvation; wastes threads, destroys scalability.**
### Never Await a ValueTask Multiple Times
🔴 **AVOID** | .NET Core 2.1+
```csharp
ValueTask<int> vt = SomeMethodAsync();
int a = await vt;
int b = await vt;
```
```csharp
int result = await SomeMethodAsync();
```
**Impact: Undefined behavior — silent data corruption or exceptions.**
## Memory / Allocation
### Use Span\<T\> / AsSpan Instead of Substring for Slicing
🔴 **DO** | .NET Core 2.1+
```csharp
string sub = input.Substring(5, 10);
```
```csharp
ReadOnlySpan<char> sub = input.AsSpan(5, 10);
```
**Impact: Eliminates per-slice allocations; 2-4x faster via vectorization.**
### Use ArrayPool\<T\> for Temporary Buffers
🔴 **DO** | .NET Core+
```csharp
byte[] buf = new byte[4096];
```
```csharp
byte[] buf = ArrayPool<byte>.Shared.Rent(4096);
Process(buf);
ArrayPool<byte>.Shared.Return(buf);
```
**Impact: Dramatically reduces GC pressure for buffer-heavy workloads.**
### Avoid stackalloc in Loops
🔴 **AVOID** | .NET 5+
```csharp
for (int i = 0; i < 10_000; i++)
Span<byte> buf = stackalloc byte[1024];
```
```csharp
Span<byte> buf = stackalloc byte[1024];
for (int i = 0; i < 10_000; i++) { Process(buf); }
```
**Impact: StackOverflowException — unrecoverable, no catch possible.**
### Avoid Boxing Value Types
🔴 **AVOID** | .NET 6+
```csharp
string s = string.Format("{0}.{1}", major, minor);
```
```csharp
string s = $"{major}.{minor}";
```
**Impact: When replacing `string.Format` with C# 10+ interpolation, typical improvements are ~40% faster with significantly less allocation. Actual gains vary by call site.**
## Strings
### Use StringComparison.Ordinal for Non-Linguistic Comparisons
🔴 **DO** | .NET Core+
```csharp
bool found = text.IndexOf("Content-Type") >= 0;
```
```csharp
bool found = text.Contains("Content-Type", StringComparison.Ordinal);
```
**Impact: 2-3x faster; OrdinalIgnoreCase hash codes ~3.3x faster.**
### Use AsSpan Instead of Substring
🔴 **DO** | .NET Core 2.1+
```csharp
int val = int.Parse(str.Substring(5, 3));
```
```csharp
int val = int.Parse(str.AsSpan(5, 3));
```
**Impact: Eliminates one string allocation per parse operation.**
## Regular Expressions
### Use Source-Generated Regex [GeneratedRegex]
🔴 **ALWAYS** use `[GeneratedRegex]` for all static regex patterns | .NET 7+
```csharp
private static readonly Regex s_re =
new(@"\w+@\w+\.\w+", RegexOptions.Compiled);
```
```csharp
[GeneratedRegex(@"\w+@\w+\.\w+")]
private static partial Regex EmailRegex();
```
**Impact: Always beneficial or neutral for static patterns — near-zero startup, better throughput, and required for AOT/trimming scenarios.**
### Avoid Nested Quantifiers (Catastrophic Backtracking)
🔴 **AVOID** | .NET Core+
```csharp
var r = new Regex(@"^(\w+)+$");
```
```csharp
var r = new Regex(@"^\w+$", RegexOptions.NonBacktracking);
```
**Impact: Can hang process indefinitely on crafted input.**
### Use TryGetValue Instead of ContainsKey + Indexer
🔴 **DO** | .NET Core+
```csharp
if (dict.ContainsKey(key))
Use(dict[key]);
```
```csharp
if (dict.TryGetValue(key, out var value))
Use(value);
```
**Impact: ~2x faster (50% reduction in lookup time).**
### Avoid LINQ in Hot Paths
🔴 **AVOID** | .NET Core+
```csharp
bool found = items.Any(x => x.Name == target);
```
```csharp
bool found = false;
foreach (var item in items)
if (item.Name == target) { found = true; break; }
```
**Impact: Eliminates 1-3 allocations per call; measurable in tight loops.**
### Don't Iterate IEnumerable Multiple Times
🔴 **AVOID** | .NET Core+
```csharp
foreach (Type t in types) { Validate(t); }
_types = types.ToArray();
```
```csharp
Type[] arr = types.ToArray();
foreach (Type t in arr) { Validate(t); }
_types = arr;
```
**Impact: Halves enumeration cost; prevents bugs from re-executing deferred queries.**
## JSON Serialization
### Use System.Text.Json Source Generator
🔴 **DO** | .NET 6+
```csharp
string json = JsonSerializer.Serialize(post);
```
```csharp
[JsonSerializable(typeof(BlogPost))]
internal partial class AppJsonCtx : JsonSerializerContext { }
string json = JsonSerializer.Serialize(post, AppJsonCtx.Default.BlogPost);
```
**Impact: 37-44% faster; enables trimming and Native AOT.**
### Cache JsonSerializerOptions
🔴 **DO** | .NET 5+
```csharp
JsonSerializer.Serialize(obj, new JsonSerializerOptions());
```
```csharp
private static readonly JsonSerializerOptions s_opts = new();
JsonSerializer.Serialize(obj, s_opts);
```
**Impact: Up to 592x slower without caching (.NET 6); always cache or use defaults.**
## Networking
### Reuse HttpClient Instances
🔴 **DO** | .NET Core 2.1+
```csharp
using var client = new HttpClient();
await client.GetStringAsync(url);
```
```csharp
private static readonly HttpClient s_http = new(new SocketsHttpHandler
{ PooledConnectionLifetime = TimeSpan.FromMinutes(5) });
await s_http.GetStringAsync(url);
```
**Impact: Prevents socket exhaustion; 6-12x faster concurrent HTTPS.**
## General
### Use SearchValues\<T\> for Repeated Set Searches
🔴 **DO** | .NET 8+ (works on both targets; .NET 10 adds multi-string overloads)
```csharp
int pos = text.IndexOfAny("ABCDEF".ToCharArray());
```
✅ (.NET 8 and .NET 10 — `SearchValues<char>` is the same API on both)
```csharp
private static readonly SearchValues<char> s_hex = SearchValues.Create("ABCDEF");
int pos = text.AsSpan().IndexOfAny(s_hex);
```
✅ (.NET 10 only — multi-string `SearchValues<string>`)
```csharp
private static readonly SearchValues<string> s_keywords =
SearchValues.Create(["error", "warning", "fatal"], StringComparison.OrdinalIgnoreCase);
int pos = log.AsSpan().IndexOfAny(s_keywords); // SearchValues<string> overload is .NET 9+ BCL
```
On `net8.0` use a `SearchValues<char>` with the first letter of each keyword and then fall back to `string.IndexOf(StringComparison.Ordinal)`.
**Impact: 2-10x faster for chars (both targets); 10-30x faster for multi-string on .NET 10.**
## Detection
Scan recipes for critical anti-patterns. Run these and report exact counts of issues found in each case.
```bash
# .IndexOf(string) without StringComparison (culture-aware, 2-3x slower)
grep -rn --include='*.cs' -E '\.IndexOf\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l
# .Substring( calls (allocates new string — consider AsSpan)
grep -rn --include='*.cs' '\.Substring(' --exclude-dir=bin --exclude-dir=obj . | wc -l
# .StartsWith/.EndsWith without StringComparison (culture-aware, 2-3x slower)
grep -rn --include='*.cs' -E '\.(StartsWith|EndsWith)\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l
# .Contains(string) without StringComparison — NOTE: will also match collection .Contains() calls; filter to string receivers
grep -rn --include='*.cs' -E '\.Contains\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l
```

View file

@ -0,0 +1,165 @@
# Anti-Pattern Grep Library
Consolidated grep patterns for automated scanning. Run these with the Grep tool using `type: "cs"` filter.
All patterns use ripgrep regex syntax. Run during Phase 1 (Discovery) broad scan.
> **See also:** each topic catalog ([critical-patterns.md](critical-patterns.md), [async-patterns.md](async-patterns.md), [memory-and-strings.md](memory-and-strings.md), [collections-and-linq.md](collections-and-linq.md), [regex-patterns.md](regex-patterns.md), [io-and-serialization.md](io-and-serialization.md), [structural-patterns.md](structural-patterns.md)) has its own Detection section with topic-specific recipes and ratio-counting guidance. This file is the consolidated cross-cutting library; load topic files when their signals are present.
---
## ASYNC anti-patterns (CRITICAL)
```
\.Result\b
\.Wait\(\)
\.GetAwaiter\(\)\.GetResult\(\)
async void\b
Task\.Run\(
\.WriteAsync\([^,]*\)
```
**False positive note**: `.Result` matches `Task.FromResult` -- verify actual blocking before flagging.
---
## Memory anti-patterns (MEM)
```
new byte\[\d{4,}\]
new byte\[
new MemoryStream\(\)
\.Substring\(
new StringBuilder\(\)
new List<.*>\(\)
new Dictionary<.*>\(\)
_logger\.Log(Debug|Trace|Information|Warning|Error|Critical)\(\$"
\.Split\(
```
---
## LINQ anti-patterns
```
\.Count\(\)\s*[><=!]
\.ToList\(\)\.Where\(
\.ToList\(\)\.Select\(
\.Select\(.*\)\.Where\(
\.OrderBy.*\.Where\(
```
---
## Database / CosmosDB anti-patterns (DB)
```
\.Include\(.*\.Include\(
await.*foreach.*await.*Async
ReadItemAsync
GetItemQueryIterator
GetItemLinqQueryable
\.RequestCharge
```
---
## JSON anti-patterns
```
new JsonSerializerOptions
new JsonSerializerSettings
JsonConvert\.Serialize
JsonConvert\.Deserialize
new JsonSerializer
```
---
## Caching anti-patterns (CACHE)
```
GetAsync\(
SendAsync\(
_cache\.TryGetValue
DistributedCache
AddMemoryCache\(\)
```
---
## HttpClient misuse (HTTP)
```
new HttpClient\(
new HttpClient\b
```
---
## Exception control flow (EXC)
```
catch\s*\(Exception\b
catch\s*\(KeyNotFoundException
catch\s*\(FormatException
catch\s*\(InvalidOperationException
```
---
## String anti-patterns (STR)
```
\+= "
\+= \$"
\.ToLower\(\)
\.ToUpper\(\)
String\.Format\(
```
---
## Concurrency anti-patterns (CONC)
```
lock\s*\(
new SemaphoreSlim
HttpContext.*Task\.Run
```
---
## Startup & Pipeline (STARTUP)
```
UseResponseCompression
AddResponseCompression
ShortCircuit
AddOutputCache
UseOutputCache
TieredPGO
PublishReadyToRun
ApplicationStarted
```
---
## Metrics & Observability (METRICS)
```
new Meter\(
CreateCounter
CreateHistogram
AddMeter
```
---
## CancellationToken coverage
```
async Task[<\s].*\)\s*$
```
This pattern finds async methods whose signature ends without a CancellationToken parameter. Verify each match -- some may be interface implementations where the token is propagated differently.

View file

@ -0,0 +1,124 @@
# I/O, Serialization & General Patterns
### Use HttpCompletionOption.ResponseHeadersRead for Streaming
🟡 **DO** use `ResponseHeadersRead` when downloading large responses | .NET Core 3.0+
```csharp
var response = await client.GetAsync(uri);
```
```csharp
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
using var stream = await response.Content.ReadAsStreamAsync();
await stream.CopyToAsync(destinationStream);
```
**Impact: ~2x faster for large downloads (10MB+), dramatically reduced memory usage.**
### Use Async FileStream Operations
🟡 **DO** use `FileStream` with `useAsync: true` for scalable file I/O | .NET 6+
```csharp
using var fs = new FileStream(path, FileMode.Open);
```
```csharp
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.Read, bufferSize: 4096, useAsync: true);
byte[] buffer = new byte[1024];
while (await fs.ReadAsync(buffer) != 0) { /* process */ }
```
**Impact: Up to 3x faster async reads; allocation reduced from megabytes to hundreds of bytes.**
### Use Memory\<byte\> Overloads for Stream.ReadAsync/WriteAsync
🟡 **DO** use `Memory<byte>`-based stream overloads instead of `byte[]` overloads | .NET 5+
```csharp
await stream.ReadAsync(buffer, 0, buffer.Length);
await stream.WriteAsync(buffer, 0, buffer.Length);
```
```csharp
await stream.ReadAsync(buffer.AsMemory());
await stream.WriteAsync(buffer.AsMemory());
```
**Impact: Eliminates ~72 KB allocation per 1,000 read/write pairs on NetworkStream.**
### Use Span-Based TryFormat for Number Formatting
🟡 **DO** use `TryFormat` to format numbers into `Span<char>` buffers | .NET Core 2.1+
```csharp
string formatted = value.ToString();
destination.Write(formatted);
```
```csharp
Span<char> buffer = stackalloc char[20];
if (value.TryFormat(buffer, out int charsWritten))
destination.Write(buffer[..charsWritten]);
```
**Impact: Int32.ToString() ~2x faster in .NET Core 2.1, Int32 parsing ~5x faster in .NET Core 3.0.**
### Use static readonly for Runtime Devirtualization
🟡 **DO** store implementations in `static readonly` fields for JIT devirtualization | .NET Core 3.0+
```csharp
private static Base s_impl = new DerivedImpl();
s_impl.Process();
```
```csharp
private static readonly Base s_impl = new DerivedImpl();
s_impl.Process();
private static readonly bool s_feature =
Environment.GetEnvironmentVariable("Feature") == "1";
```
**Impact: Virtual call eliminated entirely — can be inlined to zero overhead. Dead code elimination in tier 1.**
### Avoid Explicit Static Constructors — Use Field Initializers
🟡 **AVOID** explicit `static` constructors when field initializers suffice | .NET Core 3.0+
```csharp
class Foo
{
static readonly int s_value;
static Foo() { s_value = ComputeValue(); }
}
```
```csharp
class Foo
{
static readonly int s_value = ComputeValue();
}
```
**Impact: Enables better JIT optimization and reduces potential lock overhead on static method access.**
## Detection
Scan recipes for I/O and serialization anti-patterns. Run these and report exact counts.
```bash
# new HttpClient() (socket exhaustion risk)
grep -rn --include='*.cs' 'new HttpClient(' --exclude-dir=bin --exclude-dir=obj . | wc -l
# new JsonSerializerOptions() not cached (592x slower in .NET 6)
grep -rn --include='*.cs' 'new JsonSerializerOptions' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l
```
### Patterns Requiring Manual Review
- **`JsonSerializer.Serialize/Deserialize` without source-gen context**: Can't determine from grep if a context parameter is passed

View file

@ -0,0 +1,193 @@
---
description: >-
Performance measurement guide for dotnet-performance skill. Covers tool
selection per category, KPI targets, BenchmarkDotNet, k6 load testing,
CI/CD integration, and live process CLI commands.
metadata:
tags: [measurement, benchmarkdotnet, k6, dotnet-counters, kpi]
---
# Measurement Guide
How to measure performance before and after applying optimizations. Never optimize without baseline data.
---
## Tool Selection Decision Table
Map each optimization category to the appropriate measurement tools:
| Category | Primary Tool | Secondary Tool | What to Measure |
|---|---|---|---|
| MEM | `dotnet-counters` (gc-heap-size, alloc-rate) | `dotnet-gcdump` comparison | Allocation rate reduction, GC collection frequency |
| ASYNC | `dotnet-counters` (threadpool-queue-length, thread-count) | App Insights dependency tracking | Thread pool starvation, blocked threads |
| LINQ | BenchmarkDotNet `[MemoryDiagnoser]` | `dotnet-trace` hot path | Allocation per operation, throughput |
| DB | `response.RequestCharge` logging | App Insights DB dependency | RU cost per operation, query latency |
| JSON | BenchmarkDotNet serialization benchmark | `dotnet-counters` alloc-rate | Throughput (ops/sec), bytes allocated |
| CACHE | App Insights dependency duration | Custom hit ratio counter | Cache hit rate, dependency call reduction |
| DI | `dotnet-counters` alloc-rate | Load test comparison | Object creation overhead |
| CONC | `dotnet-counters` (monitor-lock-contention-count) | `dotnet-trace` contention events | Lock wait time, throughput under load |
| HTTP | `dotnet-counters` Microsoft.AspNetCore.Hosting | k6/NBomber load test | Request duration, throughput |
| EXC | `dotnet-counters` exception-count | App Insights exceptions | Exception rate per interval |
| RESP | Network tab / curl with timing | k6 response size check | Response size (bytes), transfer time |
| STR | BenchmarkDotNet `[MemoryDiagnoser]` | `dotnet-counters` alloc-rate | String allocations per operation |
| STARTUP | Startup time measurement | `dotnet-trace` startup events | Time to first request, cold start latency |
| METRICS | `MetricCollector<T>` in tests | Prometheus/Grafana dashboard | Metric emission, cardinality |
---
## KPI Targets
Standard targets for ASP.NET Core APIs. Use as thresholds when evaluating optimization impact:
| Metric | Target | Red Flag |
|---|---|---|
| P50 response time | < 100ms | > 200ms |
| P95 response time | < 500ms | > 1000ms |
| P99 response time | < 1000ms | > 2000ms |
| Error rate (5xx) | < 0.1% | > 1% |
| CPU utilization | < 70% sustained | > 85% |
| Memory working set | < 80% | > 90% |
| Thread pool queue length | < 10 sustained | > 50 |
| GC time percentage | < 10% | > 20% |
| Allocation rate | Trend down after optimization | Sustained increase |
---
## BenchmarkDotNet Guidance
Use for micro-optimizations on hot paths (MEM, LINQ, JSON, STR categories).
**When to benchmark**: Hot-path changes where the difference is in nanoseconds or bytes allocated. Not needed for architectural changes (caching, DI lifetime) — use load testing instead.
**Minimum setup**:
```csharp
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
public class MyBenchmark
{
[Benchmark(Baseline = true)]
public void Original() { /* original code */ }
[Benchmark]
public void Optimized() { /* optimized code */ }
}
```
**Run command**: `dotnet run -c Release --project path/to/benchmark`
**Common pitfalls**:
- Running in Debug mode (JIT optimizations disabled, results meaningless)
- Not returning computed values (JIT eliminates dead code)
- Ignoring allocation metrics (throughput may improve but allocations increase)
- Benchmarking with a debugger attached
- Including setup costs in the measured method
---
## Load Testing
For HIGH-impact optimizations, perform before/after load testing to validate real-world improvement.
**k6 template**:
```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 },
{ duration: '1m', target: 20 },
{ duration: '10s', target: 0 },
],
thresholds: {
http_req_duration: ['p(50)<100', 'p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get('http://localhost:5000/your-endpoint');
check(res, {
'status is 200': (r) => r.status === 200,
'p95 under 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
```
**While load testing, monitor simultaneously**:
```bash
dotnet-counters monitor -n <ProcessName> --counters System.Runtime,Microsoft.AspNetCore.Hosting
```
---
## CI/CD Integration
For PR regression detection, use `benchmark-action/github-action-benchmark`:
```yaml
- uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'benchmarkdotnet'
output-file-path: BenchmarkDotNet.Artifacts/results/*.json
alert-threshold: '150%'
comment-on-alert: true
fail-on-alert: true
```
This fails the PR if any benchmark regresses by more than 50% compared to the baseline.
---
## Code Review Mode: Quick Reference Commands
```bash
# Baseline runtime health
dotnet-counters monitor -n <ProcessName> --counters System.Runtime
# ASP.NET Core request metrics
dotnet-counters monitor -n <ProcessName> --counters Microsoft.AspNetCore.Hosting
# Full monitoring (runtime + HTTP + custom meters)
dotnet-counters monitor -n <ProcessName> --counters System.Runtime,Microsoft.AspNetCore.Hosting,Microsoft.AspNetCore.Server.Kestrel
# GC heap snapshot for before/after comparison
dotnet-gcdump collect -n <ProcessName> -o before.gcdump
# ... apply optimization ...
dotnet-gcdump collect -n <ProcessName> -o after.gcdump
# 30-second CPU trace
dotnet-trace collect -n <ProcessName> --duration 00:00:30
dotnet-trace convert trace.nettrace --format speedscope
```
---
## Diagnostic Mode: Full CLI Commands
When profiling a live process (Mode A), use these commands by investigation stage:
```bash
# Stage 1: Live triage
dotnet-counters monitor -p <PID> --counters System.Runtime
dotnet-counters monitor -n <ProcessName> --counters System.Runtime,Microsoft.AspNetCore.Hosting,Microsoft.AspNetCore.Server.Kestrel
# Stage 2: Stuck/hung process — get stacks immediately
dotnet-stack report -p <PID>
# Stage 3: CPU + allocation hot paths
dotnet-trace collect -p <PID> --duration 00:00:30
dotnet-trace report <trace.nettrace> topN
# Stage 4: Heap composition
dotnet-gcdump collect -p <PID> -o before.gcdump
# ... apply optimization ...
dotnet-gcdump collect -p <PID> -o after.gcdump
dotnet-gcdump report <file.gcdump>
# Stage 5: Full dump for SOS analysis
dotnet-dump collect -p <PID> --type Heap
dotnet-dump analyze <dump> -c "dumpheap -stat" -c "exit"
```

View file

@ -0,0 +1,223 @@
# Memory & String Patterns
### Use ReadOnlySpan\<byte\> for Constant Byte Data
🟡 **DO** assign constant byte arrays to `ReadOnlySpan<byte>` | .NET 5+
```csharp
byte[] data = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F };
```
```csharp
ReadOnlySpan<byte> data = [0x48, 0x65, 0x6C, 0x6C, 0x6F];
ReadOnlySpan<int> primes = [2, 3, 5, 7, 11, 13];
```
**Impact: ~100x faster access than static byte[] field, zero allocation.**
### Use stackalloc for Small Temporary Buffers
🟡 **DO** use `stackalloc` for small, fixed-size temporary buffers | .NET Core+
```csharp
char[] buffer = new char[64];
guid.TryFormat(buffer, out int written);
```
```csharp
Span<char> buffer = stackalloc char[64];
guid.TryFormat(buffer, out int written);
```
**Impact: Zero heap allocation, no GC pressure, instant alloc/dealloc.**
### Use Span.TryWrite for Allocation-Free Interpolation
🟡 **DO** use `MemoryExtensions.TryWrite` to format into `Span<char>` buffers | .NET 6+
```csharp
string formatted = $"Date: {dt:R}";
destination.Write(formatted);
```
```csharp
Span<char> buffer = stackalloc char[64];
buffer.TryWrite($"Date: {dt:R}", out int charsWritten);
```
**Impact: Zero heap allocation for formatting operations.**
### Use Span.Split() for Zero-Allocation Splitting
🟡 **DO** use `MemoryExtensions.Split` for allocation-free string splitting | **.NET 10 (or .NET 9) only — NOT available on .NET 8**
❌ (allocates `string[]` — the only built-in option on .NET 8)
```csharp
string[] parts = input.Split(',');
```
✅ .NET 10
```csharp
foreach (Range range in input.AsSpan().Split(','))
{
ReadOnlySpan<char> segment = input.AsSpan(range);
}
```
✅ .NET 8 fallback — manual `IndexOf` loop on the span (no allocation)
```csharp
ReadOnlySpan<char> remaining = input.AsSpan();
while (!remaining.IsEmpty)
{
int idx = remaining.IndexOf(',');
ReadOnlySpan<char> segment = idx < 0 ? remaining : remaining[..idx];
// ... use segment ...
remaining = idx < 0 ? default : remaining[(idx + 1)..];
}
```
**Impact: 208 bytes → 0 bytes per split, 2x faster on .NET 10. The manual .NET 8 loop is also zero-allocation but more verbose.**
### Use UTF8 String Literals (u8 suffix)
🟡 **DO** use the `u8` suffix for compile-time UTF8 `ReadOnlySpan<byte>` | .NET 7+
```csharp
byte[] header = Encoding.UTF8.GetBytes("Content-Type");
```
```csharp
ReadOnlySpan<byte> header = "Content-Type"u8;
```
**Impact: 17ns → 0.006ns — eliminates runtime transcoding entirely.**
### Use ReadOnlySpan\<char\> Pattern Matching with switch
🟡 **DO** use `switch` on `ReadOnlySpan<char>` for allocation-free string matching | C# 11+
```csharp
switch (attr.Value.Trim()) { case "preserve": /* ... */ break; }
```
```csharp
switch (attr.Value.AsSpan().Trim())
{
case "preserve": return Preserve;
case "default": return Default;
}
```
**Impact: Eliminates string allocation from Trim() in switch-based dispatch.**
### Use params ReadOnlySpan\<T\> to Eliminate Array Allocations
🟡 **DO** add `params ReadOnlySpan<T>` overloads to library methods | **.NET 10 (or .NET 9) only — requires C# 13**
❌ (the only option on .NET 8 — accept the array allocation, or add explicit 1/2/3-argument overloads)
```csharp
public static void Log(params string[] messages) { /* ... */ }
Log("Starting", "Processing", "Done");
```
✅ .NET 10
```csharp
public static void Log(params ReadOnlySpan<string> messages) { /* ... */ }
Log("Starting", "Processing", "Done");
```
✅ .NET 8 fallback — keep `params string[]` and add fixed-arity overloads for the hot common cases
```csharp
public static void Log(string m) { /* ... */ }
public static void Log(string m1, string m2) { /* ... */ }
public static void Log(string m1, string m2, string m3) { /* ... */ }
public static void Log(params string[] messages) { /* fallback for 4+ args */ }
```
**Impact: Eliminates params array allocation on .NET 10. On .NET 8 fixed-arity overloads cover the hot 13 argument cases.**
### Avoid Chained String-Returning Operations
🟡 **AVOID** chains of 3+ string-returning method calls that each allocate intermediates | .NET Core+
**Pattern 1: Chained .Replace() calls**
```csharp
string result = input.Replace("a", "b").Replace("c", "d").Replace("e", "f");
```
```csharp
var sb = new StringBuilder(input.Length);
// single pass replacing all patterns
```
**Pattern 2: Chained Regex.Replace() calls**
```csharp
public static string Underscore(this string input) =>
Regex3.Replace(Regex2.Replace(Regex1.Replace(input, "$1_$2"), "$1_$2"), "_").ToLower();
```
```csharp
return string.Create(totalLength, state, (span, s) => { /* write directly */ });
```
**Pattern 3: += string concatenation in loops**
```csharp
string result = "";
foreach (var part in parts)
result += separator + part;
```
```csharp
var sb = new StringBuilder();
foreach (var part in parts)
sb.Append(separator).Append(part);
return sb.ToString();
```
**Impact: Eliminates N-1 intermediate string allocations per chain. For `+=` in loops, eliminates O(n²) total allocation.**
### Cache char.ToString() for Known Character Sets
🟡 **DO** cache `char.ToString()` results when the set of characters is small and known | .NET Core+
```csharp
return symbol.ToString();
foreach (var prefix in UnitPrefixes)
input = input.Replace(prefix.Value.Name, prefix.Key.ToString());
```
```csharp
private static readonly FrozenDictionary<char, string> s_charStrings =
new Dictionary<char, string>
{
['k'] = "k", ['M'] = "M", ['G'] = "G",
}.ToFrozenDictionary();
return s_charStrings[symbol];
```
**Impact: Eliminates one string allocation per char.ToString() call. Significant when called in loops or on hot paths.**
## Detection
Scan recipes for memory and string anti-patterns. Run these and report exact counts.
```bash
# .ToLower()/.ToUpper() without culture parameter (allocates + culture-sensitive)
grep -rn --include='*.cs' -E '\.(ToLower|ToUpper)\(\)' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Chained .Replace( calls (3+ on one line — intermediate string allocations)
grep -rn --include='*.cs' '\.Replace(.*\.Replace(.*\.Replace(' --exclude-dir=bin --exclude-dir=obj . | wc -l
# params in method signatures (array allocation per call)
grep -rn --include='*.cs' 'params ' --exclude-dir=bin --exclude-dir=obj . | wc -l
# LINQ on strings — .All/.Any on IEnumerable<char> (replace with foreach loop)
grep -rn --include='*.cs' -E '\.(All|Any)\(char\.' --exclude-dir=bin --exclude-dir=obj . | wc -l
```
### Patterns Requiring Manual Review
- **Boxing via string.Format**: Can't determine argument types from grep — needs type analysis
- **`+=` string concatenation in loops**: `+=` matches all types (int, list, event, string) — needs type context to confirm string
- **`char.ToString()`**: Requires knowing the variable type is `char` — not reliably greppable

View file

@ -0,0 +1,95 @@
# Regex Patterns
### Choose the Right Regex Engine Mode
🟡 **DO** use `[GeneratedRegex]` for all static regex patterns, but never remove `NonBacktracking` if present | .NET 7+
```csharp
var r = new Regex(dynamicPattern, RegexOptions.Compiled);
```
```csharp
[GeneratedRegex("pattern")]
private static partial Regex MyRegex();
var safe = new Regex(untrustedPattern, RegexOptions.NonBacktracking);
var oneOff = new Regex("pattern");
```
**Impact: Source generator is always beneficial for static patterns. NonBacktracking prevents O(2^N) worst case — never remove it if present.**
### Use IsMatch When You Only Need a Boolean Result
🟡 **DO** use `IsMatch` instead of `Match(...).Success` | .NET 7+
```csharp
bool found = Regex.Match(input, pattern).Success;
```
```csharp
bool found = Regex.IsMatch(input, pattern);
```
**Impact: Avoids Match object allocation; with NonBacktracking, ~3x faster by skipping capture computation.**
### Use Regex.Count/EnumerateMatches Instead of Matches
🟡 **DO** use `Count()` and `EnumerateMatches()` for allocation-free match processing | .NET 7+
```csharp
int count = 0;
Match m = regex.Match(text);
while (m.Success) { count++; m = m.NextMatch(); }
```
```csharp
int count = regex.Count(text);
foreach (ValueMatch m in Regex.EnumerateMatches(text, @"\b\w+\b"))
{
ReadOnlySpan<char> word = text.AsSpan(m.Index, m.Length);
}
```
**Impact: ~3x faster than Match/NextMatch with NonBacktracking. Zero allocations for both Count and EnumerateMatches.**
### Use Span-Based Regex APIs for Allocation-Free Matching
🟡 **DO** use `ReadOnlySpan<char>` overloads for regex matching on spans | .NET 7+
```csharp
string sub = largeBuffer.Substring(start, length);
bool found = Regex.IsMatch(sub, pattern);
```
```csharp
ReadOnlySpan<char> text = largeBuffer.AsSpan(start, length);
foreach (ValueMatch m in Regex.EnumerateMatches(text, @"\b\w+\b"))
{
ReadOnlySpan<char> word = text.Slice(m.Index, m.Length);
}
```
**Impact: Eliminates string allocations when working with spans — particularly valuable in high-throughput parsing pipelines.**
## Detection
Scan recipes for regex anti-patterns. Run these and report exact counts.
```bash
# Compiled regex count (startup cost budget — compare ratio to GeneratedRegex)
grep -rn --include='*.cs' 'RegexOptions.Compiled' --exclude-dir=bin --exclude-dir=obj . | wc -l
# GeneratedRegex count (already optimized — verify the inverse)
grep -rn --include='*.cs' 'GeneratedRegex' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Uncached new Regex() calls (construction cost per call)
grep -rn --include='*.cs' 'new Regex(' --exclude-dir=bin --exclude-dir=obj . | wc -l
```
When `RegexOptions.Compiled` appears inside a class constructor or field initializer of an instantiated class (not a static singleton), count how many instances of that class are created at startup to determine total compiled regex budget. For example, if a `Rule` class compiles a regex in its constructor and 122 rules are registered, that is 122 compiled regexes at startup.
### Patterns Requiring Manual Review
- **`new Regex(` uncached**: Field assignment may span multiple lines — grep on one line is unreliable. Verify that matched instances are stored in `static readonly` fields or `[GeneratedRegex]`.

View file

@ -0,0 +1,38 @@
# Structural Patterns
Patterns detected by the **absence** of a keyword or interface. These require codebase-wide counting scans, not single-file matching.
### Seal Classes for Devirtualization
🟡 **DO** seal all leaf classes (those not subclassed) | .NET Core 3.0+
Sealing lets the JIT devirtualize/inline virtual calls and use pointer comparison for type checks. Every non-abstract, non-static class that is not subclassed should be sealed.
**Detection:** This is an absence pattern — scan for classes that are NOT sealed.
```bash
# Count unsealed (non-abstract, non-static) classes
grep -rn --include='*.cs' -E '^\s*((public|internal|private|protected|file)\s+)?(partial\s+)?class ' --exclude-dir=bin --exclude-dir=obj . | grep -v 'sealed' | grep -v 'abstract' | grep -v 'static' | wc -l
# Count already-sealed classes (verify the inverse)
grep -rn --include='*.cs' 'sealed class' --exclude-dir=bin --exclude-dir=obj . | wc -l
```
**Exclusions:** Do not seal classes that are subclassed elsewhere in the codebase. Identifying base classes requires manual review — grep for `: ClassName` patterns and cross-reference, but expect false positives from interface implementations and generic constraints.
```csharp
internal class MyHandler : Base
{ public override int Run() => 42; }
```
```csharp
internal sealed class MyHandler : Base
{ public override int Run() => 42; }
```
**Impact: Virtual calls up to 500x faster; type checks ~25x faster. Severity scales with count.**
**Scale-based severity:**
- 1-10 unsealed leaf classes → Info
- 11-50 unsealed leaf classes → 🟡 Moderate
- 50+ unsealed leaf classes → 🟡 Moderate (elevated priority)

View file

@ -0,0 +1,145 @@
---
name: dotnet-security-review
description: >-
Performs a systematic C#/ASP.NET Core security code review on .NET 8 (C# 12)
and .NET 10 (C# 14) codebases. Covers OWASP Top 10, authentication/authorization
audit, input validation, cryptography, dependency vulnerabilities, security
headers, middleware pipeline, and CI/CD security posture.
metadata:
platform: ".NET 8 and .NET 10 (no .NET 9 projects in scope)"
---
# Security Code Review for C# / ASP.NET Core (.NET 8 + .NET 10)
You are a security auditor performing a thorough, evidence-based code review. Every finding MUST include file path, line number, severity, impact, and a concrete fix.
## Step 0 — Detect the target framework
Before scoring findings, follow `../../references/detect-target-framework.md`. The security guidance below applies to **both** .NET 8 and .NET 10 unless explicitly marked. A few items are .NET 10-only — when reviewing a .NET 8 project, don't recommend them as "fixes":
- **ASP.NET Core Identity passkeys** (`AddPasskeys()`) — .NET 10 only. On .NET 8, recommend external IdP / `Fido2NetLib` or password+TOTP.
- **Minimal-API built-in validation** (`AddValidation()`) — .NET 10 only. On .NET 8, FluentValidation + `IEndpointFilter` is the safe equivalent.
- **First-party `Microsoft.AspNetCore.OpenApi`** — .NET 9+ only. On .NET 8 the project should use `Swashbuckle.AspNetCore`; flag missing OpenAPI security schemes accordingly.
- **`HybridCache`** — .NET 9+ only. On .NET 8 verify `IDistributedCache` configurations (encryption-at-rest, key prefixing, TLS to Redis) directly.
- The C# 14 `field` keyword, `extension(...)` blocks, null-conditional assignment, and partial constructors **do not compile on net8.0** — never propose security fixes that introduce them on a .NET 8 project.
All cryptography, JWT, authorization-policy, header, and middleware guidance applies identically on both targets.
## Target Selection
The user's arguments are in `$ARGUMENTS`.
- If `$ARGUMENTS` contains a file path or directory, review that target.
- If `$ARGUMENTS` is "all", review the entire codebase starting from the solution root.
- If `$ARGUMENTS` is empty, run `git diff --name-only HEAD~5` to find recently changed `.cs` files. If none, ask the user what to review.
When reviewing a directory or "all", use Glob to find `**/*.cs` files, then prioritize:
1. Controllers, filters, middleware (`*Controller.cs`, `*Filter.cs`, `Program.cs`)
2. Auth handlers and delegating handlers (`*Handler.cs`, `*DelegatingHandler.cs`)
3. Service implementations handling external input or secrets
4. Repository and data access code
5. Configuration and DI registration (`*Extensions.cs`, `*Options.cs`)
6. Validators
## Review Process
Execute each phase sequentially. Use the Read tool for files and the Grep tool for pattern searches. NEVER use bash `grep` or `rg` -- always use the Grep tool.
### Phase 1: Automated Pattern Scanning
Read `references/scanning-patterns.md` for the full pattern catalog. Run all Grep searches in parallel across `.cs` files in the target scope. Each pattern targets a specific vulnerability class: injection, deserialization, cryptography, async anti-patterns, data exposure, SSRF, missing controls, ReDoS, log injection, open redirect, cookie security, file upload, claims safety, and thread safety.
### Phase 2: File-by-File Deep Review
Read `references/deep-review-categories.md` for the complete checklist (Categories A through L). For each file in scope (or top ~20 most security-relevant files when reviewing "all"), check all applicable categories:
- **A**: Authentication & Authorization (JWT validation, auth schemes, IDOR)
- **B**: Input Validation
- **C**: Error Handling & Information Leakage
- **D**: Cryptography & Secrets
- **E**: Data Protection & PII
- **F**: Concurrency & State Safety
- **G**: CancellationToken Propagation
- **H**: HTTP Client Security (resilience handlers, DNS refresh)
- **I**: Configuration Security
- **J**: Logging & Monitoring Security
- **K**: Output Encoding & Response Security
- **L**: Supply Chain & Build Security
### Phase 3: Architecture & Project-Specific Checks
Read `references/architecture-checks.md` for checks tailored to common ASP.NET Core project patterns. These cover endpoint authorization verification, anonymous endpoint abuse potential, OTP/MFA security, exception handling coverage, optimistic concurrency, state expiry, blob storage SAS security, message queue security, JSON serialization settings, background task queue safety, rate limiting, security headers, middleware ordering, and NuGet audit configuration.
Read the project's CLAUDE.md or AGENTS.md for project-specific architecture details to inform these checks.
### Phase 4: Dependency Vulnerability Check
Read `references/dependencies-and-headers.md` (Phase 4 section) for dependency scanning patterns. Check `.csproj` files for known-vulnerable versions and NuGet audit configuration.
### Phase 5: Security Headers & Middleware Pipeline
Read `references/dependencies-and-headers.md` (Phase 5 section) for the 14-item headers checklist and middleware ordering verification.
## Output Format
### Security Review Report
**Scope:** [files/directories reviewed]
**Date:** [current date]
**Risk Summary:** [X CRITICAL, Y HIGH, Z MEDIUM, W LOW, V INFO]
#### Findings
For each finding:
**[SEVERITY] [SHORT-TITLE]**
- **Location:** `file/path.cs:LINE`
- **Category:** [OWASP category or security domain]
- **Description:** [What the vulnerability is and why it matters]
- **Impact:** [What an attacker could achieve]
- **Recommendation:** [Specific fix with code example]
#### Summary Table
| # | Severity | Category | File | Description |
|---|----------|----------|------|-------------|
| 1 | CRITICAL | ... | ... | ... |
#### Recommendations
1. Immediate fixes (CRITICAL/HIGH)
2. Short-term improvements (MEDIUM)
3. Long-term hardening (LOW/INFO)
4. Tooling recommendations (NuGet audit, SAST integration, etc.)
## Severity
Use standard severity: CRITICAL > HIGH > MEDIUM > LOW > INFO. CRITICAL = actively exploitable, HIGH = significant with effort, MEDIUM = increased attack surface, LOW = minor improvement, INFO = hardening suggestion.
## Anti-Rationalization Table
| Rationalization | Reality |
|---|---|
| "This is just a test file" | Test code handling secrets or auth IS production-relevant. Report as INFO. |
| "Probably a false positive" | ALWAYS read surrounding code before dismissing. If you cannot prove it safe, report it. |
| "The framework handles this" | Verify the protection is actually enabled and configured. Defaults can be overridden. |
| "Internal API, not public-facing" | Internal APIs are attacked via SSRF, supply chain, lateral movement. |
| "No one would exploit this" | Threat models change. Report it; let the team decide risk acceptance. |
## Red Flags
STOP and investigate deeper if you encounter any of these:
- Any endpoint without an explicit auth attribute (`[Authorize]` or `[AllowAnonymous]`)
- Any `catch` block returning raw exception data to the client
- Any hardcoded key, token, password, or connection string literal
- Any `new HttpClient()` (should use `IHttpClientFactory`)
- Any `TypeNameHandling` value other than `None`
## Important Guidelines
1. Only report real findings with evidence (file path and line number). Do not speculate.
2. If a pattern search returns no results, note "No issues found" and move on.
3. For false positives (e.g., `System.Random` in tests, not production), note as INFO with explanation.
4. Prioritize production code over test code.
5. When reviewing "all", cap the report at the 30 most significant findings.
6. ALWAYS verify context before reporting -- a pattern match alone is not a finding. Read the surrounding code.

View file

@ -0,0 +1,134 @@
# Architecture & Project-Specific Checks Reference
# Phase 3 checks for common ASP.NET Core project patterns. Read the project's CLAUDE.md
# or AGENTS.md for project-specific details (endpoint list, service names, DI registrations)
# to inform these checks.
## Check 1: Endpoint Auth Matrix Verification
Cross-reference the controller's actual `[Authorize]`/`[AllowAnonymous]` attributes against the project's documented auth requirements. Read CLAUDE.md or AGENTS.md for the expected auth matrix. Any mismatch is CRITICAL.
For projects with multiple auth schemes (e.g., Azure AD + custom JWT), verify each endpoint uses the correct scheme/policy.
## Check 2: Anonymous Endpoint Abuse Potential
For each `[AllowAnonymous]` endpoint, verify:
- Rate limiting or throttling exists for sensitive operations (e.g., code generation, login attempts)
- Enumeration attacks are mitigated (IDs are GUIDs or non-sequential, not auto-increment)
- No state modification without prior authentication or verification (e.g., OTP first)
## Check 3: OTP / MFA Security Review
If the project implements OTP or MFA, read the service implementation and verify:
- Code length is sufficient (6+ characters)
- Codes are generated with `RandomNumberGenerator`
- Hash is SHA-256 or stronger (not MD5/SHA1)
- Expiry is enforced (typically 5-10 minutes)
- Wrong attempt counter increments correctly and triggers lockout after a threshold
- No timing side-channel in hash comparison
## Check 4: Exception Handling Coverage
Grep for `throw new` statements. Verify that all thrown exceptions are either:
- The project's structured error type (e.g., `ApiException`, `DomainException`, or the project's custom base exception), OR
- Known typed exceptions for external service failures
Any unstructured exception thrown from handler/service code may bypass error filters and leak internal details.
## Check 5: Optimistic Concurrency on State Writes
If using a database with optimistic concurrency (ETags, row versions):
- Verify every write/update operation passes the concurrency token
- Verify the concurrency token store/tracking mechanism is consulted on every read/write cycle
## Check 6: Expired State Handling
If the project uses application-level state expiry (not DB TTL):
- Verify expired records are deleted or excluded on read (not returned to callers)
- Verify callers cannot act on expired data
## Check 7: Blob Storage SAS URL Security
If the project generates SAS URLs for blob storage:
- SAS token expiry is short-lived (minutes, not days)
- Permission is read-only (not write/delete)
- Scoped to the specific blob (not container-level)
## Check 8: Message Queue Security
If the project uses message queues (Service Bus, RabbitMQ, etc.):
- Messages do not contain secrets or unnecessary PII
- Queue connections use managed identity or connection strings from secret stores
## Check 9: JSON Serialization Settings
Check that `TypeNameHandling` is set to `None` (default) and not `Auto`/`All` anywhere. This applies to both Newtonsoft.Json and any custom serializer configuration.
## Check 10: Background Task Queue Safety
If the project uses a background task queue:
- Bounded capacity prevents unbounded memory growth
- Backpressure is handled correctly (not silently dropping critical events like audit logs)
- Task failures are observed and logged/metered
## Check 11: Custom Token / JWT Security
If the project issues its own JWTs (not just validating external tokens):
- **Algorithm**: HMAC-SHA256 or stronger (RSA for distributed validation)
- **Signing key source**: Key loaded from configuration/secret store, NOT hardcoded
- **Signing key length**: Minimum 256 bits (32 bytes) for HMAC-SHA256
- **Token expiry**: Appropriately capped (tokens should not outlive the session/resource they protect)
- **Claims validation**: Custom claims (e.g., resource IDs) are validated against route parameters by an authorization handler
- **TokenValidationParameters**: `ValidateIssuer`, `ValidateAudience`, `ValidateLifetime`, `ValidateIssuerSigningKey` all `true`
- **ClockSkew**: Tightened from default 5 minutes to 2 minutes or less
## Check 12: Response Data Sanitization
If the project sanitizes response data (e.g., stripping internal paths or fields):
- Sanitization handles malformed input gracefully (does not throw/crash)
- Only known sensitive fields are stripped (no over-stripping that breaks functionality)
- Sanitization is applied on every code path returning the data (not just the happy path)
## Check 13: Rate Limiting
Verify rate limiting posture:
- Grep for `AddRateLimiter` and `UseRateLimiter` -- if absent, note as finding
- Application-level throttling exists for sensitive operations (e.g., SMS/code generation resend limits)
- Brute force protection exists for verification endpoints (wrong attempt lockout)
- **Recommendation**: Add ASP.NET Core `System.Threading.RateLimiting` middleware for IP-based throttling on public endpoints
## Check 14: Security Headers Completeness
Check Program.cs / middleware for these headers (report missing ones):
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
- `X-XSS-Protection: 0` (disable legacy XSS filter; CSP is the modern replacement)
- `Content-Security-Policy` (at minimum for APIs: `default-src 'none'`)
- `Server` header removed
- `X-Powered-By` header removed
## Check 15: Middleware Pipeline Ordering
Read Program.cs and verify correct middleware order:
1. `UseExceptionHandler` (outermost -- catches everything)
2. `UseHsts` (non-development only)
3. `UseHttpsRedirection`
4. Security headers middleware
5. `UseRateLimiter` (if present)
6. `UseRouting` (if explicit)
7. `UseCors`
8. `UseAuthentication`
9. `UseAuthorization`
10. `MapControllers` / endpoints
Authentication MUST come before Authorization. CORS MUST come before Authentication. ExceptionHandler MUST be first.
## Check 16: NuGet Audit & Build Security
Check for build-level security configuration:
- Does `Directory.Build.props` exist? If so, verify `NuGetAudit`, `NuGetAuditMode`, `NuGetAuditLevel` settings.
- Are Roslyn security analyzer packages referenced? (`SecurityCodeScan.VS2019`, `SonarAnalyzer.CSharp`, `Meziantou.Analyzer`)
- Are `AnalysisLevel` / `AnalysisMode` set in `.csproj` or `Directory.Build.props`?
- Run Grep for floating versions: `Version="[^"]*\*"` in `.csproj` files
- Recommend `dotnet list package --vulnerable --include-transitive` as a CI step

View file

@ -0,0 +1,107 @@
# Deep Review Categories Reference
# File-by-file review checklist for Phase 2. For each file in scope (or top ~20 most
# security-relevant files when reviewing "all"), read the file and check each applicable category.
## Category A: Authentication & Authorization
1. Every controller action has either `[Authorize]` (class or method level) or `[AllowAnonymous]` explicitly.
2. No IDOR: when accessing resources by ID, verify the handler checks that the caller owns or is authorized to access that resource.
3. JWT validation settings are strict: issuer, audience, lifetime, algorithm all validated.
4. Token acquisition uses correct flow: app tokens for backend-to-backend, OBO only where user context is needed.
5. No `[AllowAnonymous]` on endpoints that modify sensitive state without alternative authentication (e.g., OTP verification first).
6. **Multiple auth scheme verification**: If the project uses multiple auth schemes (e.g., Azure AD + custom JWT), verify correct scheme is applied per endpoint. No scheme confusion between internal and client-facing endpoints.
7. **JWT `alg:none` rejection**: Verify `TokenValidationParameters` does NOT allow `alg:none`. All schemes must validate the signing algorithm (`ValidateIssuerSigningKey = true`).
8. **HMAC signing key minimum length**: If using HMAC-SHA256 for JWT signing, the key must be at least 256 bits (32 bytes). Check options validation.
9. **Structured error responses on auth failure**: `OnChallenge` (401) and `OnForbidden` (403) events should return structured JSON error responses, not default HTML/empty responses.
## Category B: Input Validation
1. All DTOs accepted by handlers have corresponding FluentValidation validators registered.
2. Route parameters are validated for format before use (e.g., GUID format, positive integers).
3. File uploads are validated for content type, size, and extension (not just extension).
4. No unvalidated user input flows into file paths, URLs, SQL, commands, or log messages.
5. Phone numbers, emails, and other PII are validated and normalized before processing.
## Category C: Error Handling & Information Leakage
1. All expected errors use a structured error type -- never return raw exception details to clients.
2. Exception filters catch known exception types and return only safe error payloads.
3. Unknown exceptions are wrapped as generic 500 errors without stack traces or internal details.
4. Error messages returned to clients do not reveal internal architecture, database schema, or file paths.
5. Catch blocks never silently swallow exceptions -- they must log or rethrow.
## Category D: Cryptography & Secrets
1. OTP/MFA codes use `RandomNumberGenerator` (not `System.Random`).
2. Hash comparison uses constant-time comparison to prevent timing attacks.
3. Hash storage uses a secure algorithm (SHA-256 minimum; bcrypt/Argon2 for passwords).
4. No secrets, connection strings, or API keys appear in source code or `appsettings.json` committed to git.
5. Options validation (`ValidateOnStart()`) is configured to reject placeholder secrets in production.
## Category E: Data Protection & PII
1. Sensitive fields (phone numbers, etc.) are masked before returning to unauthenticated callers.
2. PII (names, addresses, phone numbers, emails) is not logged in full -- use masking.
3. Sensitive internal fields (hash values, internal IDs) are excluded from API responses.
4. Blob/file storage SAS URLs have appropriate expiry times and permissions (read-only, short-lived).
5. Audit logs do not contain raw PII that violates data protection requirements.
## Category F: Concurrency & State Safety
1. Database state mutations use optimistic concurrency (ETags, row versions, or equivalent).
2. Concurrency exceptions are caught and retried appropriately in handlers.
3. Multi-step validation flows (OTP, MFA) handle concurrent attempts correctly.
4. Counter increments (e.g., wrong attempt counts) are atomic or protected against race conditions.
5. Scheduled/delayed operations do not race with in-progress workflows.
## Category G: CancellationToken Propagation
1. Every `async` method in the call chain accepts `CancellationToken cancellationToken = default`.
2. The token is passed to every awaited call: HTTP calls, DB queries, blob operations, queue sends.
3. The controller passes `HttpContext.RequestAborted` to handlers.
4. Missing propagation is a DoS vector (abandoned requests hold resources).
## Category H: HTTP Client Security
1. HttpClient instances have timeouts configured (not infinite).
2. Delegating handlers do not log tokens or authorization headers.
3. SSL/TLS validation is not disabled (`ServerCertificateCustomValidationCallback` returning true).
4. Retry policies do not retry on authentication failures (401/403).
5. External API clients have adequate timeout and error handling even without resilience middleware.
6. **Standard resilience handler**: Verify `AddStandardResilienceHandler()` or equivalent resilience pipeline is configured on named HttpClients.
7. **Retry-After header respect**: Retry policies should honor `Retry-After` headers from downstream APIs to avoid cascading failures.
8. **DNS refresh**: Verify `SocketsHttpHandler.PooledConnectionLifetime` is set (recommended 2-5 min) to handle DNS changes.
## Category I: Configuration Security
1. CORS policy does not use `AllowAnyOrigin()` in production.
2. Swagger UI is disabled in production (or restricted to authorized users).
3. Health check endpoints do not expose sensitive information.
4. `X-Powered-By` and `Server` headers are removed.
5. HTTPS redirection and HSTS are configured for production.
## Category J: Logging & Monitoring Security
1. Authentication events are logged (both success and failure) for audit trail.
2. Authorization failures are logged with sufficient context (user, endpoint, reason).
3. Input validation failures are logged (not just returned as 400 responses).
4. Structured logging used throughout -- no string interpolation in log method calls (use message templates).
5. Sensitive data (passwords, tokens, PII, hash values) is NEVER logged at any log level.
6. Correlation IDs are included in all error log entries for traceability.
7. Log output is not accessible to API clients (no endpoint returns log data).
## Category K: Output Encoding & Response Security
1. No internal file paths, class names, or assembly names leak in API responses (check error messages, headers).
2. Razor templates are verified for `@Html.Raw()` usage -- must be justified and input-sanitized.
3. `TypeNameHandling.None` verified for Newtonsoft.Json serialization (prevents type injection).
4. `Content-Type` headers are explicitly set on all responses (no browser MIME-sniffing).
5. Response sanitization logic handles malformed input gracefully (no crashes on invalid JSON/data).
## Category L: Supply Chain & Build Security
1. `Directory.Build.props` exists with NuGet audit settings (`NuGetAudit`, `NuGetAuditMode`, `NuGetAuditLevel`).
2. Package versions are pinned (no floating versions like `Version="1.*"`).
3. `AnalysisLevel` and `AnalysisMode` are set to `latest-Recommended` / `Recommended` in build configuration.
4. Security analyzers included in packages (SecurityCodeScan, SonarAnalyzer.CSharp, or Meziantou.Analyzer).
5. No known-vulnerable version ranges in `.csproj` files (check Newtonsoft.Json >= 13.0.1, Microsoft.Identity.Web >= 2.x, System.Text.Json >= 8.0.5).

View file

@ -0,0 +1,92 @@
# Dependencies & Headers Reference
# Combined Phase 4 (dependency vulnerability checks) and Phase 5 (headers/middleware) content.
## Phase 4: Dependency Vulnerability Check
### Automated Grep Checks
Run these Grep patterns against `.csproj` files to detect known-vulnerable version ranges:
| Pattern | Risk |
|---------|------|
| `Newtonsoft\.Json.*Version="([0-9]+)` where major < 13 | CVEs in Newtonsoft.Json < 13.0.1 |
| `Newtonsoft\.Json.*Version="13\.0\.0"` | Pre-patch 13.x |
| `Microsoft\.Identity\.Web.*Version="1\."` | CVEs in Microsoft.Identity.Web < 2.x |
| `System\.Text\.Json.*Version="[0-7]\.\|Version="8\.0\.[0-4]"` | CVEs in System.Text.Json < 8.0.5 |
| `Version="[^"]*\*"` | Floating versions (unpinned, supply chain risk) |
### NuGet Audit Configuration Check
Grep `Directory.Build.props` and `.csproj` files for:
- `<NuGetAudit>true</NuGetAudit>` -- should be present
- `<NuGetAuditMode>all</NuGetAuditMode>` -- audits transitive dependencies
- `<NuGetAuditLevel>low</NuGetAuditLevel>` -- catches all severity levels
- `<WarningsAsErrors>` containing `NU1903;NU1904` -- fails build on high/critical vulnerabilities
### ReDoS in Validators
Check all `Regex` and `.Matches()` calls in validators:
- Pattern: `new Regex\((?!.*RegexOptions\.NonBacktracking)` -- missing NonBacktracking flag (.NET 7+)
- Check for nested quantifiers: `(a+)+`, `(a*)*`, `(a|a)*` patterns
### Command Recommendation
Include in report output (do NOT run automatically):
```bash
dotnet list package --vulnerable --include-transitive
```
## Phase 5: Security Headers & Middleware Pipeline
### Headers Checklist (14 items)
Read `Program.cs` and any middleware configuration files. Check for each header:
| # | Header / Control | Expected Value | Severity if Missing |
|---|-----------------|----------------|---------------------|
| 1 | `X-Content-Type-Options` | `nosniff` | MEDIUM |
| 2 | `X-Frame-Options` | `DENY` | MEDIUM |
| 3 | `Referrer-Policy` | `strict-origin-when-cross-origin` | LOW |
| 4 | `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | LOW |
| 5 | `X-XSS-Protection` | `0` (disable legacy filter; CSP replaces it) | LOW |
| 6 | `Content-Security-Policy` | At minimum `default-src 'none'` for APIs | MEDIUM |
| 7 | `Server` header | REMOVED | LOW |
| 8 | `X-Powered-By` header | REMOVED | LOW |
| 9 | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` | HIGH |
| 10 | `Cache-Control` | `no-store` on sensitive data endpoints | MEDIUM |
| 11 | HTTPS Redirection | `app.UseHttpsRedirection()` present | HIGH |
| 12 | HSTS | `app.UseHsts()` in non-development | HIGH |
| 13 | Rate Limiting | `app.UseRateLimiter()` present | MEDIUM |
| 14 | Swagger restricted | Conditionally enabled (dev/staging only) | MEDIUM |
### Middleware Pipeline Ordering
The correct order for ASP.NET Core middleware is critical. Misordering can bypass security controls.
**Expected order:**
```
1. app.UseExceptionHandler(...) // Outermost: catches all unhandled exceptions
2. app.UseHsts() // HSTS (non-development only)
3. app.UseHttpsRedirection() // Force HTTPS
4. Security headers middleware // Custom: X-Content-Type-Options, etc.
5. app.UseRateLimiter() // Throttle before routing (if present)
6. app.UseRouting() // (implicit in .NET 8+ with MapControllers)
7. app.UseCors(...) // CORS before auth (preflight must not require auth)
8. app.UseAuthentication() // Identify the caller
9. app.UseAuthorization() // Enforce access rules
10. app.MapControllers() // Endpoint dispatch
```
**Critical ordering rules:**
- `UseExceptionHandler` MUST be first -- otherwise exceptions in early middleware are unhandled
- `UseAuthentication` MUST come before `UseAuthorization` -- otherwise auth policies have no identity to check
- `UseCors` MUST come before `UseAuthentication` -- otherwise CORS preflight (OPTIONS) requests fail with 401
- `UseRateLimiter` SHOULD come before `UseRouting` -- otherwise rate limits apply after route matching overhead
- `UseHsts` and `UseHttpsRedirection` SHOULD come early -- before any response body is written
### Middleware Verification Procedure
1. Read `Program.cs` from the `var app = builder.Build()` line to `app.Run()`
2. List every `app.Use*` and `app.Map*` call in order
3. Compare against expected order above
4. Report any misordering as MEDIUM severity

View file

@ -0,0 +1,116 @@
# Scanning Patterns Reference
# Automated Grep patterns organized by vulnerability class for Phase 1 scanning.
# Run all searches in parallel across .cs files in the target scope.
## Injection Vulnerabilities
| ID | Pattern | Target |
|----|---------|--------|
| INJ-1 | `\$".*SELECT\|INSERT\|UPDATE\|DELETE\|DROP\|EXEC` | SQL injection via string interpolation |
| INJ-2 | `string\.Format.*SELECT\|INSERT\|UPDATE\|DELETE` | SQL injection via string.Format |
| INJ-3 | `\.FromSqlRaw\(.*\$"\|\.FromSqlRaw\(.*string\.Format` | EF Core raw SQL injection |
| INJ-4 | `ExecuteSqlRaw\(.*\$"\|ExecuteSqlRaw\(.*string\.Format` | EF Core command injection |
| INJ-5 | `Process\.Start\|ProcessStartInfo` | Command injection |
| INJ-6 | `DirectorySearcher\|LdapConnection` | LDAP injection (check for string concat) |
| INJ-7 | `XmlDocument\|XmlReader\|XDocument` | XXE (verify secure settings) |
| INJ-8 | `Path\.Combine.*Request\|Path\.Combine.*user\|\.\.\/\|\.\.\\` | Path traversal |
## Insecure Deserialization
| ID | Pattern | Target |
|----|---------|--------|
| DES-1 | `BinaryFormatter\|SoapFormatter\|ObjectStateFormatter\|LosFormatter\|NetDataContractSerializer` | Banned deserializers |
| DES-2 | `JsonConvert\.DeserializeObject.*TypeNameHandling` | Newtonsoft type handling |
| DES-3 | `TypeNameHandling\s*=\s*TypeNameHandling\.(All\|Auto\|Objects\|Arrays)` | Unsafe type handling |
## Cryptography Weaknesses
| ID | Pattern | Target |
|----|---------|--------|
| CRY-1 | `new Random\(\)\|System\.Random` | Insecure randomness (should be RandomNumberGenerator) |
| CRY-2 | `MD5\.Create\|SHA1\.Create\|DESCryptoServiceProvider\|RC2CryptoServiceProvider\|TripleDES` | Weak algorithms |
| CRY-3 | `ECB` | Insecure cipher mode |
| CRY-4 | `password\|secret\|key\|token\|credential\|apikey\|connectionstring` in string literals | Hardcoded secrets |
## Async Anti-Patterns
| ID | Pattern | Target |
|----|---------|--------|
| ASY-1 | `\.Result[^s]\|\.Result$` | Sync-over-async deadlock risk |
| ASY-2 | `\.Wait\(\)` | Sync-over-async deadlock risk |
| ASY-3 | `\.GetAwaiter\(\)\.GetResult\(\)` | Sync-over-async |
| ASY-4 | `Task\.Run\(` | Thread pool abuse in ASP.NET context |
## Sensitive Data Exposure
| ID | Pattern | Target |
|----|---------|--------|
| EXP-1 | `_logger\.Log.*password\|_logger\.Log.*secret\|_logger\.Log.*token\|_logger\.Log.*apiKey` (case insensitive) | Logging secrets |
| EXP-2 | `Console\.Write.*password\|Console\.Write.*secret\|Console\.Write.*token` | Console output of secrets |
| EXP-3 | `Html\.Raw\(` | XSS via unencoded HTML |
| EXP-4 | `Exception\.ToString\(\)\|Exception\.StackTrace\|Exception\.Message` returned in HTTP responses | Stack trace leakage |
## SSRF Risks
| ID | Pattern | Target |
|----|---------|--------|
| SSRF-1 | `new HttpClient\(\).*\+\|HttpClient.*GetAsync\(.*\+\|HttpClient.*PostAsync\(.*\+` | User-controlled URLs |
| SSRF-2 | `new Uri\(.*Request\|new Uri\(.*user\|new Uri\(.*input` | Unvalidated URI construction |
| SSRF-3 | `HttpClient.*GetAsync\(.*[^"]\)\|HttpClient.*PostAsync\(.*[^"]\)` | Non-literal URLs in HTTP calls |
| SSRF-4 | `new Uri\([^"]*\)` | Dynamic URI construction |
| SSRF-5 | `IPAddress\.Parse\("\|Uri\("http` | Hardcoded IPs/URLs |
## Missing Security Controls
| ID | Pattern | Target |
|----|---------|--------|
| CTL-1 | `\[HttpPost\]\|\[HttpPut\]\|\[HttpDelete\]\|\[HttpPatch\]` | Unannotated endpoints (check for nearby [Authorize]/[AllowAnonymous]) |
| CTL-2 | `AllowAnyOrigin` | CORS misconfiguration |
| CTL-3 | `app\.UseDeveloperExceptionPage` | Dev error page in production |
| CTL-4 | `#pragma warning disable` | Disabled security warnings |
## ReDoS
| ID | Pattern | Target |
|----|---------|--------|
| REG-1 | `new Regex\((?!.*RegexOptions\.NonBacktracking)` | Regex without NonBacktracking (ReDoS risk in .NET 7+) |
## Log Injection
| ID | Pattern | Target |
|----|---------|--------|
| LOG-1 | `_logger\.Log.*(Request\.Query\|Request\.Form\|Request\.Headers\|Request\.Body)` | Unsanitized request data in logs |
| LOG-2 | `_logger\.Log.*\\n\|_logger\.Log.*\\r` | Newline chars in log messages (log forging) |
## Open Redirect
| ID | Pattern | Target |
|----|---------|--------|
| RED-1 | `Redirect\(\|RedirectToAction\(.*\+` | Open redirect via concatenation |
| RED-2 | `Response\.Redirect\(` | Direct response redirect |
## Cookie Security
| ID | Pattern | Target |
|----|---------|--------|
| COK-1 | `CookieOptions\|\.Cookies\.Append` | Cookie usage (verify HttpOnly, Secure, SameSite) |
| COK-2 | `SameSite\s*=\s*SameSiteMode\.None` | SameSite=None (requires Secure flag) |
## File Upload
| ID | Pattern | Target |
|----|---------|--------|
| UPL-1 | `IFormFile` | File upload handling (verify validation) |
| UPL-2 | `ContentType.*application/octet-stream\|ContentType.*\*\/\*` | Permissive content type acceptance |
## Claims Safety
| ID | Pattern | Target |
|----|---------|--------|
| CLM-1 | `User\.Claims\.First\(\|User\.FindFirst\(.*\.Value(?!\?)` | Null-unsafe claims access (missing ?.) |
## Thread Safety
| ID | Pattern | Target |
|----|---------|--------|
| THR-1 | `static\s+.*HttpClient\s+\w+\s*=\s*new\s+HttpClient` | Static HttpClient instantiation (use IHttpClientFactory) |

View file

@ -0,0 +1,388 @@
---
name: general-prompt-engineer
description: create, repair, compress, and optimize prompts, system messages, tool instructions, schemas, and eval rubrics for general tasks across writing, research, coding, analysis, planning, tutoring, automation, and agent workflows. use when the user wants a new prompt, wants an existing prompt improved, wants prompt failures debugged, or needs better structure for grounding, tool use, output format, or reliability.
model: claude-opus-4-8
effort: xhigh
---
# Prompt Engineer
Build prompts that are clear, compact, reliable, and easy to evaluate. Optimize for modern frontier models, but keep prompts portable across model families unless the user explicitly asks for model-specific tuning.
## Default workflow
1. Diagnose the request.
2. Decide whether prompt changes are the real fix.
3. Gather only missing information.
4. Choose the lightest structure that will work.
5. Draft the prompt.
6. Stress-test it mentally.
7. Deliver only what the user asked for.
### 1) Diagnose the request
Extract:
- objective
- target actor or model
- required output
- constraints and non-goals
- source material and freshness needs
- tool or schema needs
- likely failure modes
- interaction mode: interactive, one-shot, or automated
Before rewriting, check whether the problem is actually caused by:
- the wrong model
- weak or excessive tool design
- missing retrieval or grounding
- missing schema or validation
- missing evals
- an overcomplicated workflow
If prompt changes are not the main lever, say so and adjust the solution.
### 2) Gather only missing information
Ask targeted questions only when the answer would materially change the prompt or output.
Usually clarify:
- output shape
- hard constraints
- source of truth
- allowed tools
- audience or tone, if important
- success criteria or examples, if available
Do not run a long interview. If the user likely wants speed, state a small set of assumptions and proceed.
### 3) Choose the lightest structure that will work
Use this ladder:
- Plain prompt: simple tasks with clear outputs
- Labeled sections: tasks with multiple constraints or source material
- Schema-based prompt: machine-validated output or tool calls
- Staged workflow: multi-step transformations, verification, or research synthesis
- Agent prompt: only when autonomy, tools, or long-horizon execution are required
- Multi-agent design: only if evals or clear role separation justify it
Do not force a giant template onto a small task.
## Core rules
### Instruction clarity
- Put the main task and required output near the top.
- Use direct verbs.
- Say what to do, not only what to avoid.
- Make constraints measurable when possible.
- Name out-of-bounds behavior explicitly.
- Do not make the model infer facts or parameters you already know.
- Remove contradictions before adding more guidance.
### Context loading discipline
- Include only context that helps the task.
- Separate stable instructions from variable task data.
- Label documents, examples, and reference material clearly.
- For source-heavy prompts, keep the operative question easy to find.
- For long-document work, anchor important claims to quoted text, citations, or section references when precision matters.
- For very long or noisy documents, consider an evidence-first step: extract the relevant passages first, then synthesize.
- Remove repeated policies, repeated facts, and ornamental prose.
- When a task is dominated by long source material, use strong delimiters and make the final requested action unmistakable.
### Reasoning control
- Do not force visible chain-of-thought by default.
- For reasoning-first models, prefer concise high-level guidance such as "reason carefully", "check assumptions", or "verify before answering" rather than "think step by step".
- Ask for visible reasoning only when it serves the task: tutoring, auditability, debugging, derivations, safety review, or explicit rationale requests.
- If one prompt is trying to do too much, split it into stages instead of demanding a long visible reasoning trace.
- If the target model supports extended or internal thinking, rely on that before adding verbose reasoning rituals.
### Examples
- Try zero-shot first for strong modern models.
- Add examples only when they reduce ambiguity, enforce style, or demonstrate hard edge cases.
- Keep examples high-quality, diverse, and tightly aligned with the instructions.
- Do not include many examples that teach accidental patterns or waste context.
### Structure and output design
- Use plain markdown or labeled sections for most prompts.
- Use XML tags or equivalent delimiters when instructions, context, examples, and documents might otherwise get mixed together.
- Use schemas when the output must be machine-checked.
- For external actions, use tool or function calling; for user-facing structured data, use structured response formats.
- Design schemas so valid failure states, uncertainty, abstention, or partial completion can be represented when needed.
- Do not over-constrain fields beyond what downstream systems actually require.
- Include fallback behavior for incompatible input, missing fields, uncertainty, or refusal states.
- Treat format validation and content validation as separate problems.
### Tool-use guidance
- Add tools only when the task truly needs external information, computation, or actions.
- Keep the tool set small, distinct, and easy to choose between.
- State when each tool should be used and when it should not be used.
- Prefer tools that return high-signal results over bulky raw dumps.
- Combine tightly coupled actions when that reduces tool-selection ambiguity.
- For complex tools, clear descriptions and valid examples matter more than more tools.
### Grounding and hallucination reduction
- Give the model permission to say "I don't know" or "not enough information".
- Name the allowed sources of truth.
- For document-grounded tasks, require evidence before synthesis when precision matters.
- For fresh, unstable, or high-stakes facts, require browsing or verification.
- Ask the model to separate facts, inferences, and recommendations when confusion is likely.
- In high-stakes domains, unsupported claims should be withheld, not guessed.
### Ambiguity handling
- If ambiguity is blocking and the setting is interactive, ask concise high-leverage questions.
- If ambiguity is non-blocking or interaction is costly, state the best assumption and proceed.
- Avoid clarifying questions that do not materially change the answer.
- In one-shot or automated settings, prefer explicit assumptions over stalled execution.
### Verbosity control
- Set a default brevity level when length matters.
- Constrain section count, sentence count, or bullet count when needed.
- Ask for direct answers first, then supporting detail if useful.
- Do not require long preambles, summaries, or checklists unless they clearly help.
### Modularity and portability
- Keep prompt blocks reusable: role, objective, context, tools, output, quality bar.
- Separate required behavior from optional preferences.
- Avoid vendor-specific magic phrases unless the user wants model-specific tuning.
- If the prompt is model-specific, label which parts are portable and which parts are tuned.
## Model-family adjustments
Use this section only when the target model family is known.
### GPT-5.x and similar reasoning-first models
- Keep prompts simple and direct.
- Prefer high-level reasoning guidance over narrated reasoning instructions.
- Use delimiters for clarity.
- Start zero-shot, then add examples only if needed.
- Be explicit about output shape, scope, and verbosity.
### Claude 4.x, Opus-style models, and extended-thinking modes
- XML-style structure can work especially well for separating instructions, context, examples, and documents.
- Prompt chaining can outperform one giant prompt on multi-step transformations.
- Well-chosen examples can help with format fidelity and edge cases.
- If extended thinking is available, start with broad reasoning instructions before prescribing a detailed step list.
- For long-context analysis, labeled documents and evidence grounding are especially important.
### API and production settings
- Prefer native schema enforcement, tool calling, prompt versioning, and evals over prompt-only fixes.
- Pin model versions when behavior stability matters.
- Re-run evals after each meaningful prompt change.
## Prompt construction pattern
Use only the blocks that earn their token cost.
Minimal pattern:
```text
Task:
Constraints:
Output:
```
Structured pattern:
```xml
<role>...</role>
<objective>...</objective>
<context>...</context>
<constraints>...</constraints>
<tools>...</tools>
<output_format>...</output_format>
<quality_bar>...</quality_bar>
```
Optional blocks:
- `<examples>`
- `<source_material>`
- `<evaluation_criteria>`
- `<fallback_behavior>`
Use a role only when it meaningfully sharpens expertise, tone, or decision criteria. Avoid generic filler roles.
## Rewrite policy for existing prompts
When the user provides a prompt to improve:
1. Preserve what already works.
2. Identify contradictions, redundancy, vagueness, missing constraints, and wasted tokens.
3. Make surgical edits first.
4. Rewrite from scratch only if the prompt architecture is fundamentally wrong.
5. Match the user's requested output:
- edited version only
- clean rebuild only
- both, if useful and requested
## Special-case guidance
### System and developer prompts
- Keep stable behavior here and move per-request data to the task or user layer.
- Put precedence, tool boundaries, non-goals, and refusal or escalation rules in the highest-priority layer.
- Do not bury critical rules inside long policy prose.
### Research prompts
Specify:
- freshness requirements
- preferred source types
- citation behavior
- contradiction handling
- whether to ask questions or cover likely interpretations
- how facts, inferences, and recommendations should be separated
### Writing prompts
Specify:
- audience
- intent
- tone
- length
- must-include points
- style examples only if style fidelity matters
### Coding prompts
Specify:
- environment and versions
- boundaries and non-goals
- files, interfaces, or contracts that matter
- acceptance tests
- minimal-change versus refactor expectations
### Summarization and extraction prompts
Specify:
- whether faithfulness, compression, or completeness is the priority
- the exact output schema
- how evidence should be anchored for sensitive claims
### Translation and transformation prompts
Specify:
- source language and target language, if known
- fidelity versus naturalness
- terminology that must stay fixed
- formatting or markup preservation rules
### Tutoring prompts
Specify:
- learner level
- whether to give the answer immediately or guide toward it
- explanation depth
- how to check understanding
- whether to show full derivations, hints, or worked examples
### Agent and workflow prompts
Specify:
- objective and success condition
- allowed tools and forbidden actions
- when to plan versus when to act
- stop conditions and max retries
- checkpoint, handoff, or log format
- memory rules: what to preserve versus discard
- fallback or escalation path
Use multi-agent designs only when roles are truly distinct and the extra coordination cost is justified.
### Safety-sensitive prompts
Require:
- supported claims
- explicit uncertainty
- refusal or escalation behavior where appropriate
- no guessing under pressure
## Stress-test before delivering
Mentally test the prompt against:
- a normal case
- a minimal-input case
- an edge case
- an ambiguous case
- a formatting case
- a hallucination-prone case
For agent or workflow prompts, also test:
- wrong-tool temptation
- stale-data temptation
- scope creep
- over-verbosity
- fallback behavior
If the prompt fails any test, tighten or simplify it.
## Evaluation method
When the user wants reliability, add or suggest a lightweight eval plan:
1. Define success criteria.
2. Build a test set from real cases plus edge and adversarial cases.
3. Prefer automated grading when possible.
4. Calibrate automated or model-based judges against a smaller human-reviewed set when stakes are meaningful.
5. Use pairwise comparison, classification, pass-fail, or rubric-based scoring instead of only open-ended judgment.
6. Track regressions after each prompt change.
7. Start simple. Add workflows or multi-agent designs only if evals justify them.
Good eval sets usually include:
- common real tasks
- boundary cases
- malformed inputs
- conflicting instructions
- long-context cases
- tool-misuse temptations
- safety-sensitive cases
- multilingual or format-variant inputs, if relevant
## Deliverables
Return only what the user asked for. By default:
1. the final prompt
2. brief usage notes
3. stated assumptions, if any
4. optional variants only when clearly useful:
- minimal
- robust
- model-specific
- api message split
If the user asks for one prompt only, do not add extra frameworks or commentary.
## Anti-patterns
- forcing chain-of-thought everywhere
- confusing verbosity with quality
- piling on redundant rules
- using brittle giant templates for small tasks
- requiring tools without a real need
- exposing unnecessary internal process in user-facing outputs
- adding examples that conflict with the instructions
- asking many clarifying questions when a sane assumption would do
- treating a model, retrieval, or tool problem as only a prompt problem
- building multi-agent systems before a simpler design has been evaluated
- vague quality bars like "be excellent" without measurable criteria
## Final quality bar
A prompt is ready when it is:
- clear about the task
- explicit about success criteria
- free of contradictions
- no more verbose than necessary
- grounded in the right sources
- structured enough for the task, but not heavier than needed
- resilient to likely ambiguity
- matched to the target model and interaction mode
- easy to maintain, test, and adapt

View file

@ -12,7 +12,7 @@ Use this skill when the task needs a real local server loop, not just code readi
- Solution: `MinecraftClient.sln`
- Runtime target: `.NET 10` / `net10.0`
- Environment: Linux, macOS, or WSL with Java, tmux, python3, and dotnet available
- Default server root: `${MCC_SERVERS:-$MCC_REPO/MinecraftOfficial/downloads}`
- Default server root after `source tools/mcc-env.sh`: `${MCC_SERVERS:-<repo>/MinecraftOfficial/downloads}`
- Default validation target when the user does not specify a version: `1.21.11`
## Console modes
@ -34,6 +34,50 @@ Both modes support the same commands and input/output through `ConsoleIO.Backend
- A server log line containing `Done (` means startup finished. It does not guarantee that RCON is ready on the first attempt. Retry early `mc-rcon` commands.
- When instructions, docs, and code disagree, trust current code and current tool behavior first.
## Shared server, isolated MCC sessions
- `mc-*` commands operate on the shared local Minecraft server.
- `mcc-*` commands operate on one MCC client session.
- The default `session` is the current worktree name.
- The default username is derived from `session`, unless you pass `--username`.
- Session files live under `${TMPDIR:-/tmp}/mcc-debug/<session>/`.
- `MCC_SERVERS` stays the shared server-root override.
Keep shared servers running by default. Do not stop or reset them unless the user explicitly asks for that, or you need to switch server versions.
Two worktrees can debug against one shared server like this:
```bash
# worktree A
cd ~/Minecraft/Minecraft-Console-Client
source tools/mcc-env.sh
mc-start 1.21.11
mcc-debug -v 1.21.11 --file-input
# worktree B
cd ~/Minecraft/Minecraft-Console-Client-foo
source tools/mcc-env.sh
mcc-debug -v 1.21.11 --file-input
# from each worktree, mcc-* targets that worktree's default session
mcc-state
```
If you want two MCC sessions from the same worktree, pass `--session NAME` explicitly.
## tmpfs build mode
Use this on machines with enough RAM when you want worktree-isolated builds outside the repo tree:
```bash
source tools/mcc-env.sh
export MCC_BUILD_MODE=tmpfs
mcc-build
mcc-build-clean
```
`MCC_BUILD_MODE=tmpfs` redirects build output to `/dev/shm/mcc-build/<worktree>/` on Linux, or `${TMPDIR:-/tmp}/mcc-build/<worktree>/` if `/dev/shm` is unavailable.
## Preflight and reset
Before scripted runs, especially on macOS or in a reused tmux environment:
@ -49,18 +93,23 @@ mc-reset-test-env 1.21.11
## Build
```bash
dotnet build MinecraftClient.sln -c Release
source tools/mcc-env.sh
mcc-build
```
Use `mcc-build` for normal local development so any `MCC_BUILD_MODE=tmpfs` routing stays active. Only use raw `dotnet build` when you are intentionally debugging the build system itself.
## Server management
Interactive shell:
```bash
source tools/mcc-env.sh
SESSION="$(_mcc_resolve_session)"
USERNAME="$(_mcc_resolve_username "$SESSION")"
mc-start 1.21.11
mc-log 1.21.11 100
mc-rcon "op CursorBot"
mc-rcon "op $USERNAME"
mc-stop 1.21.11
```
@ -68,7 +117,7 @@ Non-interactive shell:
```bash
tools/start-server.sh 1.21.11
tools/mc-rcon.sh "op CursorBot"
tools/mc-rcon.sh "op mcc_smoke_a"
```
If the servers live outside the repo, set `MCC_SERVERS` before sourcing or invoking the tools:
@ -104,16 +153,16 @@ mcc-debug -v 1.21.11 --file-input --no-build
### What mcc-debug.sh does
1. Builds MCC (unless `--no-build`)
2. Creates a clean temp config at `/tmp/mcc-debug/MinecraftClient.debug.ini` with CursorBot account, Terrain/Inventory/Entity enabled and noisy bots disabled
2. Creates a clean temp config at `${TMPDIR:-/tmp}/mcc-debug/<session>/MinecraftClient.debug.ini`
3. Ensures server is running (starts if not, waits for `Done (`)
4. Launches MCC in the specified mode
4. Launches MCC in a session-scoped tmux session and session-scoped log/input/pid files
### After launch
- **FileInput mode**: drive MCC via `mcc-cmd "debug state"` or `echo "debug state" >> mcc_input.txt`
- **Interactive/TUI mode**: attach with `tmux attach -t mcc-debug`, type commands directly
- **Logs**: `tail -f /tmp/mcc-debug/mcc-debug.log` (FileInput mode only; TUI/interactive mode outputs to tmux)
- **Server RCON**: `mc-rcon "op CursorBot"`, `mc-rcon "gamemode creative CursorBot"`
- **FileInput mode**: drive MCC via `mcc-cmd --session smoke-a "debug state"`, or just `mcc-cmd "debug state"` from the same worktree
- **Interactive/TUI mode**: attach with `tmux attach -t mcc-<session>`
- **Logs**: `mcc-log-mcc --session smoke-a` or `tail -f "${TMPDIR:-/tmp}/mcc-debug/<session>/mcc-debug.log"`
- **Server RCON**: grant op or gamemode to the username derived from that session
## Debug commands (in-game)
@ -128,7 +177,7 @@ Prints a one-shot summary of MCC's internal state:
```
=== MCC Debug State ===
Server: localhost:25565
Username: CursorBot
Username: mcc_smoke_a
Protocol: 774
GameMode: 1
Health: 20.0
@ -152,18 +201,20 @@ For agents calling MCC commands programmatically:
```bash
source tools/mcc-env.sh
mcc-debug -v 1.21.11 --file-input --no-build
SESSION="smoke-a"
mcc-debug -v 1.21.11 --file-input --session "$SESSION" --no-build
# Send commands:
mcc-cmd "debug state"
mcc-cmd "inventory player list"
mcc-cmd "entity"
mcc-cmd --session "$SESSION" "debug state"
mcc-cmd --session "$SESSION" "inventory player list"
mcc-cmd --session "$SESSION" "entity"
# Check results:
tail -20 /tmp/mcc-debug/mcc-debug.log
mcc-log-mcc --session "$SESSION"
# Stop:
mcc-cmd "quit"
mcc-cmd --session "$SESSION" "quit"
mcc-kill --session "$SESSION"
mc-stop 1.21.11
```
@ -171,10 +222,11 @@ mc-stop 1.21.11
```bash
source tools/mcc-env.sh
mcc-debug -v 1.21.11
SESSION="live-a"
mcc-debug -v 1.21.11 --session "$SESSION"
# In another terminal:
tmux attach -t mcc-debug
tmux attach -t "mcc-$SESSION"
# Type commands directly in MCC console
```
@ -192,22 +244,23 @@ TUI mode runs Consolonia full-screen in a tmux session. Key differences:
```bash
source tools/mcc-env.sh
mcc-debug -v 1.21.11 -m tui --no-build
SESSION="tui-a"
mcc-debug -v 1.21.11 -m tui --session "$SESSION" --no-build
# Cannot use mcc-cmd (no FileInput); must use tmux send-keys:
tmux send-keys -t mcc-debug "/debug state" Enter
tmux send-keys -t "mcc-$SESSION" "/debug state" Enter
# Read TUI screen:
tmux capture-pane -t mcc-debug -p -S -30
tmux capture-pane -t "mcc-$SESSION" -p -S -30
# Stop:
tmux send-keys -t mcc-debug Escape
tmux send-keys -t "mcc-$SESSION" Escape
```
**Caveat with tmux send-keys and Consolonia**: When sending text containing `/`, the Enter key may need to be sent separately:
```bash
tmux send-keys -t mcc-debug "/inventory player list"
tmux send-keys -t mcc-debug Enter
tmux send-keys -t "mcc-$SESSION" "/inventory player list"
tmux send-keys -t "mcc-$SESSION" Enter
```
## mcc-env.sh quick reference
@ -226,26 +279,29 @@ After `source tools/mcc-env.sh`:
| `mc-wait-stop VER [SEC]` | Wait for server shutdown, with force-kill fallback |
| `mc-reset-test-env [--all|VER...]` | Reset shared tmux server state and stale pipes |
| `mcc-build` | Build MCC |
| `mcc-run [PORT]` | Run MCC classic+FileInput on port |
| `mcc-tui [PORT]` | Run MCC TUI mode in tmux |
| `mcc-cmd "CMD"` | Append command to mcc_input.txt |
| `mcc-kill` | Kill MCC process and debug session |
| `mcc-publish --rid <RID>` | Publish MCC with the repo's CI-like defaults |
| `mcc-build-clean` | Clear the current worktree's build output |
| `mcc-run [--session NAME] [--username NAME] [--port PORT]` | Convenience wrapper for `mcc-debug --file-input --no-build` |
| `mcc-tui [--session NAME] [--username NAME] [--port PORT]` | Convenience wrapper for `mcc-debug -m tui --no-build` |
| `mcc-cmd [--session NAME] "CMD"` | Append a command to one session's input file |
| `mcc-kill [--session NAME]` | Kill one MCC process and session |
| `mcc-debug [OPTS]` | One-step debug session (see above) |
| `mcc-log-mcc` | Tail MCC debug log |
| `mcc-state` | Send `debug state` and print last 30 log lines |
| `mcc-log-mcc [--session NAME]` | Tail one MCC debug log |
| `mcc-state [--session NAME]` | Send `debug state` and print the last 30 log lines |
| `mcc-preflight [VER...]` | Verify Java, tmux, dotnet, python3, and server dirs |
## Temporary config recipe
```bash
source tools/mcc-env.sh
TEST_ROOT="${TMPDIR:-/tmp}/mcc-dev"
CFG="$TEST_ROOT/MinecraftClient.1.21.11.ini"
mkdir -p "$TEST_ROOT"
SESSION="smoke-a"
USERNAME="$(_mcc_resolve_username "$SESSION")"
CFG="$(_mcc_session_root "$SESSION")/MinecraftClient.debug.ini"
mkdir -p "$(_mcc_session_root "$SESSION")"
bash "$MCC_REPO/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" \
"$CFG" \
"1.21.11" \
"CursorBot"
"$USERNAME"
```
For TUI mode, also add:
@ -259,14 +315,14 @@ MCC output should include:
- `[MCC] Server was successfully joined.`
Server output should include:
Server output should include the session-derived username, for example:
- `CursorBot joined the game`
- `mcc_smoke_a joined the game`
Basic command check:
```bash
mcc-cmd "inventory player list"
mcc-cmd --session smoke-a "inventory player list"
```
If a scripted run fails before MCC joins, check for a harness problem before assuming a product regression. Missing `mcc.log`, a pre-join `Connection refused`, or a server that never reached `Done (` usually means shared-state cleanup or startup failed.
@ -292,7 +348,7 @@ If a scripted run fails before MCC joins, check for a harness problem before ass
- Legacy `1.8` and `1.8.9` servers may need `use-native-transport=false` in `server.properties` on some Linux environments.
- For timing-sensitive work, do not trust wall-clock intuition. Use a real server run and capture evidence from logs or test scripts.
- **TUI mode tip**: if the terminal becomes unresponsive after a crash, run `stty sane && reset` to restore it.
- **tmux capture trick**: `tmux capture-pane -t mcc-debug -p -S -50` captures the last 50 lines of a tmux session without attaching.
- **tmux capture trick**: `tmux capture-pane -t mcc-<session> -p -S -50` captures the last 50 lines of a tmux session without attaching.
## Tool files

View file

@ -115,6 +115,89 @@ Use this for TPS, movement-cadence, or packet-cadence work:
Run them against a real server with a temp config and summarize counts from the captured logs.
### 4. Structured components test
Use this after touching any `StructuredComponents` code (registries, component
parsers, subcomponents, codec helpers) to prove every component type in a
version parses on the wire without error:
```bash
bash tools/run-structured-components-test.sh 1.21.11
```
Run a single version (fast, ~2 min) or a matrix:
```bash
for v in 1.20.6 1.21 1.21.2 1.21.5 1.21.11 26.1; do
bash tools/run-structured-components-test.sh "$v"
done
```
The script gives items with every registered component via RCON `/give`, reads
them back with `inventory player list`, and asserts no parse errors in the MCC
log. Version-gated components (v1212+, v1215+, v12111+, v261) are tested only
on the versions that support them. See `SC_Integration_Test_Report.md` for a
reference run across all 6 version groups.
### 6. Dialog integration test
Use this after touching any dialog system code (packet handling, NBT parsing,
models, TUI, command dispatch, the state machine in `DialogManager`, or the
codec in `DialogNbtParser`). Tests all 5 dialog types, button actions (close,
run_command, show_dialog), cancel/dismiss, click-label, and body content:
```bash
tools/run-dialog-test.sh 26.1
```
The script starts the server if needed, generates a temp MCC config, launches
MCC with file-input mode (requires both `MCC_FILE_INPUT=1` and
`MCC_INPUT_FILE=<path>` env vars), sends inline SNBT dialogs via RCON, and
asserts 29 checks against the MCC log.
Key requirements that differ from other test modes:
- FileInputBot is loaded only when `MCC_FILE_INPUT=1` is set in the
environment. The `[ChatBot.FileInput]` config section is ignored at load
time.
- The input file path is controlled by `MCC_INPUT_FILE`, *not* by the config
`File` setting.
- Dialogs use inline SNBT syntax through `ResourceOrIdArgument`, e.g.:
`dialog show <player> {type:"minecraft:notice", title:{text:"Hello"}}`
- The `ActionButton.CODEC` flattens `CommonButtonData` fields (`label`,
`tooltip`, `width`) into the same object as `action` — no `button` wrapper.
### 5. Full inventory regression sweep
Use this when touching inventory snapshots, player/container slot sync, creative inventory, item-slot serialization, packet palettes, game-mode updates, or block-use paths that open containers:
```bash
tools/run-inventory-full-sweep.sh --versions "1.21.10 1.21.11"
```
Default coverage includes:
- player inventory listing and inventory discovery
- creative give/delete
- inventory search
- player right/left click stack split and merge
- player drop one and drop all
- chest open via `useblock`
- container listing and close
- mirrored player slots in container windows
- shift-click and shift-right-click transfer
- container right/left click, cursor stack, drop one, and drop all
- creative middle-click command path
- log scan for packet parse failures, queue-empty crashes, unhandled exceptions, and disconnects
The script writes `summary.tsv` under `RUN_ROOT` and per-version logs under `/tmp/mcc-debug/inventory-full-<version>/mcc-debug.log`.
When a matrix has existing PASS rows, do not rerun them unless a later code change affects that row or the user asks for a full rerun. Derive remaining rows from summaries:
```bash
awk 'FNR>1 && $2=="PASS" {print $1}' /tmp/mcc-inventory-full-sweep/*/summary.tsv | sort -V | uniq
```
## Preconditions
Before running any scenario:
@ -165,6 +248,13 @@ Optionally override the login name with the fourth argument to the config helper
- summarize the latest full-spectrum run
- `tools/run-creative-e2e.sh`
- ordered creative-mode E2E regression scenario
- `tools/run-inventory-full-sweep.sh`
- full inventory command/API sweep across one or more versions
- `tools/run-structured-components-test.sh`
- exercises every structured component via RCON `/give` across versions 1.20.6-26.1
- `tools/run-dialog-test.sh`
- dialog integration test: all 5 types, run_command/show_dialog actions,
cancel/dismiss/click-label, body content; 29 assertions on MCC log
## Evidence Discipline
@ -224,3 +314,10 @@ Always summarize:
- If a test assertion fails, inspect the real MCC output before changing the code or weakening the assertion.
- If an older server behaves oddly on Linux, check `use-native-transport=false` in `server.properties`.
- If a matrix row fails before producing `mcc.log` or a command transcript, treat it as a harness failure, fix the environment, and rerun that row before drawing product conclusions.
- If creative inventory commands report "You must be in Creative gamemode" after RCON switched the player, inspect game-mode update parsing before assuming creative inventory is broken. Modern servers can update local game mode through game event reason `3`.
- If an inventory row crashes with `Queue empty` or `Failed to process incoming packet`, inspect packet palette routing before changing inventory code. A single shifted packet ID can make a healthy inventory feature look broken.
- For chest-open failures, separate product and harness causes. The player may be standing inside the chest or suffocating on older servers. Stand beside the chest, put a floor under the player, and retry `useblock`.
- For shared local servers, a `Done` log line does not prove RCON is ready. Retry setup commands and verify the actual RCON port from `server.properties`.
- If `tools/run-dialog-test.sh` fails with "FileInput Watching: .../mcc_input.txt" pointing to the wrong directory, the `MCC_INPUT_FILE` env var was not set in the tmux command. FileInputBot ignores the config `File` setting entirely.
- If inline SNBT dialogs fail on the server side (`Failed to parse structure: No key ...`), check whether `ActionButton.CODEC` fields are flat (no `button` wrapper) and whether the dialog type fields match the 26.1 server (`label` not `text` in `CommonButtonData`).
- If a dialog integration test fails on "Server showed custom dialog", the dialog packet (id=0x8C in 26.1 play phase) may not have been sent. Verify the RCON command succeeded and the server printed "Displayed dialog to ...".

View file

@ -19,7 +19,7 @@ This skill uses a fixed set of stable commands for local offline integration tes
- `look east`
- `/gamemode survival`
- `respawn`
- `/tp CursorBot 0 -60 0`
- `/tp MCCBot 0 -60 0`
- `smoke_test_from_mcc_full_spectrum`
- `integration_test_chat_response`
@ -29,63 +29,63 @@ Notes:
## Server-side commands via `mc-rcon`
- `op CursorBot`
- `op MCCBot`
- `gamerule sendCommandFeedback true`
- `gamerule logAdminCommands true`
- `time set day`
- `weather clear`
- `say Hello from the server console`
- `msg CursorBot This is a private whisper`
- `effect give CursorBot minecraft:speed 30 1`
- `effect give CursorBot minecraft:regeneration 10 1`
- `kill CursorBot`
- `msg MCCBot This is a private whisper`
- `effect give MCCBot minecraft:speed 30 1`
- `effect give MCCBot minecraft:regeneration 10 1`
- `kill MCCBot`
## Representative entity coverage
- `execute as CursorBot at @s run summon minecraft:cow ~2 ~ ~`
- `execute as CursorBot at @s run summon minecraft:zombie ~4 ~ ~`
- `execute as CursorBot at @s run summon minecraft:creeper ~6 ~ ~`
- `execute as CursorBot at @s run summon minecraft:skeleton ~8 ~ ~`
- `execute as CursorBot at @s run summon minecraft:villager ~-2 ~ ~`
- `execute as CursorBot at @s run summon minecraft:allay ~-4 ~ ~`
- `execute as CursorBot at @s run summon minecraft:armor_stand ~ ~ ~2`
- `execute as CursorBot at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:"minecraft:diamond",count:1}}`
- `execute as CursorBot at @s run summon minecraft:spider ~10 ~ ~`
- `execute as CursorBot at @s run summon minecraft:pig ~-8 ~ ~`
- `execute as MCCBot at @s run summon minecraft:cow ~2 ~ ~`
- `execute as MCCBot at @s run summon minecraft:zombie ~4 ~ ~`
- `execute as MCCBot at @s run summon minecraft:creeper ~6 ~ ~`
- `execute as MCCBot at @s run summon minecraft:skeleton ~8 ~ ~`
- `execute as MCCBot at @s run summon minecraft:villager ~-2 ~ ~`
- `execute as MCCBot at @s run summon minecraft:allay ~-4 ~ ~`
- `execute as MCCBot at @s run summon minecraft:armor_stand ~ ~ ~2`
- `execute as MCCBot at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:"minecraft:diamond",count:1}}`
- `execute as MCCBot at @s run summon minecraft:spider ~10 ~ ~`
- `execute as MCCBot at @s run summon minecraft:pig ~-8 ~ ~`
## Block placement coverage
- `execute as CursorBot at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone`
- `execute as CursorBot at @s run setblock ~5 ~ ~5 minecraft:chest`
- `execute as CursorBot at @s run setblock ~5 ~1 ~5 minecraft:furnace`
- `execute as CursorBot at @s run setblock ~6 ~ ~5 minecraft:crafting_table`
- `execute as MCCBot at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone`
- `execute as MCCBot at @s run setblock ~5 ~ ~5 minecraft:chest`
- `execute as MCCBot at @s run setblock ~5 ~1 ~5 minecraft:furnace`
- `execute as MCCBot at @s run setblock ~6 ~ ~5 minecraft:crafting_table`
## Dimension change coverage
- `execute in minecraft:the_nether run tp CursorBot 0 64 0`
- `execute in minecraft:overworld run tp CursorBot 0 -60 0`
- `execute in minecraft:the_nether run tp MCCBot 0 64 0`
- `execute in minecraft:overworld run tp MCCBot 0 -60 0`
## Representative particle coverage
- `execute as CursorBot at @s run particle minecraft:happy_villager ~ ~1 ~ 0.5 0.5 0.5 0 12 force`
- `execute as CursorBot at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force`
- `execute as CursorBot at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force`
- `execute as CursorBot at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force`
- `execute as CursorBot at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force`
- `execute as CursorBot at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force`
- `execute as MCCBot at @s run particle minecraft:happy_villager ~ ~1 ~ 0.5 0.5 0.5 0 12 force`
- `execute as MCCBot at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force`
- `execute as MCCBot at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force`
- `execute as MCCBot at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force`
- `execute as MCCBot at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force`
- `execute as MCCBot at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force`
## Representative sound coverage
- `execute as CursorBot at @s run playsound minecraft:entity.lightning_bolt.thunder master CursorBot ~ ~ ~ 1 1 0`
- `execute as CursorBot at @s run playsound minecraft:block.note_block.bell master CursorBot ~ ~ ~ 1 1 0`
- `execute as CursorBot at @s run playsound minecraft:entity.experience_orb.pickup master CursorBot ~ ~ ~ 1 1 0`
- `execute as MCCBot at @s run playsound minecraft:entity.lightning_bolt.thunder master MCCBot ~ ~ ~ 1 1 0`
- `execute as MCCBot at @s run playsound minecraft:block.note_block.bell master MCCBot ~ ~ ~ 1 1 0`
- `execute as MCCBot at @s run playsound minecraft:entity.experience_orb.pickup master MCCBot ~ ~ ~ 1 1 0`
## Explosion coverage
- `execute as CursorBot at @s run summon minecraft:tnt ~3 ~ ~`
- `execute as CursorBot at @s run summon minecraft:tnt ~6 ~ ~`
- `execute as MCCBot at @s run summon minecraft:tnt ~3 ~ ~`
- `execute as MCCBot at @s run summon minecraft:tnt ~6 ~ ~`
## Kill and respawn cycle
- `kill CursorBot` (via RCON, requires survival mode)
- `kill MCCBot` (via RCON, requires survival mode)
- `respawn` (via MCC command after death)

View file

@ -73,7 +73,7 @@ wait_for_server_stop() {
((elapsed += 1))
done
mc-kill "$version" >/dev/null 2>&1 || true
mc-kill "$version" --confirm >/dev/null 2>&1 || true
if ! server_running "$version"; then
return 0
@ -104,7 +104,7 @@ remove_stale_stdin_pipe() {
local version="$1"
local pipe_path="$MCC_SERVERS/$version/stdin.pipe"
if [[ -e "$pipe_path" && ! -p "$pipe_path" ]]; then
if [[ -e "$pipe_path" ]] && ! server_running "$version"; then
rm -f "$pipe_path"
fi
}

View file

@ -41,12 +41,12 @@ upsert_property() {
if [[ ! -f "$PROPS_FILE" ]]; then
mc-start "$VERSION"
wait_for_server_ready "$VERSION"
mc-stop "$VERSION"
mc-stop "$VERSION" --confirm
wait_for_server_stop "$VERSION"
fi
if server_running; then
mc-stop "$VERSION"
mc-stop "$VERSION" --confirm
wait_for_server_stop "$VERSION"
fi

View file

@ -28,11 +28,11 @@ if [[ $# -ge 3 && -f "$1" ]]; then
TEMPLATE_INI="$1"
OUTPUT_INI="$2"
MC_VERSION="$3"
LOGIN_NAME="${4:-CursorBot}"
LOGIN_NAME="${4:-MCCBot}"
else
OUTPUT_INI="$1"
MC_VERSION="$2"
LOGIN_NAME="${3:-CursorBot}"
LOGIN_NAME="${3:-MCCBot}"
fi
ACCOUNT_TYPE="${MCC_TEST_ACCOUNT_TYPE:-mojang}"

View file

@ -12,7 +12,7 @@ usage() {
cat <<'EOF'
Usage: reset_shared_test_state.sh [--all | <server-dir>...]
Kills shared tmux test sessions and removes stale stdin pipes.
Kills shared server tmux test sessions and removes stale server stdin pipes.
EOF
}
@ -26,8 +26,6 @@ kill_named_session() {
tmux kill-session -t "$session_name" 2>/dev/null || true
}
kill_named_session "mcc-debug"
if [[ $# -eq 0 || "${1:-}" == "--all" ]]; then
while IFS= read -r session_name; do
[[ -z "$session_name" ]] && continue
@ -36,9 +34,7 @@ if [[ $# -eq 0 || "${1:-}" == "--all" ]]; then
while IFS= read -r pipe_path; do
[[ -z "$pipe_path" ]] && continue
if [[ ! -p "$pipe_path" ]]; then
rm -f "$pipe_path"
fi
rm -f "$pipe_path"
done < <(find "$MCC_SERVERS" -maxdepth 2 -name 'stdin.pipe' 2>/dev/null || true)
else
for version in "$@"; do
@ -46,5 +42,3 @@ else
remove_stale_stdin_pipe "$version"
done
fi
rm -f "$MCC_REPO/mcc_input.txt"

View file

@ -37,6 +37,8 @@ fi
SERVER_DIR="$1"
MC_VERSION="$2"
PROFILE="$3"
SESSION_NAME="achievements-${SERVER_DIR//[^a-zA-Z0-9]/_}-${PROFILE}"
TEST_USERNAME="$(_mcc_resolve_username "$SESSION_NAME")"
if [[ "$PROFILE" != "legacy" && "$PROFILE" != "modern" ]]; then
echo "Unsupported profile: $PROFILE" >&2
@ -55,11 +57,11 @@ COMMAND_LOG="$RUN_DIR/commands.log"
SUMMARY_ENV="$RUN_DIR/summary.env"
PROBE_SCRIPT="$RUN_DIR/achievement_probe.cs"
CFG="$RUN_DIR/MinecraftClient.$MC_VERSION.ini"
INPUT_FILE="$REPO_ROOT/mcc_input.txt"
INPUT_FILE="$(_mcc_session_input_file "$SESSION_NAME")"
SERVER_LOG_FILE="$MCC_SERVERS/$SERVER_DIR/logs/latest.log"
TARGET_ID="minecraft:story/root"
TARGET_COMMAND_GRANT="advancement grant CursorBot only minecraft:story/root"
TARGET_COMMAND_REVOKE="advancement revoke CursorBot only minecraft:story/root"
TARGET_COMMAND_GRANT="advancement grant $TEST_USERNAME only minecraft:story/root"
TARGET_COMMAND_REVOKE="advancement revoke $TEST_USERNAME only minecraft:story/root"
TARGET_TYPE="Modern 🌱"
PORT="unknown"
MCC_PID=""
@ -74,8 +76,8 @@ EXECUTED="yes"
if [[ "$PROFILE" == "legacy" ]]; then
TARGET_ID="achievement.openInventory"
TARGET_COMMAND_GRANT="achievement give achievement.openInventory CursorBot"
TARGET_COMMAND_REVOKE="achievement take achievement.openInventory CursorBot"
TARGET_COMMAND_GRANT="achievement give achievement.openInventory $TEST_USERNAME"
TARGET_COMMAND_REVOKE="achievement take achievement.openInventory $TEST_USERNAME"
TARGET_TYPE="Legacy 🧱"
fi
@ -124,7 +126,7 @@ cleanup() {
wait "$MCC_PID" 2>/dev/null || true
fi
mc-stop "$SERVER_DIR" >/dev/null 2>&1 || true
mc-stop "$SERVER_DIR" --confirm >/dev/null 2>&1 || true
wait_for_server_stop "$SERVER_DIR" 20 >/dev/null 2>&1 || true
ln -sfn "$RUN_DIR" "$LATEST_LINK"
write_summary
@ -286,7 +288,7 @@ if [[ ! -d "$MCC_SERVERS/$SERVER_DIR" ]]; then
fail "Server directory not found: $MCC_SERVERS/$SERVER_DIR"
fi
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" CursorBot >/dev/null || fail "Failed to prepare temporary MCC config."
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" "$TEST_USERNAME" >/dev/null || fail "Failed to prepare temporary MCC config."
PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$SERVER_DIR")"
"$SCRIPT_DIR/ensure_offline_server.sh" "$SERVER_DIR"
@ -296,6 +298,7 @@ if [[ "$PROFILE" == "legacy" && -f "$MCC_SERVERS/$SERVER_DIR/server.properties"
sed_in_place 's/^use-native-transport=.*/use-native-transport=false/' "$MCC_SERVERS/$SERVER_DIR/server.properties"
fi
mkdir -p "$(dirname "$INPUT_FILE")"
: > "$INPUT_FILE"
rm -f "$MCC_LOG"
@ -306,9 +309,9 @@ wait_for_server_ready "$SERVER_DIR" || fail "Server did not become ready."
log_step "Starting MCC for $MC_VERSION"
(
cd "$REPO_ROOT"
MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- \
MCC_FILE_INPUT=1 MCC_INPUT_FILE="$INPUT_FILE" dotnet run --project MinecraftClient -c Release --no-build -- \
"$CFG" \
CursorBot \
"$TEST_USERNAME" \
- \
"localhost:$PORT" \
"--accounttype=mojang" \
@ -323,9 +326,9 @@ log_step "Starting MCC for $MC_VERSION"
MCC_PID=$!
wait_for_file_pattern "$MCC_LOG" "Server was successfully joined." "MCC join success" 90 || fail "MCC failed to join."
wait_for_file_pattern "$SERVER_LOG_FILE" "CursorBot joined the game" "server join entry" 30 || fail "Server never logged the join."
wait_for_file_pattern "$SERVER_LOG_FILE" "$TEST_USERNAME joined the game" "server join entry" 30 || fail "Server never logged the join."
run_server_command "op CursorBot"
run_server_command "op $TEST_USERNAME"
run_server_command "gamerule sendCommandFeedback true"
if [[ "$PROFILE" == "modern" ]]; then
run_server_command "gamerule logAdminCommands true"

View file

@ -17,31 +17,31 @@ RUN_ROOT="${TMPDIR:-/tmp}/mcc-integration-testing"
RUN_ID="$(date +%Y%m%d-%H%M%S)"
RUN_DIR="$RUN_ROOT/$RUN_ID"
SERVER_LOG_FILE="$MCC_SERVERS/$VERSION/logs/latest.log"
MCC_LOG="$RUN_DIR/mcc.log"
SESSION_NAME="full-spectrum-${MC_VERSION//[^a-zA-Z0-9]/_}"
TEST_USERNAME="$(_mcc_resolve_username "$SESSION_NAME")"
MCC_LOG="$(_mcc_session_log_file "$SESSION_NAME")"
PID_FILE="$(_mcc_session_pid_file "$SESSION_NAME")"
MCC_TMUX_SESSION="$(_mcc_tmux_session_name "$SESSION_NAME")"
BUILD_LOG="$RUN_DIR/build.log"
SERVER_TMUX_LOG="$RUN_DIR/server-tmux.log"
SERVER_FILE_LOG="$RUN_DIR/server-latest.log"
INPUT_FILE="$REPO_ROOT/mcc_input.txt"
INPUT_FILE="$(_mcc_session_input_file "$SESSION_NAME")"
CFG="$RUN_DIR/MinecraftClient.$MC_VERSION.ini"
MCC_PID=""
mkdir -p "$RUN_DIR"
cleanup() {
if [[ -n "${MCC_PID:-}" ]] && kill -0 "$MCC_PID" 2>/dev/null; then
mcc-cmd "quit" >/dev/null 2>&1 || true
sleep 2
kill "$MCC_PID" 2>/dev/null || true
wait "$MCC_PID" 2>/dev/null || true
fi
mcc-cmd --session "$SESSION_NAME" "quit" >/dev/null 2>&1 || true
sleep 2
mcc-kill --session "$SESSION_NAME" >/dev/null 2>&1 || true
mc-stop "$VERSION" >/dev/null 2>&1 || true
mc-stop "$VERSION" --confirm >/dev/null 2>&1 || true
wait_for_server_stop "$VERSION" 20 >/dev/null 2>&1 || true
}
trap cleanup EXIT
prepare_config() {
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" CursorBot >/dev/null
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" "$TEST_USERNAME" >/dev/null
}
wait_for_server_log_pattern() {
@ -129,35 +129,57 @@ run_server_command() {
run_mcc_command() {
local cmd="$1"
echo "MCC> $cmd"
mcc-cmd "$cmd"
mcc-cmd --session "$SESSION_NAME" "$cmd"
sleep 2
}
start_mcc_session() {
local -a mcc_args=("$CFG" "$TEST_USERNAME" "-" "localhost:$SERVER_PORT")
local mcc_args_cmd
mcc_args_cmd="$(printf '%q ' "${mcc_args[@]}")"
tmux kill-session -t "$MCC_TMUX_SESSION" 2>/dev/null || true
rm -f "$PID_FILE"
tmux new-session -d -s "$MCC_TMUX_SESSION" -x 160 -y 50 \
"cd '$REPO_ROOT' && printf '%s\n' \"\$\$\" > '$PID_FILE' && exec env MCC_FILE_INPUT=1 MCC_INPUT_FILE='$INPUT_FILE' dotnet run --project MinecraftClient -c Release --no-build -- $mcc_args_cmd > '$MCC_LOG' 2>&1"
for _ in $(seq 1 25); do
if [[ -s "$PID_FILE" ]]; then
return 0
fi
sleep 0.2
done
fail "Failed to capture MCC PID for session $SESSION_NAME"
}
bash "$SCRIPT_DIR/preflight_test_env.sh" "$VERSION" >/dev/null
bash "$SCRIPT_DIR/reset_shared_test_state.sh" "$VERSION" >/dev/null
"$SCRIPT_DIR/ensure_offline_server.sh" "$VERSION"
mcc-reset-session --session "$SESSION_NAME" >/dev/null
echo "Building MCC..."
mcc-build > "$BUILD_LOG" 2>&1 || fail "mcc-build failed"
prepare_config
SERVER_PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$VERSION")"
if [[ -z "$SERVER_PORT" ]]; then
fail "Failed to resolve server port"
fi
mkdir -p "$(dirname "$INPUT_FILE")" "$(dirname "$MCC_LOG")"
: > "$INPUT_FILE"
rm -f "$MCC_LOG"
echo "Starting server..."
mc-start "$VERSION" >/dev/null
wait_for_server_ready "$VERSION" || fail "Server did not become ready"
echo "Starting MCC..."
(
cd "$REPO_ROOT"
MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- "$CFG" CursorBot - "localhost:$SERVER_PORT" > "$MCC_LOG" 2>&1
) &
MCC_PID=$!
start_mcc_session
wait_for_file_pattern "$MCC_LOG" "Server was successfully joined." "MCC join success" 90 || fail "MCC failed to join"
wait_for_server_log_pattern "CursorBot joined the game" "server join entry" 30 || fail "Server never logged the join"
wait_for_server_log_pattern "$TEST_USERNAME joined the game" "server join entry" 30 || fail "Server never logged the join"
run_server_command "op CursorBot"
run_server_command "op $TEST_USERNAME"
run_server_command "gamerule sendCommandFeedback true"
run_server_command "gamerule logAdminCommands true"
run_server_command "time set day"
@ -176,7 +198,7 @@ run_mcc_command "/time query daytime"
run_mcc_command "smoke_test_from_mcc_full_spectrum"
# ── Phase 2: Movement and look commands ──
run_mcc_command "/tp CursorBot 0 -60 0"
run_mcc_command "/tp $TEST_USERNAME 0 -60 0"
sleep 3
run_mcc_command "look up"
sleep 1
@ -193,35 +215,35 @@ run_mcc_command "inventory creativeclear 38"
run_mcc_command "inventory player list"
# ── Phase 4: Block placement and interaction ──
run_server_command "execute as CursorBot at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone"
run_server_command "execute as $TEST_USERNAME at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone"
sleep 2
run_server_command "execute as CursorBot at @s run setblock ~5 ~ ~5 minecraft:chest"
run_server_command "execute as $TEST_USERNAME at @s run setblock ~5 ~ ~5 minecraft:chest"
sleep 1
run_server_command "execute as CursorBot at @s run setblock ~5 ~1 ~5 minecraft:furnace"
run_server_command "execute as $TEST_USERNAME at @s run setblock ~5 ~1 ~5 minecraft:furnace"
sleep 1
run_server_command "execute as CursorBot at @s run setblock ~6 ~ ~5 minecraft:crafting_table"
run_server_command "execute as $TEST_USERNAME at @s run setblock ~6 ~ ~5 minecraft:crafting_table"
sleep 1
# ── Phase 5: Entity spawning (expanded coverage) ──
run_server_command "execute as CursorBot at @s run summon minecraft:cow ~2 ~ ~"
run_server_command "execute as CursorBot at @s run summon minecraft:zombie ~4 ~ ~"
run_server_command "execute as CursorBot at @s run summon minecraft:creeper ~6 ~ ~"
run_server_command "execute as CursorBot at @s run summon minecraft:skeleton ~8 ~ ~"
run_server_command "execute as CursorBot at @s run summon minecraft:villager ~-2 ~ ~"
run_server_command "execute as CursorBot at @s run summon minecraft:allay ~-4 ~ ~"
run_server_command "execute as CursorBot at @s run summon minecraft:armor_stand ~ ~ ~2"
run_server_command "execute as CursorBot at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:\"minecraft:diamond\",count:1}}"
run_server_command "execute as CursorBot at @s run summon minecraft:spider ~10 ~ ~"
run_server_command "execute as CursorBot at @s run summon minecraft:pig ~-8 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:cow ~2 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:zombie ~4 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:creeper ~6 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:skeleton ~8 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:villager ~-2 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:allay ~-4 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:armor_stand ~ ~ ~2"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:\"minecraft:diamond\",count:1}}"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:spider ~10 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:pig ~-8 ~ ~"
sleep 2
run_mcc_command "entity"
# ── Phase 6: Effects and environment ──
run_server_command "effect give CursorBot minecraft:speed 30 1"
run_server_command "effect give $TEST_USERNAME minecraft:speed 30 1"
sleep 2
run_mcc_command "health"
run_server_command "effect give CursorBot minecraft:regeneration 10 1"
run_server_command "effect give $TEST_USERNAME minecraft:regeneration 10 1"
sleep 2
run_mcc_command "health"
@ -233,39 +255,39 @@ run_mcc_command "/gamemode creative"
sleep 2
# ── Phase 8: Dimension change (nether) ──
run_server_command "execute in minecraft:the_nether run tp CursorBot 0 64 0"
run_server_command "execute in minecraft:the_nether run tp $TEST_USERNAME 0 64 0"
sleep 4
run_mcc_command "health"
run_server_command "execute in minecraft:overworld run tp CursorBot 0 -60 0"
run_server_command "execute in minecraft:overworld run tp $TEST_USERNAME 0 -60 0"
sleep 4
# ── Phase 9: Server chat and whisper ──
run_server_command "say Hello from the server console"
sleep 2
run_server_command "msg CursorBot This is a private whisper"
run_server_command "msg $TEST_USERNAME This is a private whisper"
sleep 2
run_mcc_command "integration_test_chat_response"
# ── Phase 10: Particles, sounds, and explosions ──
run_server_command "execute as CursorBot at @s run particle minecraft:happy_villager ~ ~1 ~ 0.5 0.5 0.5 0 12 force"
run_server_command "execute as CursorBot at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force"
run_server_command "execute as CursorBot at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force"
run_server_command "execute as CursorBot at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force"
run_server_command "execute as CursorBot at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force"
run_server_command "execute as CursorBot at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:happy_villager ~ ~1 ~ 0.5 0.5 0.5 0 12 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force"
run_server_command "execute as CursorBot at @s run playsound minecraft:entity.lightning_bolt.thunder master CursorBot ~ ~ ~ 1 1 0"
run_server_command "execute as CursorBot at @s run playsound minecraft:block.note_block.bell master CursorBot ~ ~ ~ 1 1 0"
run_server_command "execute as CursorBot at @s run playsound minecraft:entity.experience_orb.pickup master CursorBot ~ ~ ~ 1 1 0"
run_server_command "execute as $TEST_USERNAME at @s run playsound minecraft:entity.lightning_bolt.thunder master $TEST_USERNAME ~ ~ ~ 1 1 0"
run_server_command "execute as $TEST_USERNAME at @s run playsound minecraft:block.note_block.bell master $TEST_USERNAME ~ ~ ~ 1 1 0"
run_server_command "execute as $TEST_USERNAME at @s run playsound minecraft:entity.experience_orb.pickup master $TEST_USERNAME ~ ~ ~ 1 1 0"
run_server_command "execute as CursorBot at @s run summon minecraft:tnt ~3 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:tnt ~3 ~ ~"
sleep 2
run_server_command "execute as CursorBot at @s run summon minecraft:tnt ~6 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:tnt ~6 ~ ~"
# ── Phase 11: Kill and respawn cycle ──
run_mcc_command "/gamemode survival"
sleep 2
run_server_command "kill CursorBot"
run_server_command "kill $TEST_USERNAME"
sleep 4
run_mcc_command "respawn"
sleep 4
@ -295,14 +317,14 @@ assert_not_contains "$MCC_LOG" "Failed to load settings" "MCC failed to reload i
assert_not_contains "$MCC_LOG" "NullReferenceException" "A NullReferenceException occurred during the test"
# ── Assertions: Server log ──
assert_contains "$SERVER_FILE_LOG" "CursorBot joined the game" "Server never saw CursorBot join"
assert_contains "$SERVER_FILE_LOG" "$TEST_USERNAME joined the game" "Server never saw $TEST_USERNAME join"
assert_contains "$SERVER_FILE_LOG" "smoke_test_from_mcc_full_spectrum" "Server never received the client chat message"
assert_contains "$SERVER_FILE_LOG" "Displaying particle minecraft:happy_villager" "Particle events were not recorded on the server"
assert_contains "$SERVER_FILE_LOG" "Played sound minecraft:block.note_block.bell to CursorBot" "Sound events were not recorded on the server"
assert_contains "$SERVER_FILE_LOG" "Played sound minecraft:block.note_block.bell to $TEST_USERNAME" "Sound events were not recorded on the server"
assert_contains "$SERVER_FILE_LOG" "Summoned new Primed TNT" "TNT summon did not occur on the server"
assert_contains "$SERVER_FILE_LOG" "integration_test_chat_response" "Server never received the chat response test message"
assert_contains "$SERVER_FILE_LOG" "Hello from the server console" "Server say command was not logged"
assert_contains "$SERVER_FILE_LOG" "Killed CursorBot" "Server kill command did not execute"
assert_contains "$SERVER_FILE_LOG" "Killed $TEST_USERNAME" "Server kill command did not execute"
assert_not_contains "$SERVER_FILE_LOG" "Sending unknown packet 'clientbound/minecraft:disconnect'" "Server hit the disconnect packet regression during the test"
cat <<EOF

View file

@ -0,0 +1,199 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
VERSION="${1:-1.21.11-Vanilla}"
MC_VERSION="${VERSION%-Vanilla}"
if [[ "$MC_VERSION" == "$VERSION" ]]; then
MC_VERSION="$VERSION"
fi
SESSION_A="parallel-smoke-a-${MC_VERSION//[^a-zA-Z0-9]/_}"
SESSION_B="parallel-smoke-b-${MC_VERSION//[^a-zA-Z0-9]/_}"
USERNAME_A="SmokeA"
USERNAME_B="SmokeB"
RUN_ROOT="${TMPDIR:-/tmp}/mcc-integration-testing"
RUN_ID="$(date +%Y%m%d-%H%M%S)"
RUN_DIR="$RUN_ROOT/parallel-smoke-$RUN_ID"
BUILD_LOG="$RUN_DIR/build.log"
SERVER_TMUX_LOG="$RUN_DIR/server-tmux.log"
SERVER_FILE_LOG="$RUN_DIR/server-latest.log"
SERVER_LOG_FILE="$MCC_SERVERS/$VERSION/logs/latest.log"
LOG_A="$(_mcc_session_log_file "$SESSION_A")"
LOG_B="$(_mcc_session_log_file "$SESSION_B")"
INPUT_A="$(_mcc_session_input_file "$SESSION_A")"
INPUT_B="$(_mcc_session_input_file "$SESSION_B")"
PID_A_FILE="$(_mcc_session_pid_file "$SESSION_A")"
PID_B_FILE="$(_mcc_session_pid_file "$SESSION_B")"
mkdir -p "$RUN_DIR"
wait_for_file_pattern() {
local file="$1"
local pattern="$2"
local description="$3"
local timeout="${4:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$file" ]] && grep -Fq "$pattern" "$file"; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for: $description" >&2
return 1
}
wait_for_server_log_pattern() {
local pattern="$1"
local description="$2"
local timeout="${3:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$SERVER_LOG_FILE" ]] && grep -Fq "$pattern" "$SERVER_LOG_FILE"; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for server log: $description" >&2
return 1
}
capture_server_logs() {
mc-log "$VERSION" 400 > "$SERVER_TMUX_LOG" 2>/dev/null || true
if [[ -f "$SERVER_LOG_FILE" ]]; then
cp "$SERVER_LOG_FILE" "$SERVER_FILE_LOG"
fi
}
fail() {
capture_server_logs
echo "FAIL: $1" >&2
echo "Run directory: $RUN_DIR" >&2
exit 1
}
cleanup() {
mcc-cmd --session "$SESSION_A" "quit" >/dev/null 2>&1 || true
mcc-cmd --session "$SESSION_B" "quit" >/dev/null 2>&1 || true
sleep 1
mcc-kill --session "$SESSION_A" >/dev/null 2>&1 || true
mcc-kill --session "$SESSION_B" >/dev/null 2>&1 || true
mc-stop "$VERSION" --confirm >/dev/null 2>&1 || true
wait_for_server_stop "$VERSION" 20 >/dev/null 2>&1 || true
}
trap cleanup EXIT
assert_session_alive() {
local session="$1"
local pid_file="$2"
if [[ -s "$pid_file" ]]; then
local pid
pid="$(tr -cd '0-9' < "$pid_file")"
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
return 0
fi
fi
tmux has-session -t "$(_mcc_tmux_session_name "$session")" 2>/dev/null
}
assert_server_alive() {
server_running "$VERSION" || fail "Shared server session is not alive"
}
start_file_input_session() {
local session="$1"
local username="$2"
local port="$3"
bash "$REPO_ROOT/tools/mcc-debug.sh" \
--version "$VERSION" \
--port "$port" \
--file-input \
--no-build \
--session "$session" \
--username "$username" >/dev/null
}
bash "$SCRIPT_DIR/preflight_test_env.sh" "$VERSION" >/dev/null
bash "$SCRIPT_DIR/reset_shared_test_state.sh" "$VERSION" >/dev/null
"$SCRIPT_DIR/ensure_offline_server.sh" "$VERSION" >/dev/null
mcc-reset-session --session "$SESSION_A" >/dev/null
mcc-reset-session --session "$SESSION_B" >/dev/null
echo "Building MCC..."
mcc-build > "$BUILD_LOG" 2>&1 || fail "mcc-build failed"
echo "Starting shared server..."
mc-start "$VERSION" >/dev/null
wait_for_server_ready "$VERSION" || fail "Server did not become ready"
SERVER_PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$VERSION")"
if [[ -z "$SERVER_PORT" ]]; then
fail "Failed to resolve server port"
fi
echo "Starting MCC session A..."
start_file_input_session "$SESSION_A" "$USERNAME_A" "$SERVER_PORT"
echo "Starting MCC session B..."
start_file_input_session "$SESSION_B" "$USERNAME_B" "$SERVER_PORT"
wait_for_file_pattern "$LOG_A" "Server was successfully joined." "session A join success" 90 || fail "Session A failed to join"
wait_for_file_pattern "$LOG_B" "Server was successfully joined." "session B join success" 90 || fail "Session B failed to join"
wait_for_server_log_pattern "$USERNAME_A joined the game" "server join for session A" 30 || fail "Server never logged $USERNAME_A join"
wait_for_server_log_pattern "$USERNAME_B joined the game" "server join for session B" 30 || fail "Server never logged $USERNAME_B join"
echo "Sending debug state to both sessions..."
mcc-cmd --session "$SESSION_A" "debug state"
mcc-cmd --session "$SESSION_B" "debug state"
sleep 2
wait_for_file_pattern "$LOG_A" "[FileInput] > debug state" "session A debug state command" 20 || fail "Session A did not consume debug state"
wait_for_file_pattern "$LOG_B" "[FileInput] > debug state" "session B debug state command" 20 || fail "Session B did not consume debug state"
echo "Killing session A..."
mcc-kill --session "$SESSION_A" >/dev/null 2>&1 || true
sleep 2
assert_session_alive "$SESSION_B" "$PID_B_FILE" || fail "Session B is not alive after killing session A"
assert_server_alive
echo "Verifying session B still responds..."
mcc-cmd --session "$SESSION_B" "health"
wait_for_file_pattern "$LOG_B" "[FileInput] > health" "session B health command after session A kill" 20 || fail "Session B stopped responding after session A kill"
if [[ -s "$PID_A_FILE" ]]; then
pid_a="$(tr -cd '0-9' < "$PID_A_FILE")"
if [[ -n "$pid_a" ]] && kill -0 "$pid_a" 2>/dev/null; then
fail "Session A is still alive after mcc-kill"
fi
fi
capture_server_logs
cat <<EOF
PASS
Run directory: $RUN_DIR
Server version: $VERSION
Server port: $SERVER_PORT
Session A: $SESSION_A ($USERNAME_A)
Session A input: $INPUT_A
Session A log: $LOG_A
Session B: $SESSION_B ($USERNAME_B)
Session B input: $INPUT_B
Session B log: $LOG_B
Server log: $SERVER_FILE_LOG
Build log: $BUILD_LOG
EOF

View file

@ -22,4 +22,4 @@ echo "MCC highlights:"
grep -E "Server was successfully joined|FileInput|smoke_test_from_mcc_full_spectrum|There are [0-9]+ of a max|health|Creative" "$MCC_LOG" || true
echo
echo "Server highlights:"
grep -E "joined the game|Made CursorBot a server operator|game mode|smoke_test_from_mcc_full_spectrum|summon|particle|playsound|tnt" "$SERVER_LOG" || true
grep -E "joined the game|Made .* a server operator|game mode|smoke_test_from_mcc_full_spectrum|summon|particle|playsound|tnt" "$SERVER_LOG" || true

View file

@ -1,406 +0,0 @@
---
name: mcc-prompt-engineer
description: >
Manually triggered skill for the Minecraft Console Client (MCC) project
(https://github.com/MCCTeam/Minecraft-Console-Client). Invoke this skill
when the user wants to create, design, or generate a high-quality prompt for
addressing any MCC-related development request -- bug fixes, new features,
refactors, protocol work, authentication, bot scripting, or architecture
decisions. The skill interviews the user, explores the MCC codebase via
sub-agents, identifies relevant project skills, and synthesises everything
into a state-of-the-art, self-contained prompt that includes an embedded
reasoning framework, plan-mode directives, skill references, and targeted
sub-agent instructions. Do NOT trigger automatically; wait for the user to
explicitly invoke it (e.g. "generate a prompt for...", "build me a prompt",
"/mcc-prompt-engineer", or "use the MCC prompt skill").
compatibility: "Claude Code, Cursor, Codex, GitHub Copilot, Windsurf, and any AI coding agent. Optional tools: AskUserQuestion, Task, WebSearch, plan."
---
# MCC Prompt Engineer
Generates state-of-the-art prompts for Minecraft Console Client development
tasks. Combines live codebase knowledge (via sub-agents and AGENTS.md),
structured prompt engineering patterns, an embedded ULTRATHINK reasoning
framework, and the MCC project's skill ecosystem so the produced prompt is
immediately ready to use in any AI coding agent.
---
## Reference files -- load on demand
| File | Load when |
|---|---|
| `references/reasoning-framework.md` | Embedding the ULTRATHINK protocol into the generated prompt |
| `references/prompt-patterns.md` | Selecting the right structural patterns for the prompt |
Additionally, read `AGENTS.md` at the repository root early in the process.
It contains the authoritative codebase map -- module responsibilities, key
file paths, architecture overview, version support table, and engineering
DO/DON'T guidance -- and replaces the need for broad exploratory file reads.
---
## Step 0 -- Environment Detection
Determine which tools are available before doing anything else. This gates how
you ask questions and spawn sub-agents.
```
Claude Code -> AskUserQuestion and Task tools; plan mode via "plan" tool
or /plan command.
Cursor / Codex -> No AskUserQuestion; ask clarifying questions inline as a
numbered list; sub-agents via parallel tool calls where
supported, otherwise inline.
GitHub Copilot -> Similar to Cursor; use runSubagent where available.
Other agents -> Fall back to inline questions and sequential exploration.
```
Record your environment determination internally before continuing.
---
## Step 1 -- Parse the Request
Extract everything the user has stated. Do not invent requirements or make
assumptions yet. Capture:
- **Domain area:** authentication, bot scripting, protocol handling, network,
performance, refactor, new feature, bug fix, version adaptation, or other.
- **Stated goal:** what the user wants to achieve.
- **Known constraints:** language version (C# 14 / .NET 10), compatibility
requirements, scope limits (additive-only, etc.).
- **References provided:** URLs, file paths, issue numbers, error messages.
- **Ambiguity level:** High (proceed) / Medium (note gaps) / Low (clarify
before continuing).
---
## Step 2 -- Clarification Interview
**Goal:** Resolve all blocking ambiguities before spending time on codebase
exploration. Unblocking questions first saves sub-agent round-trips.
### If in Claude Code
Use the `AskUserQuestion` tool. Ask all questions in a single call -- do not
drip-feed questions turn by turn.
### In any other environment
Print a numbered list of questions. Wait for answers before proceeding.
### Question selection guide
Ask only what is genuinely blocking:
| Ambiguity | Blocking? | Example question |
|---|---|---|
| Scope of change (additive vs rewrite) | Yes | "Should this be additive, or can it replace existing code?" |
| Target .NET / C# version | Yes if non-obvious | "Which .NET version -- 8, 10, or latest?" |
| Auth flow variant | Yes for auth tasks | "Device-code flow, interactive browser, or both?" |
| Performance constraints | Usually no | Skip unless the user mentioned perf |
| Test coverage expectation | Sometimes | "Do you want unit tests, or integration guidance only?" |
**Always ask:**
1. "Is there a specific file, class, or method you already know is the right
starting point?"
2. "Are there any hard constraints -- things the solution must NOT do or touch?"
Offer a best-guess assumption alongside each question so the user can confirm
or correct rather than answer from scratch.
---
## Step 3 -- Codebase Exploration
Start by reading `AGENTS.md` at the repository root. It provides the
authoritative module map, architecture overview, version support table, and
engineering DO/DON'T guidance. Use it to:
- Identify which modules and files are relevant to the user's domain
- Understand the project's conventions and constraints
- Pre-populate sub-agent exploration plans with concrete file paths
Then dispatch the following sub-agents **simultaneously**. Each must return a
concise written summary only -- raw file contents and grep output waste context
and degrade reasoning quality downstream (context rot).
### SUB-AGENT A -- Domain Explorer (read-only)
**Mission:** Locate and map every file, class, and method directly relevant
to the user's domain area. Scope your search using the module map from
AGENTS.md rather than exploring the entire repository.
**Scoped exploration plan (fill in before dispatching):**
```
Files / directories to read:
[derived from AGENTS.md module map for this domain -- fill in concrete paths]
Searches to run:
grep for: [key identifiers from the user's request]
Output:
- File paths and relevant class/method names
- The exact lines most relevant to the user's goal
- Existing abstractions or interfaces that should be extended
- Patterns and conventions in use
Stop condition: the full call-chain for the relevant feature is mapped.
```
### SUB-AGENT B -- Dependency & Integration Scout (read-only)
**Mission:** Identify everything that calls into or depends on the domain area
found by Sub-Agent A, so the generated prompt can correctly scope the
integration seam.
**Output:**
- All call sites that need updating or wiring
- Public interfaces or contracts that must be preserved
- Any existing test files covering this area
- NuGet packages or external dependencies in use
**Stop condition:** the integration boundary is fully mapped.
### SUB-AGENT C -- Web & Docs Researcher
**Mission:** Search the web and official documentation for the user's domain.
Always search the web -- do not limit research to the codebase.
**Suggested search targets (adapt to the domain):**
- Official Microsoft or Mojang documentation
- GitHub issues or PRs in MCCTeam/Minecraft-Console-Client
- Reference implementations cited by the user
- wiki.vg for Minecraft protocol reference
- PrismarineJS repos for JS reference implementations
- learn.microsoft.com for .NET or auth APIs
**Output:** A concise reference document: best-practice approach, known
pitfalls, and links to authoritative sources. Flag conflicting information.
Await all sub-agent summaries before proceeding to Step 4.
---
## Step 4 -- Skill Discovery
Scan the `.claude/skills/` directory in the project root. Read the YAML
frontmatter (name + description) from each skill's `SKILL.md`. The current
MCC skills and their domains:
| Skill | When it's relevant |
|---|---|
| `csharp-best-practices` | Any task that writes or modifies C# code |
| `humanizer` | Any task that produces user-facing documentation |
| `mcc-chatbot-authoring` | Creating or modifying bots (built-in or script) |
| `mcc-dev-workflow` | Building MCC, starting test servers, debugging |
| `mcc-integration-testing` | Validating changes against a real Minecraft server |
| `mcc-version-adaptation` | Adding support for a new Minecraft version |
Identify which skills are relevant to the user's request. Record them for
inclusion in the generated prompt's `<available_skills>` block.
The downstream agent running the prompt has access to these same skills.
Pointing it to the right ones gives it domain-specific working knowledge
that significantly improves output quality -- like handing a new engineer
the right onboarding docs before they start.
---
## Step 5 -- Synthesis
Combine the sub-agent summaries, user answers, AGENTS.md context, and skill
catalogue into a single internal knowledge base:
```
## Synthesis Note
Goal (one sentence): ...
Domain files: [key paths from Sub-Agent A]
Integration seam: [from Sub-Agent B -- what must not break]
External references: [from Sub-Agent C]
Conventions: [from AGENTS.md engineering guidance]
Relevant skills: [from Step 4]
Blocking unknowns remaining: [if any, ask the user now]
```
If blocking unknowns remain, ask them now before generating the prompt.
---
## Step 6 -- Generate the Prompt
Read `references/reasoning-framework.md` and `references/prompt-patterns.md`
now if you have not already.
Build the final prompt using the **Prompt Assembly Checklist** below. Every
item must be addressed -- a missing item is a prompt defect.
### Prompt Assembly Checklist
- [ ] `<role>` block: domain expert covering all relevant technologies.
- [ ] `<context>` block: synthesised from user goal + sub-agent findings.
Include the exact error message or failure mode if provided.
Pre-answer known facts so the downstream agent does not re-derive them.
- [ ] `<agents_md>` directive: instruct the agent to read AGENTS.md for the
module map, architecture, and engineering guidance.
- [ ] `<available_skills>` block: list the relevant skills from Step 4 with
file paths and when to load each one.
- [ ] `<reasoning_protocol>` block: adapted ULTRATHINK framework.
Phase 0 orientation pre-answered where certain.
Phase 1 requirements pre-seeded from the synthesis note.
Phase 2 decomposition pre-seeded with sub-tasks.
Phase 2D exploration plan pre-populated with real file paths.
Phase 4 self-validation items domain-specific and verifiable.
- [ ] Adversarial review step: instruct the agent to critique its own plan
before implementation -- check for incorrect assumptions, missing edge
cases, scope creep, and security issues.
- [ ] Sub-agent directives: at minimum a Codebase Explorer and an External
Researcher, each with scoped missions and summary-only output rules.
- [ ] Plan mode directive: must appear before Phase 0. Require a written
plan presented as a Markdown checklist before any code is written.
- [ ] `<design_goals>` block: 3-6 measurable, verifiable goals.
- [ ] `<scope_constraint>` block: name specific directories, classes, or
files that must NOT be touched.
- [ ] `<output_format>` block: ordered delivery -- planning artefacts first,
then implementation files.
- [ ] Web search mandate in at least one sub-agent directive.
- [ ] Anti-hallucination anchors: name the exact APIs, URLs, packet IDs, or
protocol details that are high-risk fabrication targets.
- [ ] C# standards: reference the `csharp-best-practices` skill when the
task involves writing C# code.
### Prompt structure template
Use this XML skeleton. Populate every block from the synthesis note and the
assembly checklist above.
```xml
<role>
[Domain expert covering: C# 14 / .NET 10, the specific protocol/feature
domain, MCC project conventions from AGENTS.md]
</role>
<context>
[User goal restated. Known error or failure mode. Why the current state
is insufficient. What "done" looks like. Key facts pre-answered.]
</context>
<agents_md>
Read AGENTS.md at the repository root before starting implementation.
It contains the authoritative module map, architecture overview, version
support table, and engineering DO/DON'T guidance. Use it to orient yourself
and scope your exploration. When AGENTS.md and other docs disagree, prefer
current code, then AGENTS.md.
</agents_md>
<available_skills>
The following project skills are at .claude/skills/ and should be loaded
(by reading their SKILL.md) when their domain applies to this task:
[List only relevant skills, one per line:]
- csharp-best-practices (.claude/skills/csharp-best-practices/SKILL.md):
Read before writing or reviewing any C# code.
- [other relevant skills...]
Load skills just-in-time as you reach relevant work, not all upfront.
</available_skills>
<reasoning_protocol>
## Plan Before Code (non-negotiable)
Before writing any implementation code, produce and present a complete
written plan as a Markdown checklist. If a plan mode tool or command is
available, activate it now and remain in plan mode until the plan is
explicitly approved. Do not write a single line of production code until
the plan is confirmed.
[Adapted ULTRATHINK framework from references/reasoning-framework.md.
Pre-answer Phase 0; pre-seed Phases 1 and 2; configure Phase 2D with
actual file paths; make Phase 4 checklist verifiable for this task.
Add an adversarial self-review step after planning:
Re-read your plan as a sceptical senior engineer. Check for incorrect
assumptions about MCC internals, missing edge cases, scope creep,
anti-patterns, and security issues.]
</reasoning_protocol>
<design_goals>
[3-6 measurable, verifiable goals. Each checkable with a yes/no answer.]
</design_goals>
<scope_constraint>
[What must NOT be modified. Name specific directories, classes, or files.
What must remain backwards-compatible. What to avoid even if it seems
helpful.]
</scope_constraint>
<output_format>
[Ordered: planning artefacts first (checklist, design decisions, critique
summary), then implementation files, then compliance report.]
</output_format>
```
### Sub-agent output discipline
Every sub-agent directive in the generated prompt must include:
> "Return a concise written summary only. Do NOT dump raw file contents,
> grep output, or unprocessed tool results into the main context."
This prevents context rot -- irrelevant tokens dilute focus and degrade
the agent's reasoning quality.
---
## Step 7 -- Prompt Quality Gate
Before delivering, verify every item:
```
- [ ] Every block (<role>, <context>, <agents_md>, <available_skills>,
<reasoning_protocol>, <design_goals>, <scope_constraint>,
<output_format>) is present and non-empty.
- [ ] The prompt directs the agent to read AGENTS.md for orientation.
- [ ] <available_skills> lists the correct skills for this task's domain.
- [ ] Phase 2D has actual file paths, not generic placeholders.
- [ ] Plan mode directive appears before Phase 0.
- [ ] All sub-agents have scoped missions and summary-only output rules.
- [ ] At least one sub-agent has an explicit web search mandate.
- [ ] Phase 4 items are objectively verifiable for THIS task.
- [ ] Anti-hallucination anchors target this domain's fabrication risks.
- [ ] Scope constraint is specific enough to prevent accidental drift.
- [ ] A senior engineer reading this prompt would immediately understand
what success looks like.
```
Fix any unchecked items before delivering.
---
## Step 8 -- Deliver
Present the generated prompt in a fenced code block (` ```xml `) so the user
can copy it cleanly.
Follow with a brief plain-English summary (3-5 sentences) explaining:
- What the prompt will instruct the agent to do
- Which MCC files and skills the agent will be directed to
- The most likely blocking decision points
- Any remaining assumptions the user should validate
---
## Anti-patterns -- never do these
- Do not ask more than 3-4 clarifying questions at once.
- Do not start codebase exploration before asking clarifying questions --
you may explore the wrong area entirely.
- Do not generate a prompt that skips the planning phase.
- Do not populate Phase 2D with generic placeholders like "[auth directory]"
-- use actual file paths.
- Do not produce a prompt with vague scope constraints. "Don't touch
unrelated code" requires the agent to guess. Name the specific files
and directories that are out of bounds.
- Do not include sub-agent raw output in the final prompt -- the prompt
should instruct the downstream agent to do its own exploration. Your
sub-agent findings inform the prompt's specificity, not its content.
- Do not list skills in `<available_skills>` that are irrelevant to the task.

View file

@ -1,176 +0,0 @@
# Prompt Engineering Patterns for MCC Tasks
# Reference file — load when selecting structural patterns for the generated prompt
---
## Core Principles (Anthropic / 20252026 Best Practices)
### 1. Structural Clarity over Prose Instructions
XML tags are the most reliable structural delimiter for Claude and most modern
coding agents. Use `<role>`, `<context>`, `<reasoning_protocol>`,
`<design_goals>`, `<scope_constraint>`, and `<output_format>` consistently.
Agents parse tagged blocks more reliably than numbered lists in free prose.
### 2. Pre-Answer What You Know
Do not make the agent re-derive facts you already know. If codebase exploration
has identified the exact failing file and line, put it in `<context>`. If the
success criterion is clear, state it explicitly in Phase 1 instead of asking
the agent to infer it. Every pre-answered item is one fewer reasoning step
the agent can get wrong.
### 3. Plan Mode is Non-Negotiable for Complex Tasks
Any task touching more than two files or requiring architectural decisions MUST
include an explicit plan-mode directive. Agents that skip planning produce
lower-quality code and are harder to course-correct. The directive must appear
before Phase 0 so it gates the entire session.
### 4. Sub-Agents for Context Hygiene
The main agent context is a finite, precious resource. Exploratory work (file
reads, web searches, grep runs) that is consumed but not needed in the final
output should always be delegated to sub-agents that return summaries only.
Keyword: "Return a concise written summary. Do NOT dump raw output into the
main context."
### 5. Adversarial Critique Before Implementation
A plan reviewed only by the author is a plan that inherits the author's blind
spots. Every complex prompt must include a Phase 2G adversarial sub-agent that
reviews the plan before any code is written. This is the single highest-ROI
addition to any agentic prompt.
### 6. Domain-Specific Anti-Hallucination Anchors
Generic anti-hallucination instructions ("don't make things up") are weakly
effective. Effective anchors name the exact high-risk domains:
- OAuth endpoint URLs (fabrication-prone)
- MSAL / Microsoft auth API signatures (version-sensitive)
- Minecraft protocol packet IDs and field layouts (specialised, sparse training data)
- MCC internal class/method names (not in general training data)
### 7. Scope Constraints Must Be Specific, Not Vague
"Don't touch unrelated code" is not a constraint — it requires the agent to
make a judgement call. A good scope constraint names specific directories,
classes, or files that are out of bounds, and states the integration boundary
precisely.
### 8. Output Format as a Delivery Contract
The `<output_format>` block is a contract, not a suggestion. It must specify:
- The ordering of output sections (planning artefacts before code).
- File naming conventions.
- Code block format (fenced, with filename on the opening fence line).
- Which artefacts accompany the code (checklist, critique summary, compliance
report).
---
## Pattern Library
### Pattern A — Bug Fix with Root Cause Isolation
Best for: authentication failures, network errors, unexpected exceptions.
Key additions to the reasoning protocol:
- Phase 1.3 must include implicit requirement: "the fix must not alter the
working behaviour of any adjacent auth/network path."
- Phase 2D exploration plan must identify both the failing path AND the
expected (working) path for comparison.
- Phase 4 checklist must include: "Does the fix reproduce the error in a
test harness before claiming it is resolved?"
### Pattern B — Refactor + New Module Introduction
Best for: extracting monolithic logic into a dedicated, testable module.
Key additions:
- Phase 2F Tree of Thoughts must include a "module boundary" decision.
- Design goals must include: "the module's public API is stable and versioned."
- Scope constraint must name exactly which existing files are being replaced
vs. which are being delegated to (the integration seam).
- A compliance sub-agent must verify the old entry point still works after
the refactor.
### Pattern C — Protocol / Network Implementation
Best for: Minecraft packet handling, connection management, session state.
Key additions:
- Sub-Agent B (researcher) must be directed to the Minecraft wiki and any
open-source reference clients (e.g., wiki.vg, Prismarine).
- Anti-hallucination anchor: "Never fabricate packet IDs, field types, or
VarInt boundaries — cross-check against the official protocol documentation."
- Phase 4 must include: "Are all packet field offsets and types verified
against the official protocol spec?"
### Pattern D — C# Language Modernisation
Best for: C# 14 features, record types, primary constructors, pattern matching.
Key additions:
- Sub-Agent C (style auditor) must check the existing use of record types in
the project before prescribing new ones.
- Design goals must specify which C# 14 features are required vs. optional.
- Anti-hallucination anchor: "Do not assume C# 14 features are available unless
the project's .csproj has been confirmed to target .NET 10 or a compatible
SDK."
- Phase 4 must include: "Does the code compile cleanly against the target
.NET version? Are there any C# 14 features used that require a language
version pragma?"
### Pattern E — Bot Scripting / Extension
Best for: new bot actions, scripting API extensions, event hooks.
Key additions:
- Sub-Agent A must locate the scripting API surface (CSharpRunner/ChatBot)
and any existing event dispatcher / hook registration code.
- Design goals must include: "the new API is backwards-compatible with
existing user scripts."
- Scope constraint must specify: "do not modify the scripting runtime loader
or the existing public API surface -- extend only."
### Pattern F -- Context Engineering / JIT Context Loading
Best for: tasks where the agent needs broad codebase awareness without context
overload, or tasks that span multiple subsystems.
Key additions:
- The prompt must include an `<agents_md>` block containing the AGENTS.md code
map so the agent has reliable structural orientation from the start.
- An `<available_skills>` block lists skills the agent can invoke for domain-
specific guidance (e.g., `mcc-chatbot-authoring`, `mcc-version-adaptation`).
- Sub-agents must return concise summaries, not raw file dumps -- protect the
main context from noise.
- Phase 2D exploration must use targeted searches (grep, semantic search) with
explicit stop conditions, not open-ended file reads.
- Context rot prevention: avoid stale cached assumptions; re-verify facts that
are older than the current execution context.
- For multi-step sessions: periodically summarise completed work to reclaim
context space. Emit incremental progress rather than accumulating full
history.
---
## Prompt Length Calibration
| Task complexity | Recommended prompt size |
|---|---|
| Single-file bug fix | ~4080 lines — short role, context, 3-phase reasoning, clear output |
| Module refactor | ~120200 lines — full ULTRATHINK, 4 sub-agents, ToT decisions |
| New protocol feature | ~150250 lines — full ULTRATHINK, external research mandate, wiki anchors |
| Architecture overhaul | ~200300 lines — full ULTRATHINK, 5+ sub-agents, compliance verifier |
Longer is not better. Every line in a prompt that does not add precision or
constraint is a line that dilutes the signal. Trim ruthlessly after drafting.
---
## Checklist: Signs of a Weak Prompt
- The role block is generic ("expert software engineer") rather than domain-specific.
- `<context>` omits the exact error message or failing state.
- Phase 2D exploration plan uses placeholders like "[auth directory]" instead
of real MCC paths.
- Sub-agents have open-ended missions ("research everything about X").
- No adversarial critique phase.
- Scope constraint says "don't touch unrelated code" without naming specific
files or directories.
- `<output_format>` does not specify the ordering or the accompanying artefacts.
- Plan mode directive is absent or appears after Phase 0.

View file

@ -1,383 +0,0 @@
# ULTRATHINK Reasoning Framework
# Reference file -- load into context when building the <reasoning_protocol> block
---
## Identity & Core Directive
You are an expert AI coding agent operating with maximum reasoning effort.
Your primary purpose is to help engineers build correct, maintainable,
production-ready software. You apply System 2 thinking at all times: slow,
methodical, and fully verifiable -- never impulsive.
You are equally capable of handling general-purpose (non-programming) tasks;
the same structured reasoning applies to any domain.
Non-negotiable quality standards:
- Correctness over speed.
- Explicit over implicit -- every reasoning step is visible and checkable.
- Verification over assumption -- validate before building on any result.
- Honesty about uncertainty -- never fabricate; flag knowledge gaps clearly.
---
## Reasoning Protocol (ULTRATHINK Mode)
Engage extended, deliberate reasoning for every non-trivial request.
Apply the full protocol below. For simple, unambiguous tasks you may compress
phases, but never skip verification.
---
### Phase 0 -- Orientation (always execute first)
Before doing anything else, ask yourself:
1. What type of request is this?
- New feature / implementation
- Bug investigation / fix
- Refactor / improvement
- Code review / audit
- Architecture / design decision
- General (non-programming) question
- Combination of the above
2. What is the confidence level on the requirements?
- High: requirements are unambiguous -> proceed to decomposition.
- Medium: some ambiguity -> note the ambiguities and resolve them (Phase 2C)
before coding.
- Low: requirements are underspecified -> ask targeted clarifying questions
before any other work.
3. Does this require codebase exploration?
- Yes -> plan and execute exploration (Phases 2D-2E) before implementation.
- No -> proceed directly to planning (Phase 2F).
---
### Phase 1 -- Query Analysis
Parse the request deeply. Surface all explicit and implicit requirements.
```
Step 1.1: Restate the goal in your own words (one concise sentence).
Step 1.2: List explicit requirements (stated directly).
Step 1.3: Identify implicit requirements (unstated but necessary for a correct solution).
Step 1.4: Identify constraints: language, framework, performance, compatibility, security, style.
Step 1.5: Identify success criteria -- how will you know the solution is correct and complete?
Step 1.6: Flag unknowns and ambiguities (mark each as [BLOCKING] or [NON-BLOCKING]).
```
Internal check before proceeding:
- [ ] Do I have enough information to decompose the problem without inventing
requirements?
- [ ] Are there [BLOCKING] unknowns that require clarification?
---
### Phase 2 -- Problem Decomposition
Break the problem into a set of coherent, independently verifiable sub-tasks.
For each sub-task identify:
- Input: what it depends on.
- Output: what it produces.
- Constraints: specific rules that apply.
- Success criterion: how correctness is verified.
Represent the decomposition as a checklist:
```markdown
## Implementation Plan
- [ ] Sub-task 1: [description] | Input: ... | Output: ... | Verify: ...
- [ ] Sub-task 2: [description] | Input: ... | Output: ... | Verify: ...
- [ ] Sub-task 3: Verification checkpoint -- [what is confirmed here]
```
Mark each item complete only after it is verified. Update the plan dynamically
if new information emerges.
---
### Phase 2C -- Clarification Requests (when needed)
Trigger this phase when [BLOCKING] unknowns exist.
- Ask targeted, specific questions -- one or two per turn, not a waterfall
of queries.
- For each question, state why it is blocking (what decision it gates).
- Offer your best-guess assumption alongside the question so the user can
confirm or correct, rather than starting from a blank slate.
- Do not begin implementation until [BLOCKING] unknowns are resolved.
Example format:
> **Clarification needed (blocking):**
> Q1: Should the authentication middleware run before or after rate limiting?
> This gates the ordering of middleware stacks.
> *My assumption:* authentication first, so unauthenticated requests are
> rejected before consuming rate-limit quota. Please confirm or correct.
---
### Phase 2D -- Codebase Exploration Planning (when needed)
Before exploring, write a minimal, scoped exploration plan. Over-exploration
fills context with noise and degrades reasoning quality.
```markdown
## Exploration Plan
Goal: [What specific information is needed to implement the solution?]
Files / directories to read:
1. [path/to/file] -- reason: [why this file is relevant]
2. [path/to/directory] -- reason: [what pattern/interface to discover]
Searches to run:
1. grep/search for: "[pattern]" -- reason: [what to confirm]
Stop condition: [what information, once found, means exploration is complete]
```
Scope investigations narrowly. If a search would require reading hundreds of
files, use sub-agents or targeted grep -- do not consume the main context with
unbounded exploration.
---
### Phase 2E -- Codebase Exploration Execution
Execute the plan from Phase 2D step by step.
After each tool call or file read:
1. Record the finding: "Step N observation: [what was found]."
2. Evaluate: "Does this change the implementation plan? Yes/No -- [reason]."
3. Update Phase 2's plan if needed.
4. Decide: continue exploration or stop (the stop condition from 2D is met).
Anti-pattern to avoid: reading files speculatively. Every file read must map
to an item in the exploration plan.
---
### Phase 2F -- Implementation / Execution Planning
Produce a concrete, ordered implementation plan before writing any code.
Apply Tree of Thoughts at every major architectural or design decision:
```
Decision: [The specific choice to be made]
Path A: [approach] -- Pros: ... | Cons: ... | Lookahead (2-3 steps): ...
Path B: [approach] -- Pros: ... | Cons: ... | Lookahead (2-3 steps): ...
Path C: [approach] -- Pros: ... | Cons: ... | Lookahead (2-3 steps): ...
Evaluation: [Rate each path: sure / maybe / impossible for reaching a valid solution]
Selected path: [X] -- Reason: [brief justification]
```
For design decisions with significant consequences (API contracts, data models,
security boundaries), generate 3-5 independent reasoning chains
(Self-Consistency) and verify they converge. Divergence means deeper analysis
is needed before proceeding.
The final implementation plan must be a concrete checklist (same format as
Phase 2) with each step specific enough that its completion can be objectively
verified.
---
### Phase 3 -- Implementation / Execution
Execute the plan from Phase 2F, one sub-task at a time.
For each step:
```
Step N: [action]
Reasoning: [why this step is correct given prior steps and constraints]
Code / output: [the actual work]
Verification: [test, lint, type-check, logical check -- confirm this step is correct before continuing]
```
Code quality standards (always enforced):
- Write code that a senior engineer would be proud to review.
- Follow existing conventions discovered during codebase exploration (naming,
formatting, patterns).
- Prefer the simplest solution that correctly satisfies all requirements --
avoid over-engineering.
- Never add unrequested abstractions, extra files, or "flexibility" not asked
for.
- All public APIs must include documentation comments.
- Security: never embed secrets, never trust unsanitised input, apply
least-privilege where applicable.
- Error paths are first-class citizens -- handle them explicitly.
- Every new unit of behaviour must be testable; prefer test-driven
implementation where practical.
Context hygiene:
- If context is growing large, summarise completed sub-tasks instead of
retaining full detail.
- Temporary files, scripts, or scratch work created during iteration must be
cleaned up at the end of the task.
ReAct loop for tool-augmented steps:
```
Thought: [what needs to happen next and why]
Action: [tool call / command]
Observation: [result of the action]
Reflection: [does the observation match expectations? adjust plan if not]
```
Repeat until the sub-task is complete and verified.
---
### Phase 4 -- Self-Validation
Execute this phase after every sub-task and again after the final output.
Pre-Output Verification Checklist:
- [ ] Backward verification: does the solution satisfy every requirement
identified in Phase 1?
- [ ] Logical consistency: are there internal contradictions in the code
or reasoning?
- [ ] Completeness: have all sub-tasks in the plan been completed and
marked off?
- [ ] Edge cases: does the solution handle boundary conditions, empty inputs,
and error states?
- [ ] Security: are there injection vectors, insecure defaults, or exposed
sensitive data?
- [ ] Performance: are there obvious algorithmic inefficiencies or unnecessary
blocking operations?
- [ ] Format compliance: does the output match the requested structure (file
names, code style, etc.)?
- [ ] Accuracy audit: are all factual claims, library APIs, and version
numbers verifiable?
- [ ] Test coverage: are there tests (or at minimum a manual verification
script) for the new behaviour?
If any item fails, return to the appropriate phase, fix the issue, and
re-verify before outputting.
Self-Critique Pass (mandatory):
Ask: "What is the most likely way this solution could be wrong or incomplete?"
If a plausible failure mode is identified, address it before delivering the
response.
---
## Multi-Path Exploration (Tree of Thoughts) -- Detailed Rules
Apply at every decision point where multiple approaches exist:
1. Generate 2-5 alternative paths -- do not evaluate on instinct alone.
2. For each path, ask: "Is this approach likely to reach a valid solution?"
- Sure: the path is logically sound and all constraints are satisfied.
- Maybe: the path could work but has unresolved risks or dependencies.
- Impossible: the path violates a constraint or leads to a dead end.
3. Use lookahead (2-3 steps forward) to detect dead ends early.
4. On contradiction or impossibility, backtrack to the last valid decision
point and explore an alternative branch.
5. Select the most logically sound path -- not the first instinct, not the
most familiar.
---
## Self-Consistency Verification -- Detailed Rules
For critical decisions or complex logic:
1. Generate 3-5 independent reasoning chains for the same sub-problem.
2. Compare outputs for consistency.
- Majority consensus -> high confidence, proceed.
- Divergent results -> identify the error source, regenerate affected
chains.
3. Select the answer that is most consistent across attempts -- not the most
confident-sounding one.
---
## Anti-Hallucination Protocol
- Never fabricate API signatures, library versions, framework behaviour, or
factual claims.
- When uncertain, say so explicitly: "I am not certain about [X]. My best
understanding is [Y], but you should verify this against the official
documentation."
- For factual claims, internally verify against known patterns. If
verification is impossible, mark the claim as [UNVERIFIED] in the response.
- Never invent file paths, function names, or environment variables that have
not been confirmed through exploration.
- Do not rationalise a plausible-sounding answer when you genuinely do not
know.
---
## Communication Standards
### For programming tasks
- Clearly separate planning output from code output using Markdown headings.
- Use fenced code blocks with correct language tags for all code.
- Include inline comments for non-obvious logic.
- When making changes to existing code, explain what changed and why --
not just what.
- If the solution has known limitations, state them explicitly rather than
hiding them.
### For general-purpose tasks
- Apply the same structured reasoning protocol: analyse -> decompose ->
plan -> execute -> verify.
- Adapt the phases to the domain (e.g., for writing tasks, "implementation"
is the draft; "verification" is a self-critique pass for logic,
completeness, and accuracy).
### Conciseness
- Output only what is necessary. Avoid padding, excessive hedging, and
repetition.
- Do not re-state the entire problem back to the user unless a concise
restatement aids clarity.
- Do not express enthusiasm or use filler phrases ("Great question!",
"Certainly!").
---
## Workflow Summary (Quick Reference)
```
Phase 0 -- Orientation Classify request type and confidence level.
Phase 1 -- Query Analysis Explicit + implicit requirements, constraints, success criteria.
Phase 2 -- Decomposition Sub-tasks with inputs, outputs, and verification criteria.
Phase 2C -- Clarification Ask targeted questions for [BLOCKING] unknowns only.
Phase 2D -- Exploration Plan Scoped, minimal plan for codebase discovery.
Phase 2E -- Exploration Execute ReAct loop over plan; stop at stop condition.
Phase 2F -- Impl. Plan Tree-of-Thoughts design decisions; concrete checklist.
Phase 3 -- Implementation Step-by-step with ReAct; code quality standards enforced.
Phase 4 -- Self-Validation Pre-output checklist + self-critique pass.
```
For simple, unambiguous tasks (e.g., a single-line bug fix with a clear
diagnosis), compress Phases 0-2F into a single brief reasoning block and
proceed to implementation. The checklist in Phase 4 always executes.
---
## Quality Principles (Non-Negotiable)
| Principle | Guideline |
|---|---|
| Precision over speed | Never rush a complex problem to appear responsive. |
| Explicit over implicit | Make all reasoning steps visible and checkable. |
| Verification over assumption | Validate each step before building on it. |
| Consistency over confidence | Prefer answers with convergent reasoning paths. |
| Simplicity over cleverness | The simplest correct solution beats an elegant wrong one. |
| Honesty about uncertainty | Flag low-confidence areas or knowledge gaps; never paper over them. |
| Planning before coding | A written plan, however brief, is always produced before implementation. |
| Context discipline | Keep exploration scoped; clean up temporary artefacts; summarise completed work. |

View file

@ -157,6 +157,43 @@ When packet changes are detected:
2. Create new `PacketPaletteXXX.cs` based on the previous one, adjusting IDs
3. Update `PacketType18Handler.cs` routing
Use scriptable comparisons instead of eyeballing long packet tables. The packet ID is the registration index in `GameProtocols.java`:
```bash
python3 - <<'PY'
import re
for ver in ["1.21.10", "1.21.11", "26.1"]:
path=f"MinecraftOfficial/{ver}-decompiled/net/minecraft/network/protocol/game/GameProtocols.java"
text=open(path).read()
start=text.index("CLIENTBOUND_TEMPLATE")
names=[m.group(1) for m in re.finditer(r"\.addPacket\(([^,]+),", text[start:])]
print("==", ver, len(names))
for i, name in enumerate(names):
print(f"0x{i:02X}", name)
PY
```
For focused diffs:
```bash
python3 - <<'PY'
import re
def packets(ver, marker):
text=open(f"MinecraftOfficial/{ver}-decompiled/net/minecraft/network/protocol/game/GameProtocols.java").read()
start=text.index(marker)
return [m.group(1) for m in re.finditer(r"\.addPacket\(([^,]+),", text[start:])]
left, right = "1.21.10", "1.21.11"
a, b = packets(left, "CLIENTBOUND_TEMPLATE"), packets(right, "CLIENTBOUND_TEMPLATE")
for i in range(max(len(a), len(b))):
x = a[i] if i < len(a) else "<none>"
y = b[i] if i < len(b) else "<none>"
if x != y:
print(f"0x{i:02X}: {left}={x} | {right}={y}")
PY
```
Do the same for `SERVERBOUND_TEMPLATE`. Clientbound and serverbound can change independently. Do not inherit a newer palette just because one side looks similar. For example, `1.21.11` used the same play packet order as `1.21.9/1.21.10` for the tested inventory path, while `26.1` had additional shifts.
## Step 5: Check Variant Encoding Changes
For entity types that use variant serializers (Cat, Wolf, Frog, Painting), check if the codec changed between versions by inspecting:

View file

@ -0,0 +1,211 @@
---
name: mermaid-diagrams
description: Creating and refining Mermaid diagrams with live reload. Use when users want flowcharts, sequence diagrams, class diagrams, ER diagrams, state diagrams, or any other Mermaid visualization. Provides best practices for syntax, styling, and the iterative workflow using mermaid_preview and mermaid_save tools.
allowed-tools: mcp__mermaid__mermaid_preview, mcp__mermaid__mermaid_save
---
# Mermaid Diagram Expert
You are an expert at creating, refining, and optimizing Mermaid diagrams using the MCP server tools.
## Core Workflow
1. **Create Initial Diagram**: Use `mermaid_preview` to render and open the diagram with live reload
2. **Iterative Refinement**: Make improvements - the browser will auto-refresh
3. **Save Final Version**: Use `mermaid_save` when satisfied
## Tool Usage
### mermaid_preview
Always use this when creating or updating diagrams:
- `diagram`: The Mermaid code
- `preview_id`: Descriptive kebab-case ID (e.g., `auth-flow`, `architecture`)
- `format`: Use `svg` for live reload (default)
- `theme`: `default`, `forest`, `dark`, or `neutral`
- `background`: `white`, `transparent`, or hex colors
- `width`, `height`, `scale`: Adjust for quality/size
**Key Points:**
- Reuse the same `preview_id` for refinements to update the same browser tab
- Use different IDs for multiple simultaneous diagrams
- Live reload only works with SVG format
### mermaid_save
Use after the diagram is finalized:
- `save_path`: Where to save (e.g., `./docs/diagram.svg`)
- `preview_id`: Must match the preview ID used earlier
- `format`: Must match format from preview
## Diagram Types
### Flowcharts (`graph` or `flowchart`)
Direction: `LR`, `TB`, `RL`, `BT`
```mermaid
graph LR
A[Start] --> B{Decision}
B -->|Yes| C[Action]
B -->|No| D[End]
style A fill:#e1f5ff
style C fill:#d4edda
```
### Sequence Diagrams (`sequenceDiagram`)
⚠️ **Do NOT use `style` statements** - not supported
```mermaid
sequenceDiagram
participant User
participant App
participant API
User->>App: Login
App->>API: Authenticate
API-->>App: Token
App-->>User: Success
```
### Class Diagrams (`classDiagram`)
```mermaid
classDiagram
class User {
+String name
+String email
+login()
}
class Order {
+int id
+Date created
}
User "1" --> "*" Order
```
### Entity Relationship (`erDiagram`)
```mermaid
erDiagram
USER ||--o{ ORDER : places
ORDER ||--|{ LINE_ITEM : contains
USER {
int id PK
string email
string name
}
```
### State Diagrams (`stateDiagram-v2`)
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Processing : start
Processing --> Complete : finish
Complete --> [*]
```
### Gantt Charts (`gantt`)
```mermaid
gantt
title Project Timeline
section Phase 1
Task 1 :a1, 2024-01-01, 30d
Task 2 :after a1, 20d
```
## Best Practices
### Preview IDs
- Use descriptive names: `architecture`, `auth-flow`, `data-model`
- Keep the same ID during refinements
- Use different IDs for concurrent diagrams
### Themes & Styling
- `default`: Clean, professional
- `forest`: Green tones
- `dark`: Dark background
- `neutral`: Grayscale
Use `transparent` background for docs, `white` for standalone
### Common Patterns
**System Architecture:**
```mermaid
graph TB
Client[Web App]
API[API Gateway]
DB[(Database)]
Client --> API --> DB
```
**Authentication Flow:**
```mermaid
sequenceDiagram
User->>App: Login Request
App->>Auth: Validate
Auth-->>App: JWT Token
App-->>User: Access Granted
```
## User Interaction
When a user requests a diagram:
1. **Clarify if needed**: What type? What level of detail?
2. **Choose diagram type**:
- Process/workflow → Flowchart
- System interactions → Sequence
- Code structure → Class
- Database → ER
- Timeline → Gantt
3. **Create with preview**: Use descriptive `preview_id`, start with good defaults
4. **Iterate**: Keep same `preview_id`, explain changes
5. **Save**: Ask where/what format, use `mermaid_save`
## Proactive Behavior
- Always preview diagrams, don't just generate code
- Use sensible defaults without asking
- Reuse preview_id for refinements
- Suggest improvements when you see opportunities
- Explain your diagram type choice briefly
## Common Issues
**Syntax errors**: Check quotes, arrow syntax, keywords
**Layout issues**: Try different directions (LR vs TB)
**Text overlap**: Increase dimensions or shorten labels
**Colors not working**: Verify CSS color format; remember sequence diagrams don't support styles
## Example Interaction
**User**: "Create an auth flow diagram"
**You**: "I'll create a sequence diagram showing the authentication flow."
[Use mermaid_preview with preview_id="auth-flow"]
**User**: "Add database and error handling"
**You**: "I'll add database interaction and error paths."
[Use mermaid_preview with same preview_id - browser auto-refreshes]
**User**: "Save it"
**You**: "Saving to ./docs/auth-flow.svg"
[Use mermaid_save]

View file

@ -8,16 +8,17 @@
## Build / Run
- Init submodules first: `git submodule update --init --recursive`
- Build: `dotnet build MinecraftClient.sln -c Release`
- Publish (matches CI shape): `dotnet publish MinecraftClient.sln -f net10.0 -r <RID> --self-contained=true -c Release -p:UseAppHost=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded`
- Run from source: `dotnet run --project MinecraftClient -- --help`
- Build for local development: `source tools/mcc-env.sh && mcc-build`
- Publish (matches CI shape): `source tools/mcc-env.sh && mcc-publish --rid <RID>`
- Run/debug from source: `source tools/mcc-env.sh && mcc-debug -v 1.21.11 --file-input`
- Docs: `cd docs && npm install && npm run docs:dev` or `npm run docs:build`
- Docker: `cd Docker && docker build -t minecraft-console-client:latest .`
- Tests: no dedicated test project is present in the main solution.
- Current state: the solution builds after submodule init, but `dotnet build` emits many analyzer and NuGet vulnerability warnings; treat them as real.
- Current state: the solution builds after submodule init, but the underlying .NET build emits many analyzer and NuGet vulnerability warnings; treat them as real.
- Server roots: `tools/` helpers look for server jars under `MinecraftOfficial/downloads/<version>/` by default, but also support an external root via the `MCC_SERVERS` environment variable.
- Multi-version testing: tmux-based local server sessions are shared state. Run cross-version test matrices sequentially unless you have explicit per-version isolation. A server logging `Done` does not guarantee immediate RCON availability; retry RCON setup commands.
- Automated test configs: for repeated or matrix test runs, prefer generating a temporary MCC config per run instead of reusing the repo-root `MinecraftClient.ini`, to avoid leaking state between runs.
- For agent-driven local development, prefer `mcc-build`, `mcc-publish`, `mcc-build-clean`, `mcc-debug`, `mcc-run`, and `mcc-tui` over raw `dotnet build`, `dotnet publish`, or `dotnet run`, so worktree-local temp build routing stays active.
## Architecture
- `Program` bootstraps console I/O, TOML config, auth/session state, MC version selection, Forge detection, then creates `McClient`.
@ -55,10 +56,11 @@ Feature columns mean:
| 1.21.5-1.21.8 | `Protocol18Handler` | Yes | Yes | Yes | 1.21.7/1.21.8 reuse 1.21.6 block/entity palettes in code |
| 1.21.9-1.21.10 | `Protocol18Handler` | Yes | Yes | Yes | Version tools prefer server data reports since 1.21.9 |
| 1.21.11 | `Protocol18Handler` | Yes | Yes | Yes | Own entity/item/metadata palettes; blocks reuse 1.21.9 palette |
| 26.1 | `Protocol18Handler` | Yes | Yes | Yes | Latest coded support; new Minecraft version naming scheme |
| 26.1 | `Protocol18Handler` | Yes | Yes | Yes | New Minecraft version naming scheme |
| 26.2 | `Protocol18Handler` | Yes | Yes | Yes | Latest coded support; own item/block/entity palettes, reuses 26.1 packet IDs and metadata serializers |
Notes:
- Declared code range is `1.4.6` to `26.1`.
- Declared code range is `1.4.6` to `26.2`.
- Human docs are stale in places and sometimes stop at older ranges; prefer code when docs and code disagree.
- Movement/pathing limits called out in docs still apply: no swimming, no knockback. The `Physics/` engine adds vanilla-accurate collision and movement but some edge cases remain.
@ -122,3 +124,4 @@ Read `docs/guide/ai-assisted-development.md` before starting development work on
- Don't trust older docs over current code for supported versions or feature gates. When AGENTS.md, skills, and older docs disagree, prefer current code and current tool behavior, then update the stale source.
- Don't hardcode user-facing strings (messages, labels, help text) directly in source code; always use `Translations.*` resources so the text can be localized.
- Never use "—" ("em dash"), unless specifically being instructed to do so!
- Never generate MCC config files (`.ini`) in the repo root. When `dotnet run --project MinecraftClient -- --help` is used to generate a config template, it writes `MinecraftClient.ini` to the current directory. Always run this command from a system temp directory (e.g. `mktemp -d`) or use `prepare_offline_mcc_config.sh` which already handles output routing.

@ -1 +1 @@
Subproject commit a9afc0df4ce79450b76acedffa1b549449cf69cb
Subproject commit ff6d2129e9f1e0fc6c7032741bdc42a2f0fa263e

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,773 @@
using System.Diagnostics;
using System.Text.Json;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
string endpoint = Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp";
bool useStdio = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_USE_STDIO"), "1", StringComparison.Ordinal);
string? mcpAuthToken = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
string repoRoot = FindRepoRoot();
string rconScript = Path.Combine(repoRoot, "tools", "mc-rcon.sh");
string rconPort = Environment.GetEnvironmentVariable("MCC_RCON_PORT") ?? "25575";
string rconPassword = Environment.GetEnvironmentVariable("MCC_RCON_PASSWORD") ?? "test123";
bool skipSetup = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_SKIP_SETUP"), "1", StringComparison.Ordinal);
bool runLocalSetup = !useStdio && !skipSetup && IsLocalEndpoint(endpoint) && File.Exists(rconScript);
var executed = new List<object>();
var checks = new List<string>();
try
{
await using McpClient client = useStdio
? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions()))
: await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.AutoDetect,
AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken)
? null
: new Dictionary<string, string> { ["Authorization"] = $"Bearer {mcpAuthToken}" }
}));
ToolEnvelope initialWorldState = await CallSuccessAsync(client, executed, "mcc_world_state");
JsonElement initialWorldData = RequireData(initialWorldState);
string botName = ReadString(initialWorldData, "username") ?? "MCCBot";
Coordinate initialLocation = ReadCoordinate(initialWorldData, "location");
long setupBaseline = 0;
if (runLocalSetup)
{
ToolEnvelope baselineEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = 0L,
["maxCount"] = 1
});
setupBaseline = ReadInt64(RequireData(baselineEvents), "latestId");
await PrepareWorldAsync(rconScript, rconPort, rconPassword, botName, initialLocation);
await Task.Delay(1500);
}
ToolEnvelope worldState = await WaitForPredicateAsync(
client,
executed,
"mcc_world_state",
null,
envelope =>
{
if (!envelope.Success || envelope.Data is not JsonElement data)
return false;
return data.TryGetProperty("loadedChunkCount", out JsonElement loaded)
&& loaded.TryGetInt32(out int loadedChunkCount)
&& loadedChunkCount >= 0
&& HasNonNullProperty(data, "worldAge")
&& HasNonNullProperty(data, "timeOfDay");
},
"mcc_world_state never reported chunk/time state.");
JsonElement worldData = RequireData(worldState);
Coordinate worldLocation = ReadCoordinate(worldData, "location");
string dimension = RequireString(worldData, "dimension");
int loadedChunkCount = ReadInt32(worldData, "loadedChunkCount");
int pendingChunkCount = ReadInt32(worldData, "pendingChunkCount");
int totalChunkCount = ReadInt32(worldData, "totalChunkCount");
double loadRatio = ReadDouble(worldData, "loadRatio");
_ = RequireString(worldData, "host");
_ = ReadInt32(worldData, "port");
_ = RequireString(worldData, "username");
_ = ReadInt32(worldData, "protocol");
_ = ReadDouble(worldData, "tps");
Ensure(!string.IsNullOrWhiteSpace(dimension), "mcc_world_state returned an empty dimension.");
Ensure(loadedChunkCount + pendingChunkCount == totalChunkCount, "mcc_world_state chunk counters are inconsistent.");
Ensure(loadRatio is >= 0 and <= 1, "mcc_world_state loadRatio is out of range.");
Ensure(HasNonNullProperty(worldData, "worldAge"), "mcc_world_state.worldAge is null.");
Ensure(HasNonNullProperty(worldData, "timeOfDay"), "mcc_world_state.timeOfDay is null.");
if (runLocalSetup || useStdio)
{
Ensure(HasNonNullProperty(worldData, "rainLevel"), "mcc_world_state.rainLevel is null after setup.");
Ensure(HasNonNullProperty(worldData, "thunderLevel"), "mcc_world_state.thunderLevel is null after setup.");
}
checks.Add("mcc_world_state");
ToolEnvelope chunkStatus = await CallSuccessAsync(client, executed, "mcc_chunk_status");
JsonElement chunkData = RequireData(chunkStatus);
JsonElement chunk = RequireProperty(chunkData, "chunk");
_ = ReadInt32(chunk, "x");
_ = ReadInt32(chunk, "z");
Ensure(ReadBoolean(chunkData, "loaded"), "mcc_chunk_status reported the current chunk as unloaded.");
Ensure(ReadInt32(chunkData, "loadedChunkCount") + ReadInt32(chunkData, "pendingChunkCount") == ReadInt32(chunkData, "totalChunkCount"),
"mcc_chunk_status chunk counters are inconsistent.");
checks.Add("mcc_chunk_status");
await CallSuccessAsync(client, executed, "mcc_look_direction", new Dictionary<string, object?> { ["direction"] = "Down" });
ToolEnvelope raycast = await CallSuccessAsync(client, executed, "mcc_raycast_block", new Dictionary<string, object?>
{
["maxDistance"] = 8.0,
["includeNeighbors"] = true
});
JsonElement raycastData = RequireData(raycast);
Ensure(ReadBoolean(raycastData, "hit"), "mcc_raycast_block did not report a hit after looking down.");
JsonElement raycastBlock = RequireProperty(raycastData, "block");
Ensure(!string.Equals(RequireString(raycastBlock, "material"), "Air", StringComparison.OrdinalIgnoreCase),
"mcc_raycast_block hit Air instead of a solid block.");
Ensure(RequireProperty(raycastData, "neighbors").ValueKind == JsonValueKind.Object,
"mcc_raycast_block did not include neighbors when requested.");
checks.Add("mcc_raycast_block");
ToolEnvelope pathPreview = await CallSuccessAsync(client, executed, "mcc_path_preview", new Dictionary<string, object?>
{
["x"] = Math.Floor(worldLocation.X) + 2,
["y"] = worldLocation.Y,
["z"] = Math.Floor(worldLocation.Z),
["allowUnsafe"] = false,
["timeoutMs"] = 2000,
["maxWaypoints"] = 32
});
JsonElement pathData = RequireData(pathPreview);
Ensure(ReadBoolean(pathData, "pathFound"), "mcc_path_preview did not find a path to a nearby target.");
Ensure(RequireProperty(pathData, "waypoints").GetArrayLength() > 0, "mcc_path_preview returned no waypoints.");
checks.Add("mcc_path_preview");
ToolEnvelope stoneSearch = await WaitForPredicateAsync(
client,
executed,
"mcc_inventory_search",
new Dictionary<string, object?>
{
["query"] = "Stone",
["maxCount"] = 20,
["exactMatch"] = true,
["includeContainers"] = false
},
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
"mcc_inventory_search never found Stone in the player inventory.");
Ensure(ContainsItemType(RequireData(stoneSearch), "Stone"), "mcc_inventory_search results did not include Stone.");
ToolEnvelope swordSearch = await WaitForPredicateAsync(
client,
executed,
"mcc_inventory_search",
new Dictionary<string, object?>
{
["query"] = "DiamondSword",
["maxCount"] = 20,
["exactMatch"] = true,
["includeContainers"] = false
},
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
"mcc_inventory_search never found DiamondSword in the player inventory.");
Ensure(ContainsItemType(RequireData(swordSearch), "DiamondSword"), "mcc_inventory_search results did not include DiamondSword.");
checks.Add("mcc_inventory_search");
ToolEnvelope selectItem = await CallSuccessAsync(client, executed, "mcc_select_item", new Dictionary<string, object?>
{
["itemType"] = "DiamondSword",
["preferLowestSlot"] = true
});
JsonElement selectData = RequireData(selectItem);
int selectedSlot = ReadInt32(selectData, "selectedSlot");
ToolEnvelope playerStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
JsonElement playerStatsData = RequireData(playerStats);
Ensure(ReadInt32(playerStatsData, "currentSlot") == selectedSlot, "mcc_select_item did not update mcc_player_stats.currentSlot.");
_ = ReadInt32(playerStatsData, "playerEntityId");
_ = ReadInt32(playerStatsData, "level");
_ = ReadInt32(playerStatsData, "totalExperience");
_ = ReadCoordinate(playerStatsData, "location");
checks.Add("mcc_select_item");
checks.Add("mcc_player_stats");
ToolEnvelope playersDetailed = await CallSuccessAsync(client, executed, "mcc_players_detailed", new Dictionary<string, object?>
{
["includeSelf"] = true,
["includeCoordinates"] = true
});
JsonElement playersData = RequireData(playersDetailed);
JsonElement selfPlayer = FindPlayer(RequireProperty(playersData, "players"), botName);
_ = RequireString(selfPlayer, "uuid");
_ = ReadInt32(selfPlayer, "ping");
_ = ReadInt32(selfPlayer, "entityId");
_ = ReadDouble(selfPlayer, "x");
_ = ReadDouble(selfPlayer, "y");
_ = ReadDouble(selfPlayer, "z");
checks.Add("mcc_players_detailed");
ToolEnvelope statusEffects = await CallSuccessAsync(client, executed, "mcc_status_effects");
Ensure(RequireProperty(RequireData(statusEffects), "effects").ValueKind == JsonValueKind.Array,
"mcc_status_effects.effects is not an array.");
checks.Add("mcc_status_effects");
ToolEnvelope animation = await CallSuccessAsync(client, executed, "mcc_animation", new Dictionary<string, object?>
{
["hand"] = "MainHand"
});
Ensure(ReadBoolean(RequireData(animation), "success"), "mcc_animation did not report success.");
ToolEnvelope sneakOn = await CallSuccessAsync(client, executed, "mcc_toggle_sneak", new Dictionary<string, object?> { ["enabled"] = true });
Ensure(ReadBoolean(RequireData(sneakOn), "enabled"), "mcc_toggle_sneak(true) did not report enabled=true.");
ToolEnvelope sprintOn = await CallSuccessAsync(client, executed, "mcc_toggle_sprint", new Dictionary<string, object?> { ["enabled"] = true });
Ensure(ReadBoolean(RequireData(sprintOn), "enabled"), "mcc_toggle_sprint(true) did not report enabled=true.");
await CallSuccessAsync(client, executed, "mcc_look_angles", new Dictionary<string, object?>
{
["yaw"] = 45.0f,
["pitch"] = -15.0f
});
ToolEnvelope updatedStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
JsonElement updatedStatsData = RequireData(updatedStats);
Ensure(Math.Abs(ReadDouble(updatedStatsData, "yaw") - 45.0) < 0.01, "mcc_look_angles did not update yaw.");
Ensure(Math.Abs(ReadDouble(updatedStatsData, "pitch") - (-15.0)) < 0.01, "mcc_look_angles did not update pitch.");
checks.Add("mcc_animation");
checks.Add("mcc_toggle_sneak");
checks.Add("mcc_toggle_sprint");
checks.Add("mcc_look_direction");
checks.Add("mcc_look_angles");
ToolEnvelope nearestEntity = await WaitForPredicateAsync(
client,
executed,
"mcc_entity_nearest",
new Dictionary<string, object?>
{
["typeFilter"] = "ArmorStand",
["radius"] = 16.0,
["includePlayers"] = false
},
envelope => envelope.Success,
"mcc_entity_nearest never found a nearby ArmorStand.");
JsonElement nearestData = RequireData(nearestEntity);
int entityId = ReadInt32(nearestData, "id");
Ensure(string.Equals(RequireString(nearestData, "type"), "ArmorStand", StringComparison.OrdinalIgnoreCase),
"mcc_entity_nearest did not return an ArmorStand.");
ToolEnvelope attackEntity = await CallSuccessAsync(client, executed, "mcc_entity_attack", new Dictionary<string, object?>
{
["entityId"] = entityId
});
Ensure(ReadBoolean(RequireData(attackEntity), "success"), "mcc_entity_attack did not report success.");
checks.Add("mcc_entity_nearest");
checks.Add("mcc_entity_attack");
long recentSetupAfterId = runLocalSetup ? setupBaseline : 0;
if (runLocalSetup || useStdio)
{
ToolEnvelope setupEvents = await WaitForRecentEventTypesAsync(
client,
executed,
recentSetupAfterId,
"weather_rain",
"title",
"actionbar");
JsonElement setupEventsData = RequireData(setupEvents);
Ensure(GetEventTypes(setupEventsData).Contains("weather_rain", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include weather_rain.");
Ensure(GetEventTypes(setupEventsData).Contains("title", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include title.");
Ensure(GetEventTypes(setupEventsData).Contains("actionbar", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include actionbar.");
}
ToolEnvelope actionbarEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = 0L,
["maxCount"] = 20,
["typeFilter"] = "actionbar"
});
JsonElement actionbarData = RequireData(actionbarEvents);
Ensure(ReadInt32(actionbarData, "count") > 0, "mcc_recent_events typeFilter=actionbar returned no events.");
Ensure(AllEventsMatchType(actionbarData, "actionbar"), "mcc_recent_events typeFilter returned mixed event types.");
long inventoryBaseline = ReadInt64(actionbarData, "latestId");
int chestX = (int)Math.Floor(worldLocation.X) + 2;
int chestY = (int)Math.Floor(worldLocation.Y);
int chestZ = (int)Math.Floor(worldLocation.Z);
ToolEnvelope openContainer = await WaitForPredicateAsync(
client,
executed,
"mcc_container_open_at",
new Dictionary<string, object?>
{
["x"] = chestX,
["y"] = chestY,
["z"] = chestZ,
["timeoutMs"] = 3000,
["closeCurrent"] = true
},
envelope => envelope.Success,
"mcc_open_container_at never opened the nearby chest.");
JsonElement inventoryInfo = RequireProperty(RequireData(openContainer), "inventory");
int openedInventoryId = ReadInt32(inventoryInfo, "id");
ToolEnvelope closeContainer = await CallSuccessAsync(client, executed, "mcc_container_close", new Dictionary<string, object?>
{
["inventoryId"] = openedInventoryId,
["timeoutMs"] = 3000
});
Ensure(ReadBoolean(RequireData(closeContainer), "closed"), "mcc_close_container did not close the chest.");
ToolEnvelope inventoryEvents = await WaitForRecentEventTypesAsync(
client,
executed,
inventoryBaseline,
"inventory_open",
"inventory_close");
JsonElement inventoryEventsData = RequireData(inventoryEvents);
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_open", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_open.");
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_close", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_close.");
checks.Add("mcc_recent_events");
if (runLocalSetup)
{
long deathBaseline = ReadInt64(inventoryEventsData, "latestId");
await RunRconCommandAsync(rconScript, rconPort, rconPassword, $"kill {botName}");
ToolEnvelope deathEvents = await WaitForRecentEventTypesAsync(client, executed, deathBaseline, "death");
Ensure(GetEventTypes(RequireData(deathEvents)).Contains("death", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported death after the RCON kill.");
long respawnBaseline = ReadInt64(RequireData(deathEvents), "latestId");
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported respawn after mcc_respawn.");
checks.Add("mcc_respawn");
}
else
{
long respawnBaseline = ReadInt64(RequireData(inventoryEvents), "latestId");
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported respawn after mcc_respawn.");
checks.Add("mcc_respawn");
}
ToolEnvelope loadedBots = await CallSuccessAsync(client, executed, "mcc_loaded_bots");
JsonElement bots = RequireProperty(RequireData(loadedBots), "bots");
Ensure(ContainsBot(bots, "McpServer"), "mcc_loaded_bots did not include McpServer.");
checks.Add("mcc_loaded_bots");
ToolEnvelope disconnect = await CallSuccessAsync(client, executed, "mcc_disconnect");
Ensure(ReadBoolean(RequireData(disconnect), "disconnecting"), "mcc_disconnect did not report disconnecting=true.");
checks.Add("mcc_disconnect");
if (!useStdio)
{
await AssertDisconnectStopsEndpointAsync(client, executed);
}
Console.WriteLine(JsonSerializer.Serialize(new
{
success = true,
endpoint,
useStdio,
runLocalSetup,
checks,
executed
}, new JsonSerializerOptions { WriteIndented = true }));
Environment.ExitCode = 0;
}
catch (Exception ex)
{
Console.WriteLine(JsonSerializer.Serialize(new
{
success = false,
endpoint,
useStdio,
runLocalSetup,
error = ex.Message,
checks,
executed
}, new JsonSerializerOptions { WriteIndented = true }));
Environment.ExitCode = 1;
}
static async Task PrepareWorldAsync(string rconScript, string rconPort, string rconPassword, string botName, Coordinate location)
{
string[] commands =
[
$"op {botName}",
$"gamemode creative {botName}",
$"tp {botName} 0 80 0",
$"item replace entity {botName} hotbar.0 with minecraft:stone 32",
$"item replace entity {botName} hotbar.1 with minecraft:diamond_sword 1",
$"execute as {botName} at @s run setblock ~2 ~ ~ minecraft:chest",
$"execute as {botName} at @s run summon minecraft:armor_stand ~2 ~ ~1",
"weather clear",
"weather rain",
$"title {botName} title {{\"text\":\"mcp_title\"}}",
$"title {botName} actionbar {{\"text\":\"mcp_actionbar\"}}"
];
foreach (string command in commands)
{
await RunRconCommandAsync(rconScript, rconPort, rconPassword, command);
}
}
static async Task RunRconCommandAsync(string rconScript, string rconPort, string rconPassword, string command)
{
ProcessStartInfo startInfo = new("bash")
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
startInfo.ArgumentList.Add(rconScript);
startInfo.ArgumentList.Add(command);
startInfo.ArgumentList.Add(rconPort);
startInfo.ArgumentList.Add(rconPassword);
using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start mc-rcon.sh.");
string stdout = await process.StandardOutput.ReadToEndAsync();
string stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"RCON command failed ({command}): {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}");
}
}
static async Task<ToolEnvelope> CallSuccessAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args = null)
{
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
if (!envelope.Success)
{
throw new InvalidOperationException(
$"{toolName} failed with errorCode={envelope.ErrorCode ?? "<null>"} message={envelope.Message ?? "<null>"}.");
}
return envelope;
}
static async Task<ToolEnvelope> WaitForPredicateAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args,
Func<ToolEnvelope, bool> predicate,
string failureMessage,
int maxAttempts = 12,
int delayMs = 400)
{
ToolEnvelope? lastEnvelope = null;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
lastEnvelope = envelope;
if (predicate(envelope))
return envelope;
await Task.Delay(delayMs);
}
throw new InvalidOperationException(
$"{failureMessage} Last result: success={lastEnvelope?.Success}, errorCode={lastEnvelope?.ErrorCode ?? "<null>"}.");
}
static async Task<ToolEnvelope> WaitForRecentEventTypesAsync(
McpClient client,
List<object> executed,
long afterId,
params string[] expectedTypes)
{
HashSet<string> expected = expectedTypes.ToHashSet(StringComparer.OrdinalIgnoreCase);
ToolEnvelope? lastEnvelope = null;
for (int attempt = 0; attempt < 12; attempt++)
{
ToolEnvelope envelope = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = afterId,
["maxCount"] = 100
});
lastEnvelope = envelope;
JsonElement data = RequireData(envelope);
HashSet<string> actual = GetEventTypes(data);
if (expected.All(actual.Contains))
return envelope;
await Task.Delay(400);
}
throw new InvalidOperationException(
$"mcc_recent_events never reported: {string.Join(", ", expectedTypes)} after event id {afterId}. Last latestId={ReadInt64(RequireData(lastEnvelope!), "latestId")}.");
}
static async Task<ToolEnvelope> CallToolAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args = null)
{
CallToolResult result = await client.CallToolAsync(toolName, args);
string responseJson = ExtractResponseJson(result);
JsonElement root = JsonDocument.Parse(responseJson).RootElement.Clone();
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement) ? dataElement.Clone() : null;
bool success = root.TryGetProperty("success", out JsonElement successElement)
&& successElement.ValueKind == JsonValueKind.True;
string? errorCode = ReadString(root, "errorCode");
string? message = ReadString(root, "message");
executed.Add(new
{
tool = toolName,
arguments = args,
isError = result.IsError,
success,
errorCode,
message,
response = root
});
return new ToolEnvelope(toolName, result.IsError ?? false, success, errorCode, message, root, data);
}
static async Task AssertDisconnectStopsEndpointAsync(McpClient client, List<object> executed)
{
for (int attempt = 0; attempt < 15; attempt++)
{
try
{
await CallToolAsync(client, executed, "mcc_world_state");
}
catch
{
return;
}
await Task.Delay(300);
}
throw new InvalidOperationException("The MCP endpoint still responded after mcc_disconnect.");
}
static string ExtractResponseJson(CallToolResult result)
{
if (result.Content is null)
throw new InvalidOperationException("Tool response did not contain any content blocks.");
foreach (ContentBlock content in result.Content)
{
if (content is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
return text.Text;
}
throw new InvalidOperationException("Tool response did not contain a text payload.");
}
static JsonElement RequireData(ToolEnvelope envelope)
{
if (envelope.Data is JsonElement data)
return data;
throw new InvalidOperationException($"{envelope.ToolName} returned no data payload.");
}
static JsonElement RequireProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out JsonElement property))
return property;
throw new InvalidOperationException($"Missing required property '{propertyName}'.");
}
static string RequireString(JsonElement element, string propertyName)
{
string? value = ReadString(element, propertyName);
if (!string.IsNullOrWhiteSpace(value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is missing or empty.");
}
static string? ReadString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
static int ReadInt32(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetInt32(out int value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not an Int32.");
}
static long ReadInt64(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetInt64(out long value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not an Int64.");
}
static double ReadDouble(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetDouble(out double value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not a Double.");
}
static bool ReadBoolean(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => throw new InvalidOperationException($"Property '{propertyName}' is not a Boolean.")
};
}
static bool HasNonNullProperty(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind != JsonValueKind.Null;
}
static Coordinate ReadCoordinate(JsonElement element, string propertyName)
{
JsonElement coordinate = RequireProperty(element, propertyName);
return new Coordinate(
ReadDouble(coordinate, "x"),
ReadDouble(coordinate, "y"),
ReadDouble(coordinate, "z"));
}
static JsonElement FindPlayer(JsonElement players, string playerName)
{
foreach (JsonElement player in players.EnumerateArray())
{
string? name = ReadString(player, "name");
if (string.Equals(name, playerName, StringComparison.OrdinalIgnoreCase))
return player;
}
throw new InvalidOperationException($"Could not find player '{playerName}' in mcc_players_detailed.");
}
static bool ContainsItemType(JsonElement searchData, string itemType)
{
JsonElement matches = RequireProperty(searchData, "matches");
foreach (JsonElement match in matches.EnumerateArray())
{
if (string.Equals(ReadString(match, "itemType"), itemType, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
static bool ContainsBot(JsonElement bots, string botName)
{
foreach (JsonElement bot in bots.EnumerateArray())
{
if (string.Equals(ReadString(bot, "name"), botName, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
static HashSet<string> GetEventTypes(JsonElement recentEventsData)
{
JsonElement events = RequireProperty(recentEventsData, "events");
return events.EnumerateArray()
.Select(entry => RequireString(entry, "type"))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
static bool AllEventsMatchType(JsonElement recentEventsData, string type)
{
JsonElement events = RequireProperty(recentEventsData, "events");
foreach (JsonElement entry in events.EnumerateArray())
{
if (!string.Equals(RequireString(entry, "type"), type, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
static void Ensure(bool condition, string message)
{
if (!condition)
throw new InvalidOperationException(message);
}
static bool IsLocalEndpoint(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
return false;
return string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Host, "::1", StringComparison.OrdinalIgnoreCase);
}
static string FindRepoRoot()
{
string current = Directory.GetCurrentDirectory();
DirectoryInfo? directory = new(current);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "MinecraftClient.sln")))
return directory.FullName;
directory = directory.Parent;
}
return current;
}
static StdioClientTransportOptions CreateStdioOptions()
{
string? stdioBin = Environment.GetEnvironmentVariable("MCC_MCP_STDIO_BIN");
if (!string.IsNullOrWhiteSpace(stdioBin))
{
return new StdioClientTransportOptions
{
Name = "MCC MCP Stdio Harness",
Command = stdioBin,
Arguments = [],
ShutdownTimeout = TimeSpan.FromSeconds(5)
};
}
return new StdioClientTransportOptions
{
Name = "MCC MCP Stdio Harness",
Command = "dotnet",
Arguments =
[
"run",
"--project",
"DebugTools/MccMcpStdioHarness",
"-c",
"Release",
"--no-build"
],
ShutdownTimeout = TimeSpan.FromSeconds(5)
};
}
internal readonly record struct Coordinate(double X, double Y, double Z);
internal sealed record ToolEnvelope(
string ToolName,
bool IsError,
bool Success,
string? ErrorCode,
string? Message,
JsonElement Root,
JsonElement? Data);

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\MinecraftClient\MinecraftClient.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,984 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MinecraftClient.Mcp;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services.AddSingleton(new MccMcpConfig());
builder.Services.AddSingleton<IMccMcpCapabilities, DeterministicCapabilities>();
builder.Services.AddSingleton<MccMcpGuidanceProvider>();
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithTools<MccMcpToolSet>()
.WithPrompts<MccMcpPromptSet>();
await builder.Build().RunAsync();
internal sealed class DeterministicCapabilities : IMccMcpCapabilities
{
private static double C(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
private readonly List<RecentEvent> recentEvents = [];
private long nextEventId = 1;
private double playerX = C(0.5);
private double playerY = C(80.0);
private double playerZ = C(0.5);
private float yaw;
private float pitch;
private int currentSlot = 1;
private bool sneaking;
private bool sprinting;
private float health = 20.0f;
private bool disconnecting;
public DeterministicCapabilities()
{
AddRecentEvent("player_join", new { name = "HarnessBot" });
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest" });
AddRecentEvent("weather_rain", new { level = 1.0 });
AddRecentEvent("title", new { text = "mcp_title" });
AddRecentEvent("actionbar", new { text = "mcp_actionbar" });
}
public MccMcpResult GetSessionStatus() =>
MccMcpResult.Ok(new
{
connected = !disconnecting,
host = "deterministic.local",
port = 25565,
username = "HarnessBot",
location = new { x = playerX, y = playerY, z = playerZ }
});
public MccMcpResult GetServerInfo() =>
MccMcpResult.Ok(new
{
host = "deterministic.local",
port = 25565,
tps = 20.0
});
public MccMcpResult GetPlayerState() =>
MccMcpResult.Ok(new
{
nickname = "HarnessBot",
username = "HarnessBot",
health,
saturation = 20,
gamemode = 1,
currentSlot,
yaw,
pitch,
location = new { x = playerX, y = playerY, z = playerZ },
effects = new object[0]
});
public MccMcpResult GetWorldState() =>
MccMcpResult.Ok(new
{
connected = !disconnecting,
host = "deterministic.local",
port = 25565,
username = "HarnessBot",
protocol = 769,
terrainEnabled = true,
inventoryEnabled = true,
entityHandlingEnabled = true,
location = new { x = playerX, y = playerY, z = playerZ },
tps = 20.0,
dimension = "minecraft:overworld",
loadedChunkCount = 9,
pendingChunkCount = 0,
totalChunkCount = 9,
loadRatio = 1.0,
worldAge = 12000L,
timeOfDay = 6000L,
rainLevel = 1.0,
thunderLevel = 0.0
});
public MccMcpResult GetChunkStatus(double? x, double? y, double? z)
{
double resolvedX = x ?? playerX;
double resolvedY = y ?? playerY;
double resolvedZ = z ?? playerZ;
int chunkX = (int)Math.Floor(resolvedX) >> 4;
int chunkZ = (int)Math.Floor(resolvedZ) >> 4;
return MccMcpResult.Ok(new
{
location = new { x = C(resolvedX), y = C(resolvedY), z = C(resolvedZ) },
chunk = new { x = chunkX, z = chunkZ },
loaded = true,
fullyLoaded = true,
loadedChunkCount = 9,
pendingChunkCount = 0,
totalChunkCount = 9,
loadRatio = 1.0
});
}
public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors)
{
object? neighbors = includeNeighbors
? new
{
north = new { x = 0, y = 79, z = -1, material = "Air", typeLabel = "Air" },
south = new { x = 0, y = 79, z = 1, material = "Air", typeLabel = "Air" },
east = new { x = 1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
west = new { x = -1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
above = new { x = 0, y = 80, z = 0, material = "Air", typeLabel = "Air" },
below = new { x = 0, y = 78, z = 0, material = "Stone", typeLabel = "Stone" }
}
: null;
return MccMcpResult.Ok(new
{
hit = true,
maxDistance,
playerLocation = new { x = playerX, y = playerY, z = playerZ },
eyeLocation = new { x = playerX, y = C(playerY + 1.62), z = playerZ },
location = new { x = 0, y = 79, z = 0 },
block = new { material = "Stone", typeLabel = "Stone", blockId = 1, blockMeta = 0 },
distance = 1.12,
eyeDistance = 2.03,
neighbors
});
}
public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints)
{
object[] waypoints =
[
new { x = playerX, y = playerY, z = playerZ },
new { x = C((playerX + x) / 2), y = C((playerY + y) / 2), z = C((playerZ + z) / 2) },
new { x = C(x), y = C(y), z = C(z) }
];
return MccMcpResult.Ok(new
{
pathFound = true,
exactReachable = true,
target = new { x = C(x), y = C(y), z = C(z) },
startLocation = new { x = playerX, y = playerY, z = playerZ },
finalWaypoint = new { x = C(x), y = C(y), z = C(z) },
finalDistance = 0.0,
waypointCount = waypoints.Length,
truncated = waypoints.Length > Math.Max(1, maxWaypoints),
waypoints = waypoints.Take(Math.Max(1, maxWaypoints)).ToArray(),
allowUnsafe,
maxOffset,
minOffset,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
}
public MccMcpResult GetPlayersList() =>
MccMcpResult.Ok(new
{
players = new[] { "HarnessBot", "PlayerOne" }
});
public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates)
{
List<object> players = [];
if (includeSelf)
{
players.Add(new
{
name = "HarnessBot",
uuid = Guid.Parse("11111111-1111-1111-1111-111111111111"),
ping = 5,
gamemode = 1,
listed = true,
displayName = "HarnessBot",
entityId = 1,
x = includeCoordinates ? playerX : (double?)null,
y = includeCoordinates ? playerY : (double?)null,
z = includeCoordinates ? playerZ : (double?)null
});
}
players.Add(new
{
name = "PlayerOne",
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
ping = 12,
gamemode = 1,
listed = true,
displayName = "PlayerOne",
entityId = 2,
x = includeCoordinates ? C(3.5) : (double?)null,
y = includeCoordinates ? C(80.0) : (double?)null,
z = includeCoordinates ? C(0.5) : (double?)null
});
return MccMcpResult.Ok(new
{
count = players.Count,
players = players.ToArray()
});
}
public MccMcpResult GetPlayerStats() =>
MccMcpResult.Ok(new
{
health,
saturation = 20,
level = 12,
totalExperience = 245,
gamemode = 1,
playerEntityId = 1,
currentSlot,
yaw,
pitch,
sneaking,
sprinting,
location = new { x = playerX, y = playerY, z = playerZ },
tps = 20.0
});
public MccMcpResult GetStatusEffects() =>
MccMcpResult.Ok(new
{
count = 0,
effects = Array.Empty<object>()
});
public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter)
{
RecentEvent[] events = recentEvents
.Where(e => e.Id > afterId)
.Where(e => string.IsNullOrWhiteSpace(typeFilter) || string.Equals(e.Type, typeFilter, StringComparison.OrdinalIgnoreCase))
.Take(Math.Max(1, maxCount))
.ToArray();
return MccMcpResult.Ok(new
{
afterId,
latestId = recentEvents.Count > 0 ? recentEvents[^1].Id : 0,
count = events.Length,
events = events.Select(e => new
{
id = e.Id,
timestampUtc = e.TimestampUtc,
type = e.Type,
data = e.Data
}).ToArray()
});
}
public MccMcpResult GetLoadedBots() =>
MccMcpResult.Ok(new
{
count = 2,
bots = new object[]
{
new { name = "McpServer", fullTypeName = "MinecraftClient.ChatBots.McpServer", isScript = false },
new { name = "HarnessScript", fullTypeName = "MinecraftClient.ChatBots.Script", isScript = true }
}
});
public MccMcpResult GetChatHistory(int maxCount, bool includeJson) =>
MccMcpResult.Ok(new
{
count = 2,
entries = new object[]
{
new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-10), kind = "chat", text = "<PlayerOne> hello", sender = "PlayerOne", message = "hello", json = includeJson ? "{}" : null },
new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-5), kind = "system", text = "HarnessBot joined the game", sender = (string?)null, message = (string?)null, json = includeJson ? "{}" : null }
}
});
public MccMcpResult GetInternalCommands() =>
MccMcpResult.Ok(new
{
count = 4,
commands = new[]
{
new { name = "debug", usage = "debug [on|off|state]", description = "Toggle debug or print state." },
new { name = "move", usage = "move <x> <y> <z>", description = "Move to location." },
new { name = "useitem", usage = "useitem [x] [y] [z]", description = "Use current held item." },
new { name = "dig", usage = "dig <x> <y> <z> [duration]", description = "Dig block at location." }
}
});
public MccMcpResult GetMaterialsList(string? filter, int maxCount) =>
MccMcpResult.Ok(new
{
total = 3,
count = 3,
filter,
materials = new[]
{
new { name = "Air", typeLabel = "Air" },
new { name = "GrassBlock", typeLabel = "Grass Block" },
new { name = "OakLog", typeLabel = "Oak Log" }
}
});
public MccMcpResult GetBlockTypesList(string? filter, int maxCount) =>
MccMcpResult.Ok(new
{
total = 3,
count = 3,
filter,
blockTypes = new[]
{
new { name = "Air", typeLabel = "Air" },
new { name = "GrassBlock", typeLabel = "Grass Block" },
new { name = "OakLog", typeLabel = "Oak Log" }
}
});
public MccMcpResult GetEntityTypesList(string? filter, int maxCount) =>
MccMcpResult.Ok(new
{
total = 3,
count = 3,
filter,
entityTypes = new[]
{
new { name = "Player", typeLabel = "Player" },
new { name = "Item", typeLabel = "Item" },
new { name = "Villager", typeLabel = "Villager" }
}
});
public MccMcpResult SendChat(string text) =>
MccMcpResult.Ok(new { echoed = text });
public MccMcpResult QuitClient() =>
MccMcpResult.Ok(new { quitting = true });
public MccMcpResult DisconnectClient()
{
disconnecting = true;
AddRecentEvent("disconnect", new { reason = "requested", message = "Disconnect requested by test client." });
return MccMcpResult.Ok(new { disconnecting = true });
}
public MccMcpResult RunInternalCommand(string command) =>
MccMcpResult.Ok(new { command, status = "Done", output = "deterministic" });
public MccMcpResult UseItemOnHand() =>
MccMcpResult.Ok(new { success = true, action = "use_item_on_hand" });
public MccMcpResult ChangeHotbarSlot(int slot)
{
currentSlot = slot;
return MccMcpResult.Ok(new { success = true, slot });
}
public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot)
{
currentSlot = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 2 : 1;
return MccMcpResult.Ok(new
{
success = true,
itemType,
inventorySlot = currentSlot - 1,
selectedSlot = currentSlot,
count = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 1 : 32,
preferLowestSlot
});
}
public MccMcpResult UseItemOnBlock(double x, double y, double z) =>
MccMcpResult.Ok(new { success = true, x = C(x), y = C(y), z = C(z), action = "useitem" });
public MccMcpResult DigBlock(double x, double y, double z, double durationSeconds) =>
MccMcpResult.Ok(new
{
success = true,
target = new { x = C(x), y = C(y), z = C(z) },
beforeBlock = new { material = "OakLog", typeLabel = "Oak Log", blockId = 137, blockMeta = 0 },
afterBlock = new { material = "Air", typeLabel = "Air", blockId = 0, blockMeta = 0 },
commandAccepted = true,
changed = true,
destroyed = true,
attempts = 1,
attemptedDurationsSeconds = new[] { durationSeconds > 0 ? durationSeconds : 1.5 },
distance = 1.5,
playerLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }
});
public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) =>
MccMcpResult.Ok(new { success = true, x, y, z, face, hand, lookAtBlock, action = "place_block" });
public MccMcpResult InteractEntity(int entityId, string interaction, string hand) =>
MccMcpResult.Ok(new { success = true, entityId, interaction, hand });
public MccMcpResult AttackEntity(int entityId) =>
MccMcpResult.Ok(new { success = true, entityId, interaction = "Attack" });
public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers)
{
bool wantsArmorStand = string.IsNullOrWhiteSpace(typeFilter)
|| string.Equals(typeFilter, "ArmorStand", StringComparison.OrdinalIgnoreCase)
|| string.Equals(typeFilter, "Armor Stand", StringComparison.OrdinalIgnoreCase);
if (wantsArmorStand && radius >= 4.0)
{
return MccMcpResult.Ok(new
{
id = 7,
type = "ArmorStand",
typeLabel = "Armor Stand",
uuid = Guid.Parse("33333333-3333-3333-3333-333333333333"),
name = "Armor Stand",
customName = (string?)null,
x = C(2.5),
y = C(80.0),
z = C(0.5),
distance = 2.0,
health = 20.0f,
pose = "Standing",
latency = 0
});
}
if (includePlayers && radius >= 3.0)
{
return MccMcpResult.Ok(new
{
id = 2,
type = "Player",
typeLabel = "Player",
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
name = string.IsNullOrWhiteSpace(nameFilter) ? "PlayerOne" : nameFilter,
customName = (string?)null,
x = C(3.5),
y = C(80.0),
z = C(0.5),
distance = 3.0,
health = 20.0f,
pose = "Standing",
latency = 12
});
}
return MccMcpResult.Fail("invalid_state", data: new { typeFilter, nameFilter, radius, includePlayers });
}
public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) =>
MccMcpResult.Ok(new
{
center = new { x = 0, y = 79, z = 0 },
radius,
count = 1,
blocks = new[]
{
new { x = 0, y = 79, z = 0, material = materialFilter ?? "GrassBlock", blockId = 9, blockMeta = 0, distance = 0.0 }
}
});
public MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch) =>
MccMcpResult.Ok(new
{
center = new { x = 0, y = 79, z = 0 },
radius,
query,
exactMatch,
count = 2,
blocks = new object[]
{
new { x = 1, y = 79, z = 0, material = "GrassBlock", typeLabel = "Grass Block", blockId = 9, blockMeta = 0, distance = 1.0 },
new { x = 2, y = 79, z = 0, material = "Dirt", typeLabel = "Dirt", blockId = 10, blockMeta = 0, distance = 2.0 }
}
});
public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) =>
MccMcpResult.Ok(new
{
radius,
playerName,
includeSelf,
anyNearby = true,
count = 1,
players = new object[]
{
new
{
entityId = 1,
uuid = Guid.Empty,
name = "PlayerOne",
customName = (string?)null,
x = C(3.5),
y = C(80.0),
z = C(0.5),
distance = 3.0,
latency = 5
}
}
});
public MccMcpResult LocatePlayer(string playerName, bool includeSelf) =>
MccMcpResult.Ok(new
{
playerName,
matchedName = "PlayerOne",
entityId = 1,
uuid = Guid.Empty,
x = C(3.5),
y = C(80.0),
z = C(0.5),
distance = 3.0
});
public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs) =>
MccMcpResult.Ok(new
{
reachable = true,
exactReachable = true,
target = new { x = C(x), y = C(y), z = C(z) },
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalWaypoint = new { x = C(x), y = C(y), z = C(z) },
finalDistance = 0.0,
waypointCount = 4,
allowUnsafe,
maxOffset,
minOffset,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) =>
MccMcpResult.Ok(new
{
pathFound = true,
arrived = true,
tolerance = 1.5,
verifyWaitMs = 250,
target = new { x = C(x), y = C(y), z = C(z) },
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalLocation = new { x = C(x), y = C(y), z = C(z) },
finalDistance = 0.0,
distanceMoved = 3.0,
allowUnsafe,
allowDirectTeleport,
maxOffset,
minOffset,
timeoutMs
});
public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) =>
MccMcpResult.Ok(new
{
pathFound = true,
arrived = true,
tolerance = 1.5,
verifyWaitMs = 250,
target = new
{
playerName = "PlayerOne",
entityId = 1,
x = C(3.5),
y = C(80.0),
z = C(0.5)
},
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalLocation = new { x = C(3.5), y = C(80.0), z = C(0.5) },
finalDistance = 0.0,
distanceMoved = 3.0,
allowUnsafe,
allowDirectTeleport,
maxOffset,
minOffset,
timeoutMs
});
public MccMcpResult LookAt(double x, double y, double z) =>
MccMcpResult.Ok(new { looked = true, x = C(x), y = C(y), z = C(z) });
public MccMcpResult LookDirection(string direction)
{
switch (direction.Trim().ToLowerInvariant())
{
case "up":
yaw = 0.0f;
pitch = -90.0f;
break;
case "down":
yaw = 0.0f;
pitch = 90.0f;
break;
case "north":
yaw = 180.0f;
pitch = 0.0f;
break;
case "south":
yaw = 0.0f;
pitch = 0.0f;
break;
case "east":
yaw = -90.0f;
pitch = 0.0f;
break;
case "west":
yaw = 90.0f;
pitch = 0.0f;
break;
}
return MccMcpResult.Ok(new { success = true, direction, yaw, pitch });
}
public MccMcpResult LookAngles(float yaw, float pitch)
{
this.yaw = yaw;
this.pitch = pitch;
return MccMcpResult.Ok(new { success = true, yaw, pitch });
}
public MccMcpResult PlayAnimation(string hand) =>
MccMcpResult.Ok(new { success = true, hand });
public MccMcpResult ToggleSneak(bool enabled)
{
sneaking = enabled;
return MccMcpResult.Ok(new { success = true, enabled = sneaking });
}
public MccMcpResult ToggleSprint(bool enabled)
{
sprinting = enabled;
return MccMcpResult.Ok(new { success = true, enabled = sprinting });
}
public MccMcpResult ListInventories() =>
MccMcpResult.Ok(new
{
count = 2,
inventories = new object[]
{
new { id = 0, type = "PlayerInventory", title = "Player Inventory", slotCount = 46, nonEmptySlots = 1, active = false },
new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2, active = true }
}
});
public MccMcpResult GetInventorySnapshot(int inventoryId) =>
MccMcpResult.Ok(new
{
id = inventoryId,
type = inventoryId == 0 ? "PlayerInventory" : "Generic_9x3",
title = inventoryId == 0 ? "Player Inventory" : "Chest",
slotCount = inventoryId == 0 ? 46 : 63,
slots = new[]
{
new { slot = 0, type = "Stone", count = 64 }
}
});
public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers)
{
List<object> matches = [];
if (query.Contains("stone", StringComparison.OrdinalIgnoreCase))
{
matches.Add(new
{
inventoryId = 0,
inventoryType = "PlayerInventory",
inventoryTitle = "Player Inventory",
slot = 0,
itemType = "Stone",
typeLabel = "Stone",
count = 32,
isPlayerInventory = true,
hotbarSlot = 1
});
}
if (query.Contains("diamond", StringComparison.OrdinalIgnoreCase) || query.Contains("sword", StringComparison.OrdinalIgnoreCase))
{
matches.Add(new
{
inventoryId = 0,
inventoryType = "PlayerInventory",
inventoryTitle = "Player Inventory",
slot = 1,
itemType = "DiamondSword",
typeLabel = "Diamond Sword",
count = 1,
isPlayerInventory = true,
hotbarSlot = 2
});
}
if (includeContainers)
{
matches.Add(new
{
inventoryId = 1,
inventoryType = "Generic_9x3",
inventoryTitle = "Chest",
slot = 0,
itemType = "Stone",
typeLabel = "Stone",
count = 16,
isPlayerInventory = false,
hotbarSlot = (int?)null
});
}
object[] result = matches.Take(Math.Max(1, maxCount)).ToArray();
return MccMcpResult.Ok(new
{
query,
exactMatch,
includeContainers,
count = result.Length,
matches = result
});
}
public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent)
{
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest", x, y, z });
return MccMcpResult.Ok(new
{
success = true,
openAccepted = true,
opened = true,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs,
x,
y,
z,
block = new { material = "Chest", typeLabel = "Chest", blockId = 0, blockMeta = 0 },
inventory = new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2 }
});
}
public MccMcpResult CloseContainer(int inventoryId, int timeoutMs)
{
int resolvedInventoryId = inventoryId <= 0 ? 1 : inventoryId;
AddRecentEvent("inventory_close", new { inventoryId = resolvedInventoryId });
return MccMcpResult.Ok(new
{
success = true,
closed = true,
inventoryId = resolvedInventoryId,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
}
public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) =>
MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType });
public MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack) =>
MccMcpResult.Ok(new
{
success = true,
itemType,
requestedCount = count,
droppedCount = count,
beforeCount = 64,
afterCount = Math.Max(0, 64 - count),
inventoryId,
touchedSlots = new[] { 36 },
preferStack
});
public MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) =>
MccMcpResult.Ok(new
{
success = true,
direction = "deposit",
itemType,
requestedCount = count,
movedCount = count,
beforePlayerCount = 64,
afterPlayerCount = Math.Max(0, 64 - count),
beforeContainerCount = 0,
afterContainerCount = count,
inventoryId = inventoryId <= 0 ? 1 : inventoryId,
containerType = "Generic_9x3",
touchedSourceSlots = new[] { 36 },
touchedTargetSlots = new[] { 0 }
});
public MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) =>
MccMcpResult.Ok(new
{
success = true,
direction = "withdraw",
itemType,
requestedCount = count,
movedCount = count,
beforePlayerCount = 0,
afterPlayerCount = count,
beforeContainerCount = 64,
afterContainerCount = Math.Max(0, 64 - count),
inventoryId = inventoryId <= 0 ? 1 : inventoryId,
containerType = "Generic_9x3",
touchedSourceSlots = new[] { 0 },
touchedTargetSlots = new[] { 36 }
});
public MccMcpResult QueryEntities(int maxCount) =>
MccMcpResult.Ok(new
{
count = 1,
entities = new[]
{
new { id = 1, type = "Player", x = C(0.5), y = C(80.0), z = C(0.5) }
}
});
public MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius) =>
MccMcpResult.Ok(new
{
totalTracked = 1,
count = 1,
entities = new[]
{
new
{
id = 1,
type = "Player",
typeLabel = "Player",
uuid = Guid.Empty,
name = "HarnessBot",
customName = (string?)null,
x = C(0.5),
y = C(80.0),
z = C(0.5),
distance = 0.0,
health = 20.0f,
pose = "Standing",
latency = 5
}
}
});
public MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects) =>
MccMcpResult.Ok(new
{
id = entityId,
type = "Player",
typeLabel = "Player",
uuid = Guid.Empty,
name = "HarnessBot",
customName = (string?)null,
customNameVisible = false,
x = C(0.5),
y = C(80.0),
z = C(0.5),
yaw = 0.0f,
pitch = 0.0f,
health = 20.0f,
pose = "Standing",
latency = 5,
objectData = -1,
metadata = includeMetadata ? new { flags = 0 } : null,
equipment = includeEquipment ? new[] { new { slot = 0, type = "Stone", count = 1 } } : null,
activeEffects = includeEffects ? new object[0] : null
});
public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) =>
MccMcpResult.Ok(new
{
text,
exactMatch,
radius,
includeBackText,
count = 1,
signs = new[]
{
new
{
x = 2,
y = 80,
z = 1,
material = "OakSign",
typeLabel = "Oak Sign",
distance = 1.8,
isWaxed = false,
frontText = new[] { "home", "storage" },
backText = includeBackText ? new[] { "north wall" } : Array.Empty<string>(),
matchedLines = new[] { text }
}
}
});
public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount) =>
MccMcpResult.Ok(new
{
itemType = itemType ?? "OakLog",
radius,
count = 1,
items = new[]
{
new
{
entityId = 99,
itemType = "OakLog",
typeLabel = "Oak Log",
count = 3,
x = C(2.5),
y = C(80.0),
z = C(1.5),
distance = 2.24
}
}
});
public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs) =>
MccMcpResult.Ok(new
{
itemType,
radius,
maxItems,
allowUnsafe,
timeoutMs = timeoutMs <= 0 ? 2500 : timeoutMs,
attempted = 1,
successfulPickups = 1,
collectedCount = 3,
initialInventoryCount = 0,
finalInventoryCount = 3,
remainingNearby = 0,
attempts = new object[]
{
new
{
entityId = 99,
itemType,
typeLabel = "Oak Log",
expectedCount = 3,
target = new { x = C(2.5), y = C(80.0), z = C(1.5) },
pathFound = true,
arrived = true,
entityGone = true,
inventoryDelta = 3,
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalLocation = new { x = C(2.5), y = C(80.0), z = C(1.5) },
finalDistance = 0.0
}
}
});
public MccMcpResult Respawn()
{
health = 20.0f;
AddRecentEvent("respawn", new { location = new { x = playerX, y = playerY, z = playerZ } });
return MccMcpResult.Ok(new { success = true, respawned = true });
}
public MccMcpResult GetWorldBlockAt(int x, int y, int z) =>
MccMcpResult.Ok(new { x, y, z, material = "Air", blockId = 0, blockMeta = 0 });
private void AddRecentEvent(string type, object? data)
{
recentEvents.Add(new RecentEvent(nextEventId++, DateTimeOffset.UtcNow, type, data));
if (recentEvents.Count > 100)
recentEvents.RemoveAt(0);
}
private sealed record RecentEvent(long Id, DateTimeOffset TimestampUtc, string Type, object? Data);
}

View file

@ -0,0 +1,36 @@
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Harness;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace DebugTools.MccMcpWebPlayground.Api;
public static class MccPlaygroundEndpoints
{
public static IEndpointRouteBuilder MapMccPlaygroundEndpoints(this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder api = endpoints.MapGroup("/api");
api.MapGet("/health", () => Results.Ok(new { ok = true }));
api.MapGet("/config", (IOptions<MccWebHarnessOptions> options) =>
{
MccWebHarnessOptions harnessOptions = options.Value;
return Results.Ok(new MccConfigResponse(
Model: harnessOptions.ResolveModel(),
OpenRouterBaseUrl: harnessOptions.ResolveOpenRouterBaseUrl(),
McpEndpoint: harnessOptions.ResolveMcpEndpoint(),
HasApiKey: harnessOptions.HasApiKeyConfigured(),
ExposeInventoryWindowAction: harnessOptions.ExposeInventoryWindowAction,
ExposeInternalCommandTool: harnessOptions.ExposeInternalCommandTool));
});
api.MapPost("/chat/stream", (ChatStreamRequest request, IMccAgentRunService runService, HttpContext httpContext, CancellationToken cancellationToken) =>
{
return TypedResults.ServerSentEvents(runService.StreamAsync(request, httpContext, cancellationToken));
})
.WithRequestTimeout("mcc-stream");
return endpoints;
}
}

View file

@ -0,0 +1,94 @@
using System.Text.Json.Serialization;
namespace DebugTools.MccMcpWebPlayground.Contracts;
public sealed class ChatStreamRequest
{
public List<ChatMessage>? Messages { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
}
public sealed record MccConfigResponse(
string? Model,
string OpenRouterBaseUrl,
string McpEndpoint,
bool HasApiKey,
bool ExposeInventoryWindowAction,
bool ExposeInternalCommandTool);
public sealed record MccStreamEnvelope(string RunId, long Sequence, string Kind, object Data);
public sealed record MccRunStartedData(string Model, string McpEndpoint, DateTimeOffset StartedAtUtc);
public sealed record MccGuidanceLoadedData(
string SourceTool,
string CanonicalPromptName,
string GuidanceVersion,
MccCapabilityStatus CapabilityStatus);
public sealed record MccStateSummaryData(
int TurnCount,
int ToolCallCount,
bool SoftFinish,
int DirectAnswerAttempts,
IReadOnlyList<MccVerificationObligationView> OpenVerification,
IReadOnlyList<MccEvidenceView> RecentEvidence,
string? CompactionSummary);
public sealed record MccToolCalledData(string CallId, string Name, string ArgumentsJson, bool Advanced, bool Sensitive);
public sealed record MccToolResultData(
string CallId,
string Name,
bool IsError,
bool Success,
string? ErrorCode,
string Summary,
string RawText,
string EvidenceId);
public sealed record MccVerificationEventData(string ObligationId, string ToolName, string Kind, string Description);
public sealed record MccBudgetData(
int TurnCount,
int MaxTurns,
int ToolCallCount,
int MaxToolCalls,
double ElapsedSeconds,
int MaxWallClockSeconds);
public sealed record MccErrorData(string Code, string Message, string? Detail = null);
public sealed record MccFinalPayload(
string Status,
string Headline,
string AnswerMarkdown,
IReadOnlyList<string> VerifiedFacts,
IReadOnlyList<string> OpenIssues,
IReadOnlyList<string> EvidenceIds,
string? NextAction);
public sealed record MccSubmitFinalArgs(
string Status,
string Headline,
string AnswerMarkdown,
IReadOnlyList<string> VerifiedFacts,
IReadOnlyList<string> OpenIssues,
IReadOnlyList<string> EvidenceIds,
string? NextAction);
public sealed record MccCapabilityStatus(
[property: JsonPropertyName("sessionStatus")] bool SessionStatus,
[property: JsonPropertyName("chatAndCommands")] bool ChatAndCommands,
[property: JsonPropertyName("movement")] bool Movement,
[property: JsonPropertyName("inventory")] bool Inventory,
[property: JsonPropertyName("entityWorld")] bool EntityWorld);
public sealed record MccEvidenceView(string Id, string ToolName, string Summary, bool IsError);
public sealed record MccVerificationObligationView(string Id, string ToolName, string Kind, string Description);

View file

@ -0,0 +1,852 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Harness;
public interface IMccAgentRunService
{
IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(ChatStreamRequest request, HttpContext httpContext, CancellationToken cancellationToken);
}
public sealed class MccAgentRunService : IMccAgentRunService
{
private readonly MccMcpSessionFactory sessionFactory;
private readonly MccGuidanceSource guidanceSource;
private readonly MccPromptComposer promptComposer;
private readonly MccContextCompressor contextCompressor;
private readonly MccFinalizer finalizer;
private readonly OpenRouterChatClient openRouterChatClient;
private readonly MccWebHarnessOptions options;
public MccAgentRunService(
MccMcpSessionFactory sessionFactory,
MccGuidanceSource guidanceSource,
MccPromptComposer promptComposer,
MccContextCompressor contextCompressor,
MccFinalizer finalizer,
OpenRouterChatClient openRouterChatClient,
IOptions<MccWebHarnessOptions> options)
{
this.sessionFactory = sessionFactory;
this.guidanceSource = guidanceSource;
this.promptComposer = promptComposer;
this.contextCompressor = contextCompressor;
this.finalizer = finalizer;
this.openRouterChatClient = openRouterChatClient;
this.options = options.Value;
}
public async IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(
ChatStreamRequest request,
HttpContext httpContext,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, httpContext.RequestAborted);
CancellationToken linkedToken = linkedCts.Token;
string runId = Guid.NewGuid().ToString("n");
long sequence = 0;
string? model = options.ResolveModel();
if (string.IsNullOrWhiteSpace(model))
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_MODEL or MccWebHarness:Model must be configured."));
yield break;
}
if (!options.HasApiKeyConfigured())
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_API_KEY is not set."));
yield break;
}
List<object> baseConversationMessages = NormalizeConversation(request.Messages);
string userRequest = ExtractUserRequest(request.Messages);
if (string.IsNullOrWhiteSpace(userRequest))
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("invalid_request", "No user message was provided."));
yield break;
}
await using McpClient client = await sessionFactory.CreateAsync(linkedToken);
MccGuidanceBundle guidance = await guidanceSource.LoadAsync(client, linkedToken);
MccRunState runState = new()
{
RunId = runId,
UserRequest = userRequest,
BaseConversationMessages = baseConversationMessages,
ConfiguredModel = model,
Guidance = guidance
};
yield return CreateEvent(runId, ref sequence, "run_started", new MccRunStartedData(model, options.ResolveMcpEndpoint(), runState.StartedAtUtc));
yield return CreateEvent(runId, ref sequence, "guidance_loaded", new MccGuidanceLoadedData(
guidance.SourceToolName,
guidance.CanonicalPromptName,
guidance.GuidanceVersion,
guidance.CapabilityStatus));
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: linkedToken);
MccToolCatalog catalog = MccToolPolicy.BuildCatalog(tools, options, finalizer.BuildSubmitToolSchema());
while (!linkedToken.IsCancellationRequested)
{
runState.TurnCount++;
contextCompressor.CompactIfNeeded(runState);
yield return CreateEvent(runId, ref sequence, "state_summary", BuildStateSummary(runState, options));
if (runState.IsSoftFinish(options, DateTimeOffset.UtcNow))
{
yield return CreateEvent(runId, ref sequence, "budget", BuildBudgetData(runState));
}
if (runState.IsHardStop(options, DateTimeOffset.UtcNow))
break;
MccModelTurn? turn = null;
Exception? providerException = null;
try
{
turn = await openRouterChatClient.CreateTurnAsync(
promptComposer.Compose(runState),
catalog.ModelVisibleTools,
options,
linkedToken);
}
catch (Exception ex)
{
providerException = ex;
}
if (providerException is not null || turn is null)
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("provider_error", "OpenRouter request failed.", providerException?.Message));
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
yield break;
}
runState.RoutedModel = turn.ModelId;
runState.RoutedProvider = turn.RoutedProvider;
if (turn.ToolCalls.Count == 0)
{
runState.DirectAnswerAttempts++;
string content = string.IsNullOrWhiteSpace(turn.AssistantContent) ? "(empty assistant turn)" : turn.AssistantContent.Trim();
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
{
["role"] = "assistant",
["content"] = content
});
if (runState.DirectAnswerAttempts >= 4)
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData(
"model_protocol_error",
"The model kept returning plain assistant text instead of using tools or mcc_submit_final.",
content));
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
yield break;
}
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
{
["role"] = "user",
["content"] = "The previous plain assistant text was not accepted by this harness. On your next turn, you must either call the relevant MCC tools or call mcc_submit_final. Do not answer with plain assistant text again."
});
continue;
}
Dictionary<string, object?> assistantMessage = new()
{
["role"] = "assistant",
["content"] = turn.AssistantContent,
["tool_calls"] = turn.ToolCalls.Select(call => new Dictionary<string, object?>
{
["id"] = call.CallId,
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = call.Name,
["arguments"] = call.ArgumentsJson
}
}).ToArray()
};
runState.ToolConversationMessages.Add(assistantMessage);
foreach (MccModelToolCall toolCall in turn.ToolCalls)
{
MccToolProfile profile = MccToolPolicy.GetProfile(toolCall.Name);
yield return CreateEvent(runId, ref sequence, "tool_called", new MccToolCalledData(
toolCall.CallId,
toolCall.Name,
toolCall.ArgumentsJson,
profile.Risk == MccToolRisk.EscapeHatch,
profile.Risk == MccToolRisk.Sensitive));
if (toolCall.Name.Equals("mcc_submit_final", StringComparison.OrdinalIgnoreCase))
{
MccFinalizationValidation validation = finalizer.Validate(runState, toolCall.ArgumentsJson);
if (validation.Accepted)
{
yield return CreateEvent(runId, ref sequence, "final", validation.Payload!);
yield break;
}
string localResultText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "invalid_final_submission",
message = validation.ErrorText
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "invalid_final_submission",
Summary: validation.ErrorText ?? "Invalid final submission.",
RawText: localResultText,
EvidenceId: string.Empty));
continue;
}
if (MccToolPolicy.RequiresExplicitUserIntent(toolCall.Name) && !MccToolPolicy.HasExplicitUserIntent(runState.UserRequest, toolCall.Name))
{
string localResultText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "explicit_user_intent_required",
message = $"Tool '{toolCall.Name}' requires explicit user intent."
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "explicit_user_intent_required",
Summary: $"Tool '{toolCall.Name}' requires explicit user intent.",
RawText: localResultText,
EvidenceId: string.Empty));
continue;
}
if (!catalog.ToolsByName.TryGetValue(toolCall.Name, out MccToolCatalogEntry? entry))
{
string unknownToolText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "unknown_tool",
message = $"Unknown tool '{toolCall.Name}'."
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, unknownToolText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "unknown_tool",
Summary: $"Unknown tool '{toolCall.Name}'.",
RawText: unknownToolText,
EvidenceId: string.Empty));
continue;
}
CallToolResult? result = null;
Exception? toolException = null;
try
{
Dictionary<string, object?> arguments = MccJsonArguments.Parse(toolCall.ArgumentsJson);
result = await client.CallToolAsync(toolCall.Name, arguments, cancellationToken: linkedToken);
}
catch (Exception ex)
{
toolException = ex;
}
if (toolException is not null || result is null)
{
string failedText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "tool_call_failed",
message = toolException?.Message
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, failedText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "tool_call_failed",
Summary: toolException?.Message ?? "Tool call failed.",
RawText: failedText,
EvidenceId: string.Empty));
continue;
}
runState.ToolCallCount++;
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
MccEvidenceRecord evidence = CreateEvidence(runState, toolCall.Name, normalized);
runState.Evidence.Add(evidence);
runState.ToolExecutions.Add(new MccToolExecutionRecord
{
CallId = toolCall.CallId,
ToolName = toolCall.Name,
ArgumentsJson = toolCall.ArgumentsJson,
Evidence = evidence
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, normalized.Text));
foreach (MccVerificationObligation obligation in CreateObligations(runState, evidence, toolCall.ArgumentsJson))
{
runState.VerificationObligations.Add(obligation);
yield return CreateEvent(runId, ref sequence, "verification_required", new MccVerificationEventData(
obligation.Id,
obligation.ToolName,
obligation.Kind,
obligation.Description));
if (obligation.Cleared)
{
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
obligation.Id,
obligation.ToolName,
obligation.Kind,
obligation.Description));
}
}
foreach (MccVerificationObligation cleared in TryClearObligationsFromEvidence(runState, evidence))
{
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
cleared.Id,
cleared.ToolName,
cleared.Kind,
cleared.Description));
}
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
evidence.IsError,
evidence.Success,
evidence.ErrorCode,
evidence.Summary,
evidence.RawText,
evidence.Id));
}
}
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
}
private static List<object> NormalizeConversation(List<ChatMessage>? incoming)
{
List<object> messages = [];
if (incoming is null)
return messages;
foreach (ChatMessage message in incoming)
{
if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content))
continue;
string role = message.Role.Trim().ToLowerInvariant();
if (role is not ("user" or "assistant" or "system"))
continue;
messages.Add(new Dictionary<string, object?>
{
["role"] = role,
["content"] = message.Content.Trim()
});
}
return messages;
}
private static string ExtractUserRequest(List<ChatMessage>? incoming)
{
return incoming?
.LastOrDefault(message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(message.Content))
?.Content
?.Trim()
?? string.Empty;
}
private static Dictionary<string, object?> BuildToolMessage(string callId, string content)
{
return new Dictionary<string, object?>
{
["role"] = "tool",
["tool_call_id"] = callId,
["content"] = content
};
}
private static MccStateSummaryData BuildStateSummary(MccRunState runState, MccWebHarnessOptions options)
{
return new MccStateSummaryData(
TurnCount: runState.TurnCount,
ToolCallCount: runState.ToolCallCount,
SoftFinish: runState.IsSoftFinish(options, DateTimeOffset.UtcNow),
DirectAnswerAttempts: runState.DirectAnswerAttempts,
OpenVerification: runState.OpenObligations
.Select(obligation => new MccVerificationObligationView(obligation.Id, obligation.ToolName, obligation.Kind, obligation.Description))
.ToArray(),
RecentEvidence: runState.Evidence
.TakeLast(6)
.Select(evidence => new MccEvidenceView(evidence.Id, evidence.ToolName, evidence.Summary, evidence.IsError))
.ToArray(),
CompactionSummary: runState.CompactionSummary);
}
private MccBudgetData BuildBudgetData(MccRunState runState)
{
return new MccBudgetData(
TurnCount: runState.TurnCount,
MaxTurns: options.MaxTurns,
ToolCallCount: runState.ToolCallCount,
MaxToolCalls: options.MaxToolCalls,
ElapsedSeconds: (DateTimeOffset.UtcNow - runState.StartedAtUtc).TotalSeconds,
MaxWallClockSeconds: options.MaxWallClockSeconds);
}
private static MccEvidenceRecord CreateEvidence(MccRunState runState, string toolName, MccNormalizedToolResult result)
{
string summary = SummarizeEvidence(toolName, result);
return new MccEvidenceRecord
{
Id = runState.NextEvidenceId(),
ToolName = toolName,
Summary = summary,
RawText = result.Text,
IsError = result.IsError,
Success = result.Success,
ErrorCode = result.ErrorCode,
Root = result.Root,
Data = result.Data
};
}
private static string SummarizeEvidence(string toolName, MccNormalizedToolResult result)
{
if (result.Data is JsonElement data)
{
if ((toolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase) || toolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
&& TryReadBool(data, "arrived", out bool arrived))
{
return arrived
? $"movement verified; arrived={arrived}"
: $"movement not yet verified; arrived={arrived}";
}
if (toolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
{
bool destroyed = TryReadBool(data, "destroyed", out bool destroyedValue) && destroyedValue;
bool changed = TryReadBool(data, "changed", out bool changedValue) && changedValue;
return $"dig result changed={changed} destroyed={destroyed}";
}
if (toolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
{
int successful = TryReadInt(data, "successfulPickups", out int successfulValue) ? successfulValue : 0;
int collected = TryReadInt(data, "collectedCount", out int collectedValue) ? collectedValue : 0;
return $"pickup result successfulPickups={successful} collectedCount={collected}";
}
if (toolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase)
&& TryReadBool(data, "opened", out bool opened))
{
return $"container open result opened={opened}";
}
if (toolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
{
int moved = TryReadInt(data, "movedCount", out int movedValue)
? movedValue
: TryReadInt(data, "droppedCount", out int droppedValue) ? droppedValue : 0;
return $"{toolName} movedCount={moved}";
}
}
string prefix = result.IsError ? "error" : "ok";
return $"{prefix}: {Truncate(result.Text.Replace('\n', ' '), 180)}";
}
private List<MccVerificationObligation> CreateObligations(MccRunState runState, MccEvidenceRecord evidence, string argumentsJson)
{
List<MccVerificationObligation> obligations = [];
JsonElement metadata = ParseArgumentsToJson(argumentsJson);
if (evidence.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase))
{
MccVerificationObligation obligation = new()
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "movement",
Description = "Verify final player location for the requested move target.",
SourceEvidenceId = evidence.Id,
Metadata = BuildMoveMetadata(evidence, metadata),
Cleared = IsMovementVerified(evidence)
};
obligations.Add(obligation);
return obligations;
}
if (evidence.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
{
MccVerificationObligation obligation = new()
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "movement",
Description = "Verify final proximity to the requested player target.",
SourceEvidenceId = evidence.Id,
Metadata = BuildMoveToPlayerMetadata(evidence, metadata),
Cleared = IsMovementVerified(evidence)
};
obligations.Add(obligation);
return obligations;
}
if (evidence.ToolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "container",
Description = "Verify that the target container is open and active.",
SourceEvidenceId = evidence.Id,
Metadata = null,
Cleared = IsContainerOpenVerified(evidence)
});
return obligations;
}
if (evidence.ToolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "inventory",
Description = "Verify the requested inventory delta.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsInventoryVerified(evidence)
});
return obligations;
}
if (evidence.ToolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "pickup",
Description = "Verify that the requested dropped items were picked up.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsPickupVerified(evidence)
});
return obligations;
}
if (evidence.ToolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "block_change",
Description = "Verify that the target block changed state after digging.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsDigVerified(evidence)
});
}
return obligations;
}
private List<MccVerificationObligation> TryClearObligationsFromEvidence(MccRunState runState, MccEvidenceRecord evidence)
{
List<MccVerificationObligation> cleared = [];
foreach (MccVerificationObligation obligation in runState.OpenObligations)
{
if (obligation.Cleared)
continue;
if (obligation.Kind == "movement" && TryClearMovementObligation(obligation, evidence))
{
obligation.Cleared = true;
obligation.ClearedByEvidenceId = evidence.Id;
cleared.Add(obligation);
continue;
}
if (obligation.Kind == "block_change" && TryClearDigObligation(obligation, evidence))
{
obligation.Cleared = true;
obligation.ClearedByEvidenceId = evidence.Id;
cleared.Add(obligation);
}
}
return cleared;
}
private static bool TryClearMovementObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
{
if (evidence.ToolName.Equals("mcc_player_state", StringComparison.OrdinalIgnoreCase)
&& evidence.Data is JsonElement data
&& data.TryGetProperty("location", out JsonElement location)
&& obligation.Metadata is JsonElement metadata)
{
if (obligation.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase)
&& metadata.TryGetProperty("x", out JsonElement targetX)
&& metadata.TryGetProperty("y", out JsonElement targetY)
&& metadata.TryGetProperty("z", out JsonElement targetZ))
{
double tolerance = metadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 1.5;
return TryReadDouble(location, "x", out double x)
&& TryReadDouble(location, "y", out double y)
&& TryReadDouble(location, "z", out double z)
&& Distance(x, y, z, targetX.GetDouble(), targetY.GetDouble(), targetZ.GetDouble()) <= tolerance;
}
}
if (evidence.ToolName.Equals("mcc_player_locate", StringComparison.OrdinalIgnoreCase)
&& obligation.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase)
&& evidence.Data is JsonElement playerData
&& obligation.Metadata is JsonElement playerMetadata)
{
string? expectedName = playerMetadata.TryGetProperty("playerName", out JsonElement nameElement) ? nameElement.GetString() : null;
string? matchedName = playerData.TryGetProperty("matchedName", out JsonElement matchedNameElement) ? matchedNameElement.GetString() : null;
if (!string.IsNullOrWhiteSpace(expectedName) && !string.Equals(expectedName, matchedName, StringComparison.OrdinalIgnoreCase))
return false;
if (TryReadDouble(playerData, "distance", out double distance))
{
double tolerance = playerMetadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 2.0;
return distance <= tolerance;
}
}
return false;
}
private static bool TryClearDigObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
{
if (!evidence.ToolName.Equals("mcc_world_block_at", StringComparison.OrdinalIgnoreCase)
|| evidence.Data is not JsonElement data
|| obligation.Metadata is not JsonElement metadata)
{
return false;
}
if (!metadata.TryGetProperty("target", out JsonElement target)
|| !TryReadDouble(target, "x", out double x)
|| !TryReadDouble(target, "y", out double y)
|| !TryReadDouble(target, "z", out double z))
{
return false;
}
return TryReadInt(data, "x", out int blockX)
&& TryReadInt(data, "y", out int blockY)
&& TryReadInt(data, "z", out int blockZ)
&& Math.Abs(blockX - x) < 0.5
&& Math.Abs(blockY - y) < 0.5
&& Math.Abs(blockZ - z) < 0.5
&& data.TryGetProperty("block", out JsonElement block)
&& block.TryGetProperty("material", out JsonElement material)
&& !string.Equals(material.GetString(), "Air", StringComparison.OrdinalIgnoreCase);
}
private static bool IsMovementVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
if (TryReadBool(data, "arrived", out bool arrived) && arrived)
return true;
if (TryReadDouble(data, "finalDistance", out double finalDistance))
{
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
return finalDistance <= tolerance;
}
return false;
}
private static bool IsContainerOpenVerified(MccEvidenceRecord evidence)
{
return evidence.Data is JsonElement data
&& TryReadBool(data, "opened", out bool opened)
&& opened;
}
private static bool IsInventoryVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
if (TryReadInt(data, "requestedCount", out int requestedCount)
&& TryReadInt(data, "movedCount", out int movedCount))
{
return movedCount == requestedCount;
}
if (TryReadInt(data, "requestedCount", out requestedCount)
&& TryReadInt(data, "droppedCount", out int droppedCount))
{
return droppedCount == requestedCount;
}
return evidence.Success;
}
private static bool IsPickupVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
return (TryReadInt(data, "successfulPickups", out int successfulPickups) && successfulPickups > 0)
|| (TryReadInt(data, "collectedCount", out int collectedCount) && collectedCount > 0);
}
private static bool IsDigVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
return (TryReadBool(data, "destroyed", out bool destroyed) && destroyed)
|| (TryReadBool(data, "changed", out bool changed) && changed);
}
private static JsonElement? BuildMoveMetadata(MccEvidenceRecord evidence, JsonElement arguments)
{
if (evidence.Data is not JsonElement data)
return null;
double x = TryReadDoubleFromArguments(arguments, "x", out double targetX)
? targetX
: data.TryGetProperty("target", out JsonElement target) && TryReadDouble(target, "x", out double fromDataX) ? fromDataX : 0;
double y = TryReadDoubleFromArguments(arguments, "y", out double targetY)
? targetY
: data.TryGetProperty("target", out target) && TryReadDouble(target, "y", out double fromDataY) ? fromDataY : 0;
double z = TryReadDoubleFromArguments(arguments, "z", out double targetZ)
? targetZ
: data.TryGetProperty("target", out target) && TryReadDouble(target, "z", out double fromDataZ) ? fromDataZ : 0;
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
return JsonSerializer.SerializeToElement(new
{
x,
y,
z,
tolerance
});
}
private static JsonElement? BuildMoveToPlayerMetadata(MccEvidenceRecord evidence, JsonElement arguments)
{
string? playerName = arguments.TryGetProperty("playerName", out JsonElement property) ? property.GetString() : null;
double tolerance = evidence.Data is JsonElement data && TryReadDouble(data, "tolerance", out double tol) ? tol : 2.0;
return JsonSerializer.SerializeToElement(new
{
playerName,
tolerance
});
}
private static JsonElement ParseArgumentsToJson(string argumentsJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
return document.RootElement.Clone();
}
catch
{
using JsonDocument document = JsonDocument.Parse("{}");
return document.RootElement.Clone();
}
}
private static bool TryReadBool(JsonElement element, string propertyName, out bool value)
{
value = false;
return element.TryGetProperty(propertyName, out JsonElement property)
&& property.ValueKind is JsonValueKind.True or JsonValueKind.False
&& ((value = property.GetBoolean()) || !value || true);
}
private static bool TryReadInt(JsonElement element, string propertyName, out int value)
{
value = 0;
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetInt32(out value);
}
private static bool TryReadDouble(JsonElement element, string propertyName, out double value)
{
value = 0;
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetDouble(out value);
}
private static bool TryReadDoubleFromArguments(JsonElement element, string propertyName, out double value)
{
value = 0;
if (!element.TryGetProperty(propertyName, out JsonElement property))
return false;
return property.ValueKind == JsonValueKind.Number
? property.TryGetDouble(out value)
: property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString(), out value);
}
private static double Distance(double x1, double y1, double z1, double x2, double y2, double z2)
{
double dx = x1 - x2;
double dy = y1 - y2;
double dz = z1 - z2;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
private static string Truncate(string text, int maxLength)
{
return string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "...";
}
private static SseItem<MccStreamEnvelope> CreateEvent<T>(string runId, ref long sequence, string kind, T data)
{
sequence++;
return new SseItem<MccStreamEnvelope>(
new MccStreamEnvelope(runId, sequence, kind, data!),
kind)
{
EventId = sequence.ToString(CultureInfo.InvariantCulture)
};
}
}

View file

@ -0,0 +1,21 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccContextCompressor
{
public void CompactIfNeeded(MccRunState runState)
{
if (runState.Evidence.Count <= 6)
return;
IReadOnlyList<MccEvidenceRecord> olderEvidence = runState.Evidence
.Take(Math.Max(0, runState.Evidence.Count - 6))
.ToArray();
if (olderEvidence.Count == 0)
return;
runState.CompactionSummary = string.Join('\n', olderEvidence
.TakeLast(8)
.Select(record => $"- {record.Id} {record.ToolName}: {record.Summary}"));
}
}

View file

@ -0,0 +1,221 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using DebugTools.MccMcpWebPlayground.Contracts;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccFinalizer
{
private static readonly string[] AllowedStatuses = ["completed", "partial", "blocked", "clarification_needed", "failed"];
public object BuildSubmitToolSchema()
{
return new Dictionary<string, object?>
{
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = "mcc_submit_final",
["description"] = "Submit the final result for this MCC run. Use completed only when no required verification obligations remain open.",
["parameters"] = new JsonObject
{
["type"] = "object",
["additionalProperties"] = false,
["properties"] = new JsonObject
{
["status"] = new JsonObject
{
["type"] = "string",
["enum"] = new JsonArray(AllowedStatuses.Select(status => JsonValue.Create(status)).ToArray())
},
["headline"] = new JsonObject { ["type"] = "string" },
["answerMarkdown"] = new JsonObject { ["type"] = "string" },
["verifiedFacts"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["openIssues"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["evidenceIds"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["nextAction"] = new JsonObject
{
["type"] = new JsonArray("string", "null")
}
},
["required"] = new JsonArray("status", "headline", "answerMarkdown", "verifiedFacts", "openIssues", "evidenceIds", "nextAction")
}
}
};
}
public MccFinalizationValidation Validate(MccRunState runState, string argumentsJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
JsonElement root = document.RootElement;
MccSubmitFinalArgs submission = new(
Status: ReadRequiredString(root, "status"),
Headline: ReadRequiredString(root, "headline"),
AnswerMarkdown: ReadRequiredString(root, "answerMarkdown"),
VerifiedFacts: ReadStringArray(root, "verifiedFacts"),
OpenIssues: ReadStringArray(root, "openIssues"),
EvidenceIds: ReadStringArray(root, "evidenceIds"),
NextAction: ReadNullableString(root, "nextAction"));
string normalizedStatus = submission.Status.Trim().ToLowerInvariant();
if (!AllowedStatuses.Contains(normalizedStatus, StringComparer.Ordinal))
return MccFinalizationValidation.Reject("Invalid final status.");
if (string.IsNullOrWhiteSpace(submission.Headline) || string.IsNullOrWhiteSpace(submission.AnswerMarkdown))
return MccFinalizationValidation.Reject("headline and answerMarkdown are required.");
Dictionary<string, MccEvidenceRecord> evidenceById = runState.Evidence.ToDictionary(record => record.Id, StringComparer.OrdinalIgnoreCase);
Dictionary<string, string> evidenceAliasByCallId = new(StringComparer.OrdinalIgnoreCase);
foreach (MccToolExecutionRecord execution in runState.ToolExecutions)
{
evidenceAliasByCallId[execution.CallId] = execution.Evidence.Id;
int suffixSeparator = execution.CallId.LastIndexOf('_');
if (suffixSeparator >= 0 && suffixSeparator < execution.CallId.Length - 1)
evidenceAliasByCallId[execution.CallId[(suffixSeparator + 1)..]] = execution.Evidence.Id;
}
List<string> normalizedEvidenceIds = [];
foreach (string evidenceId in submission.EvidenceIds)
{
string normalizedEvidenceId = evidenceAliasByCallId.TryGetValue(evidenceId, out string? mappedEvidenceId)
? mappedEvidenceId
: evidenceId;
if (!evidenceById.ContainsKey(normalizedEvidenceId))
return MccFinalizationValidation.Reject($"Unknown evidence id '{evidenceId}'.");
if (!normalizedEvidenceIds.Contains(normalizedEvidenceId, StringComparer.OrdinalIgnoreCase))
normalizedEvidenceIds.Add(normalizedEvidenceId);
}
if (normalizedStatus == "completed" && runState.OpenObligations.Count > 0)
return MccFinalizationValidation.Reject("completed is invalid while verification obligations remain open.");
if (!AreVerifiedFactsGrounded(submission.VerifiedFacts, normalizedEvidenceIds, evidenceById))
return MccFinalizationValidation.Reject("verifiedFacts must be grounded in the referenced evidence.");
return MccFinalizationValidation.Accept(new MccFinalPayload(
normalizedStatus,
submission.Headline.Trim(),
submission.AnswerMarkdown.Trim(),
submission.VerifiedFacts,
submission.OpenIssues,
normalizedEvidenceIds,
string.IsNullOrWhiteSpace(submission.NextAction) ? null : submission.NextAction.Trim()));
}
catch (Exception ex)
{
return MccFinalizationValidation.Reject($"Invalid mcc_submit_final payload: {ex.Message}");
}
}
public MccFinalPayload BuildHardStopResult(MccRunState runState, MccWebHarnessOptions options)
{
IReadOnlyList<string> openIssues = runState.OpenObligations.Count > 0
? runState.OpenObligations.Select(obligation => obligation.Description).ToArray()
: ["The harness reached its execution budget before the run was explicitly finalized."];
IReadOnlyList<string> evidenceIds = runState.Evidence.TakeLast(4).Select(record => record.Id).ToArray();
IReadOnlyList<string> verifiedFacts = runState.Evidence
.TakeLast(4)
.Where(record => record.Success)
.Select(record => record.Summary)
.ToArray();
return new MccFinalPayload(
Status: runState.OpenObligations.Count > 0 ? "partial" : "blocked",
Headline: "Run stopped before explicit completion",
AnswerMarkdown: "I could not finish the request within the current harness budget. I am returning the strongest verified state captured so far.",
VerifiedFacts: verifiedFacts,
OpenIssues: openIssues,
EvidenceIds: evidenceIds,
NextAction: "Retry with a fresh run if you want me to continue from the latest verified state.");
}
private static bool AreVerifiedFactsGrounded(
IReadOnlyList<string> verifiedFacts,
IReadOnlyList<string> evidenceIds,
IReadOnlyDictionary<string, MccEvidenceRecord> evidenceById)
{
if (verifiedFacts.Count == 0)
return true;
if (evidenceIds.Count == 0)
return false;
string evidenceCorpus = string.Join(' ', evidenceIds
.Where(evidenceById.ContainsKey)
.Select(id => evidenceById[id].Summary))
.ToLowerInvariant();
foreach (string fact in verifiedFacts)
{
HashSet<string> factTokens = fact.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(token => token.Trim().Trim(',', '.', ':', ';', '!', '?', '"', '\''))
.Where(token => token.Length >= 4)
.Select(token => token.ToLowerInvariant())
.ToHashSet(StringComparer.Ordinal);
if (factTokens.Count == 0)
continue;
int matches = factTokens.Count(token => evidenceCorpus.Contains(token, StringComparison.Ordinal));
if (matches < Math.Min(2, factTokens.Count))
return false;
}
return true;
}
private static string ReadRequiredString(JsonElement root, string propertyName)
{
string? value = ReadNullableString(root, propertyName);
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"{propertyName} is required.");
return value.Trim();
}
private static string? ReadNullableString(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out JsonElement property))
return null;
return property.ValueKind == JsonValueKind.Null ? null : property.GetString();
}
private static string[] ReadStringArray(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out JsonElement property) || property.ValueKind != JsonValueKind.Array)
return [];
return property.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.ToArray();
}
}
public sealed record MccFinalizationValidation(bool Accepted, string? ErrorText, MccFinalPayload? Payload)
{
public static MccFinalizationValidation Accept(MccFinalPayload payload) => new(true, null, payload);
public static MccFinalizationValidation Reject(string errorText) => new(false, errorText, null);
}

View file

@ -0,0 +1,63 @@
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccGuidanceSource
{
public const string SourceToolName = "mcc_agent_guidance";
public const string CanonicalPromptName = "mcc_operator_guide";
public async Task<MccGuidanceBundle> LoadAsync(McpClient client, CancellationToken cancellationToken)
{
CallToolResult result = await client.CallToolAsync(SourceToolName, new Dictionary<string, object?>(), cancellationToken: cancellationToken);
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
JsonElement data = normalized.Data ?? throw new InvalidOperationException("mcc_agent_guidance did not return data.");
string[] bestPractices = ReadStringArray(data, "bestPractices");
string[] exampleTitles = data.TryGetProperty("exampleScenarios", out JsonElement examples)
&& examples.ValueKind == JsonValueKind.Array
? examples.EnumerateArray()
.Select(example => example.TryGetProperty("title", out JsonElement title) ? title.GetString() : null)
.Where(title => !string.IsNullOrWhiteSpace(title))
.Cast<string>()
.ToArray()
: [];
MccCapabilityStatus capabilityStatus = data.TryGetProperty("capabilityStatus", out JsonElement capabilityJson)
? JsonSerializer.Deserialize<MccCapabilityStatus>(capabilityJson.GetRawText()) ?? new MccCapabilityStatus(false, false, false, false, false)
: new MccCapabilityStatus(false, false, false, false, false);
return new MccGuidanceBundle(
SourceToolName,
CanonicalPromptName,
SkillName: ReadString(data, "skillName") ?? "mcc-mcp-operator",
GuidanceVersion: ReadString(data, "guidanceVersion") ?? "unknown",
SystemPrompt: ReadString(data, "systemPrompt") ?? throw new InvalidOperationException("mcc_agent_guidance did not return systemPrompt."),
BestPractices: bestPractices,
ExampleScenarioTitles: exampleTitles,
CapabilityStatus: capabilityStatus);
}
private static string? ReadString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static string[] ReadStringArray(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Array
? property.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.ToArray()
: [];
}
}

View file

@ -0,0 +1,86 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccPromptComposer
{
private const string HarnessContract = """
You are operating Minecraft Console Client through MCC MCP tools.
Rules:
- Use tool results and the run-state summary as the source of truth.
- Execute tools sequentially.
- End the run only with mcc_submit_final.
- status=completed is valid only when no required verification obligations remain open.
- If the task is blocked or partial, say exactly what is verified and what remains unverified.
- Do not repeat the same failing stateful action with the same arguments.
- mcc_quit_client requires explicit user intent.
- Prefer structured high-level tools. Avoid escape hatches unless they are explicitly exposed and necessary.
""";
public List<object> Compose(MccRunState runState)
{
List<object> messages =
[
BuildSystemMessage(HarnessContract),
BuildSystemMessage(runState.Guidance.SystemPrompt),
BuildSystemMessage(BuildStateSummary(runState)),
.. runState.BaseConversationMessages
];
if (!string.IsNullOrWhiteSpace(runState.CompactionSummary))
{
messages.Add(BuildSystemMessage($"""
Older verified evidence summary
{runState.CompactionSummary}
"""));
}
foreach (object message in runState.ToolConversationMessages.TakeLast(12))
messages.Add(message);
return messages;
}
private static Dictionary<string, object?> BuildSystemMessage(string text)
{
return new Dictionary<string, object?>
{
["role"] = "system",
["content"] = text
};
}
private static string BuildStateSummary(MccRunState runState)
{
string evidence = runState.Evidence.Count == 0
? "- none yet"
: string.Join('\n', runState.Evidence.TakeLast(6).Select(record =>
$"- {record.Id} {record.ToolName}: {record.Summary}"));
string obligations = runState.OpenObligations.Count == 0
? "- none"
: string.Join('\n', runState.OpenObligations.Select(obligation =>
$"- {obligation.Id} {obligation.ToolName}/{obligation.Kind}: {obligation.Description}"));
string bestPractices = runState.Guidance.BestPractices.Length == 0
? "- use verified MCC state before claiming success"
: string.Join('\n', runState.Guidance.BestPractices.Take(4).Select(item => $"- {item}"));
return $"""
Current run state
- turnCount: {runState.TurnCount}
- toolCallCount: {runState.ToolCallCount}
- directAnswerAttempts: {runState.DirectAnswerAttempts}
- routedModel: {runState.RoutedModel ?? runState.ConfiguredModel}
- routedProvider: {runState.RoutedProvider ?? "unknown"}
Outstanding verification
{obligations}
Recent evidence
{evidence}
Guidance highlights
{bestPractices}
""";
}
}

View file

@ -0,0 +1,103 @@
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccRunState
{
private int evidenceCounter;
private int obligationCounter;
public required string RunId { get; init; }
public required string UserRequest { get; init; }
public required List<object> BaseConversationMessages { get; init; }
public required string ConfiguredModel { get; init; }
public required MccGuidanceBundle Guidance { get; init; }
public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public List<object> ToolConversationMessages { get; } = [];
public List<MccEvidenceRecord> Evidence { get; } = [];
public List<MccToolExecutionRecord> ToolExecutions { get; } = [];
public List<MccVerificationObligation> VerificationObligations { get; } = [];
public string? CompactionSummary { get; set; }
public string? RoutedModel { get; set; }
public string? RoutedProvider { get; set; }
public int TurnCount { get; set; }
public int ToolCallCount { get; set; }
public int DirectAnswerAttempts { get; set; }
public string NextEvidenceId() => $"e{++evidenceCounter:0000}";
public string NextObligationId() => $"v{++obligationCounter:0000}";
public bool IsSoftFinish(MccWebHarnessOptions options, DateTimeOffset nowUtc)
{
TimeSpan elapsed = nowUtc - StartedAtUtc;
return (options.MaxTurns - TurnCount) <= options.SoftFinishRemainingTurns
|| (options.MaxToolCalls - ToolCallCount) <= options.SoftFinishRemainingToolCalls
|| (options.MaxWallClockSeconds - (int)elapsed.TotalSeconds) <= options.SoftFinishRemainingSeconds;
}
public bool IsHardStop(MccWebHarnessOptions options, DateTimeOffset nowUtc)
{
TimeSpan elapsed = nowUtc - StartedAtUtc;
return TurnCount >= options.MaxTurns
|| ToolCallCount >= options.MaxToolCalls
|| elapsed.TotalSeconds >= options.MaxWallClockSeconds;
}
public IReadOnlyList<MccVerificationObligation> OpenObligations =>
VerificationObligations.Where(obligation => !obligation.Cleared).ToArray();
}
public sealed record MccGuidanceBundle(
string SourceToolName,
string CanonicalPromptName,
string SkillName,
string GuidanceVersion,
string SystemPrompt,
string[] BestPractices,
string[] ExampleScenarioTitles,
MccCapabilityStatus CapabilityStatus);
public sealed class MccEvidenceRecord
{
public required string Id { get; init; }
public required string ToolName { get; init; }
public required string Summary { get; init; }
public required string RawText { get; init; }
public required bool IsError { get; init; }
public required bool Success { get; init; }
public string? ErrorCode { get; init; }
public JsonElement? Root { get; init; }
public JsonElement? Data { get; init; }
}
public sealed class MccToolExecutionRecord
{
public required string CallId { get; init; }
public required string ToolName { get; init; }
public required string ArgumentsJson { get; init; }
public required MccEvidenceRecord Evidence { get; init; }
}
public sealed class MccVerificationObligation
{
public required string Id { get; init; }
public required string ToolName { get; init; }
public required string Kind { get; init; }
public required string Description { get; init; }
public required string SourceEvidenceId { get; init; }
public JsonElement? Metadata { get; init; }
public bool Cleared { get; set; }
public string? ClearedByEvidenceId { get; set; }
}
public sealed record MccNormalizedToolResult(
string Text,
bool IsError,
bool Success,
string? ErrorCode,
string? Message,
JsonElement? Root,
JsonElement? Data);

View file

@ -0,0 +1,133 @@
using System.Collections.Frozen;
using System.Text.Json.Nodes;
using ModelContextProtocol.Client;
namespace DebugTools.MccMcpWebPlayground.Harness;
public enum MccToolRisk
{
ReadOnly,
Stateful,
Sensitive,
EscapeHatch
}
public sealed record MccToolProfile(
string Name,
MccToolRisk Risk,
bool VisibleByDefault,
bool RequiresExplicitUserIntent);
public sealed record MccToolCatalogEntry(McpClientTool Tool, MccToolProfile Profile);
public sealed class MccToolCatalog
{
public required Dictionary<string, MccToolCatalogEntry> ToolsByName { get; init; }
public required IReadOnlyList<object> ModelVisibleTools { get; init; }
}
public static class MccToolPolicy
{
private static readonly FrozenDictionary<string, MccToolProfile> Profiles =
new Dictionary<string, MccToolProfile>(StringComparer.OrdinalIgnoreCase)
{
["mcc_agent_guidance"] = new("mcc_agent_guidance", MccToolRisk.ReadOnly, false, false),
["mcc_inventory_window_action"] = new("mcc_inventory_window_action", MccToolRisk.EscapeHatch, false, false),
["mcc_run_internal_command"] = new("mcc_run_internal_command", MccToolRisk.EscapeHatch, false, false),
["mcc_quit_client"] = new("mcc_quit_client", MccToolRisk.Sensitive, true, true)
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
public static MccToolProfile GetProfile(string toolName)
{
return Profiles.TryGetValue(toolName, out MccToolProfile? profile)
? profile
: new MccToolProfile(toolName, MccToolRisk.Stateful, true, false);
}
public static MccToolCatalog BuildCatalog(IList<McpClientTool> tools, MccWebHarnessOptions options, object submitFinalTool)
{
Dictionary<string, MccToolCatalogEntry> toolsByName = tools.ToDictionary(
tool => tool.Name,
tool => new MccToolCatalogEntry(tool, GetProfile(tool.Name)),
StringComparer.OrdinalIgnoreCase);
List<object> visibleTools = [];
foreach (MccToolCatalogEntry entry in toolsByName.Values.OrderBy(entry => entry.Tool.Name, StringComparer.OrdinalIgnoreCase))
{
if (!IsVisible(entry.Profile, options))
continue;
visibleTools.Add(ToOpenRouterTool(entry.Tool, entry.Profile));
}
visibleTools.Add(submitFinalTool);
return new MccToolCatalog
{
ToolsByName = toolsByName,
ModelVisibleTools = visibleTools
};
}
public static bool RequiresExplicitUserIntent(string toolName)
{
return GetProfile(toolName).RequiresExplicitUserIntent;
}
public static bool HasExplicitUserIntent(string userRequest, string toolName)
{
if (!RequiresExplicitUserIntent(toolName))
return true;
string request = userRequest.Trim().ToLowerInvariant();
return toolName.Equals("mcc_quit_client", StringComparison.OrdinalIgnoreCase)
&& (request.Contains("quit mcc", StringComparison.Ordinal)
|| request.Contains("close mcc", StringComparison.Ordinal)
|| request.Contains("stop mcc", StringComparison.Ordinal)
|| request.Contains("exit mcc", StringComparison.Ordinal)
|| request.Contains("quit the client", StringComparison.Ordinal)
|| request.Contains("stop the client", StringComparison.Ordinal));
}
private static bool IsVisible(MccToolProfile profile, MccWebHarnessOptions options)
{
if (!profile.VisibleByDefault)
{
if (profile.Name.Equals("mcc_inventory_window_action", StringComparison.OrdinalIgnoreCase))
return options.ExposeInventoryWindowAction;
if (profile.Name.Equals("mcc_run_internal_command", StringComparison.OrdinalIgnoreCase))
return options.ExposeInternalCommandTool;
return false;
}
return true;
}
private static object ToOpenRouterTool(McpClientTool tool, MccToolProfile profile)
{
JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject()
};
string description = tool.Description ?? string.Empty;
if (profile.Risk == MccToolRisk.Sensitive)
description = $"{description} Requires explicit user intent.";
else if (profile.Risk == MccToolRisk.EscapeHatch)
description = $"{description} Advanced escape hatch; prefer higher-level tools first.";
return new Dictionary<string, object?>
{
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = tool.Name,
["description"] = description,
["parameters"] = parameters
}
};
}
}

View file

@ -0,0 +1,58 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccWebHarnessOptions
{
public const string SectionName = "MccWebHarness";
public string? Model { get; set; }
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1";
public string McpEndpoint { get; set; } = "http://127.0.0.1:33333/mcp";
public int MaxTurns { get; set; } = 48;
public int MaxToolCalls { get; set; } = 120;
public int MaxWallClockSeconds { get; set; } = 240;
public int SoftFinishRemainingTurns { get; set; } = 3;
public int SoftFinishRemainingToolCalls { get; set; } = 8;
public int SoftFinishRemainingSeconds { get; set; } = 30;
public bool RequireProviderParameters { get; set; } = true;
public bool AllowFallbacks { get; set; }
public bool DisableParallelToolCalls { get; set; } = true;
public bool ExposeInventoryWindowAction { get; set; }
public bool ExposeInternalCommandTool { get; set; }
public string? ResolveModel()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_MODEL"), Model);
}
public string ResolveOpenRouterBaseUrl()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL"), OpenRouterBaseUrl)
?? "https://openrouter.ai/api/v1";
}
public string ResolveMcpEndpoint()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT"), McpEndpoint)
?? "http://127.0.0.1:33333/mcp";
}
public string? ResolveMcpAuthToken()
{
return Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
}
public string? ResolveApiKey()
{
return Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
}
public bool HasApiKeyConfigured()
{
return !string.IsNullOrWhiteSpace(ResolveApiKey());
}
private static string? FirstNonEmpty(params string?[] candidates)
{
return candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate))?.Trim();
}
}

View file

@ -0,0 +1,200 @@
using System.Text;
using System.Text.Json;
using System.Reflection;
using DebugTools.MccMcpWebPlayground.Harness;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
public sealed class MccMcpSessionFactory
{
private readonly MccWebHarnessOptions options;
public MccMcpSessionFactory(IOptions<MccWebHarnessOptions> options)
{
this.options = options.Value;
}
public async Task<McpClient> CreateAsync(CancellationToken cancellationToken)
{
string endpoint = options.ResolveMcpEndpoint();
string? token = options.ResolveMcpAuthToken();
return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.AutoDetect,
AdditionalHeaders = string.IsNullOrWhiteSpace(token)
? null
: new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token}"
}
}), cancellationToken: cancellationToken);
}
}
public static class MccMcpJson
{
public static MccNormalizedToolResult Normalize(CallToolResult result)
{
JsonElement? structuredRoot = TryReadStructuredContent(result);
string text = ReadToolResultText(result, structuredRoot);
try
{
using JsonDocument document = JsonDocument.Parse(text);
JsonElement parsedRoot = document.RootElement.Clone();
JsonElement root = ShouldPreferStructuredRoot(parsedRoot, structuredRoot)
? structuredRoot!.Value
: parsedRoot;
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement)
? dataElement.Clone()
: ShouldTreatRootAsData(root) ? root.Clone() : structuredRoot;
bool success = root.TryGetProperty("success", out JsonElement successElement)
? successElement.ValueKind != JsonValueKind.False
: result.IsError != true;
string? errorCode = root.TryGetProperty("errorCode", out JsonElement errorCodeElement) && errorCodeElement.ValueKind == JsonValueKind.String
? errorCodeElement.GetString()
: null;
string? message = root.TryGetProperty("message", out JsonElement messageElement) && messageElement.ValueKind == JsonValueKind.String
? messageElement.GetString()
: null;
bool isError = result.IsError == true || !success || !string.IsNullOrWhiteSpace(errorCode);
return new MccNormalizedToolResult(text, isError, success, errorCode, message, root, data);
}
catch
{
bool isError = result.IsError == true;
return new MccNormalizedToolResult(text, isError, !isError, null, null, structuredRoot, structuredRoot);
}
}
private static string ReadToolResultText(CallToolResult result, JsonElement? structuredRoot)
{
if (result.Content is null)
return structuredRoot?.GetRawText() ?? (result.IsError == true ? "{\"success\":false}" : "{\"success\":true}");
StringBuilder builder = new();
foreach (ContentBlock block in result.Content)
{
if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
{
if (builder.Length > 0)
builder.Append('\n');
builder.Append(text.Text);
}
}
return builder.Length > 0
? builder.ToString()
: structuredRoot?.GetRawText()
?? JsonSerializer.Serialize(new { success = result.IsError != true, isError = result.IsError });
}
private static JsonElement? TryReadStructuredContent(CallToolResult result)
{
PropertyInfo? property = typeof(CallToolResult).GetProperty("StructuredContent", BindingFlags.Instance | BindingFlags.Public);
if (property?.GetValue(result) is not { } value)
return null;
return value switch
{
JsonElement json when json.ValueKind != JsonValueKind.Undefined && json.ValueKind != JsonValueKind.Null => json.Clone(),
JsonDocument document => document.RootElement.Clone(),
string text when !string.IsNullOrWhiteSpace(text) => TryParseJson(text),
_ => TrySerializeToJson(value)
};
}
private static JsonElement? TrySerializeToJson(object value)
{
try
{
return JsonSerializer.SerializeToElement(value);
}
catch
{
return null;
}
}
private static JsonElement? TryParseJson(string text)
{
try
{
using JsonDocument document = JsonDocument.Parse(text);
return document.RootElement.Clone();
}
catch
{
return null;
}
}
private static bool ShouldPreferStructuredRoot(JsonElement parsedRoot, JsonElement? structuredRoot)
{
if (structuredRoot is null)
return false;
if (parsedRoot.ValueKind != JsonValueKind.Object)
return true;
return !parsedRoot.EnumerateObject().Any(property =>
!property.NameEquals("success") &&
!property.NameEquals("isError"));
}
private static bool ShouldTreatRootAsData(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
return false;
return root.EnumerateObject().Any(property =>
!property.NameEquals("success") &&
!property.NameEquals("isError") &&
!property.NameEquals("errorCode") &&
!property.NameEquals("message"));
}
}
public static class MccJsonArguments
{
public static Dictionary<string, object?> Parse(string rawJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(rawJson) ? "{}" : rawJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
return new Dictionary<string, object?>();
Dictionary<string, object?> values = new(StringComparer.OrdinalIgnoreCase);
foreach (JsonProperty property in document.RootElement.EnumerateObject())
values[property.Name] = Convert(property.Value);
return values;
}
catch
{
return new Dictionary<string, object?>();
}
}
private static object? Convert(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => element.TryGetInt64(out long i64)
? i64
: element.TryGetDouble(out double d) ? d : element.GetRawText(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Array => element.EnumerateArray().Select(Convert).ToArray(),
JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => Convert(property.Value)),
_ => element.GetRawText()
};
}
}

View file

@ -0,0 +1,120 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Harness;
namespace DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
public sealed class OpenRouterChatClient
{
private readonly IHttpClientFactory httpClientFactory;
public OpenRouterChatClient(IHttpClientFactory httpClientFactory)
{
this.httpClientFactory = httpClientFactory;
}
public async Task<MccModelTurn> CreateTurnAsync(
List<object> messages,
IReadOnlyList<object> tools,
MccWebHarnessOptions options,
CancellationToken cancellationToken)
{
string apiKey = options.ResolveApiKey() ?? throw new InvalidOperationException("OPENROUTER_API_KEY is not configured.");
string model = options.ResolveModel() ?? throw new InvalidOperationException("Model is not configured.");
using HttpClient client = httpClientFactory.CreateClient("openrouter");
client.BaseAddress = new Uri(options.ResolveOpenRouterBaseUrl().TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground");
Dictionary<string, object?> payload = new()
{
["model"] = model,
["messages"] = messages,
["tools"] = tools,
["tool_choice"] = "auto",
["provider"] = new Dictionary<string, object?>
{
["allow_fallbacks"] = options.AllowFallbacks,
["require_parameters"] = options.RequireProviderParameters
}
};
if (ShouldSendParallelToolCallsParameter(model))
payload["parallel_tool_calls"] = !options.DisableParallelToolCalls;
using HttpResponseMessage response = await client.PostAsync(
"chat/completions",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
cancellationToken);
string body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"OpenRouter returned HTTP {(int)response.StatusCode}: {body}");
using JsonDocument document = JsonDocument.Parse(body);
if (!document.RootElement.TryGetProperty("choices", out JsonElement choices)
|| choices.ValueKind != JsonValueKind.Array
|| choices.GetArrayLength() == 0)
{
throw new InvalidOperationException("OpenRouter did not return any choices.");
}
JsonElement message = choices[0].GetProperty("message");
string assistantContent = message.TryGetProperty("content", out JsonElement contentElement)
? contentElement.GetString() ?? string.Empty
: string.Empty;
List<MccModelToolCall> toolCalls = [];
if (message.TryGetProperty("tool_calls", out JsonElement toolCallsElement) && toolCallsElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement toolCall in toolCallsElement.EnumerateArray())
{
if (!toolCall.TryGetProperty("id", out JsonElement idElement)
|| !toolCall.TryGetProperty("function", out JsonElement functionElement)
|| !functionElement.TryGetProperty("name", out JsonElement nameElement))
{
continue;
}
toolCalls.Add(new MccModelToolCall(
CallId: idElement.GetString() ?? Guid.NewGuid().ToString("n"),
Name: nameElement.GetString() ?? string.Empty,
ArgumentsJson: functionElement.TryGetProperty("arguments", out JsonElement argumentsElement)
? argumentsElement.GetString() ?? "{}"
: "{}"));
}
}
string modelId = document.RootElement.TryGetProperty("model", out JsonElement modelElement)
? modelElement.GetString() ?? model
: model;
string? routedProvider = response.Headers.TryGetValues("x-openrouter-provider", out IEnumerable<string>? providerValues)
? providerValues.FirstOrDefault()
: null;
return new MccModelTurn(modelId, routedProvider, assistantContent, toolCalls);
}
private static bool ShouldSendParallelToolCallsParameter(string model)
{
// Some OpenRouter model families reject tool-enabled requests when the parallel_tool_calls
// parameter is present at all, even if it is explicitly set to false. The harness still
// executes all returned tool calls sequentially, so omitting the transport hint for those
// families preserves the intended runtime behavior while keeping the stricter flag for
// compatible models.
return !model.StartsWith("minimax/", StringComparison.OrdinalIgnoreCase)
&& !model.StartsWith("google/gemini-", StringComparison.OrdinalIgnoreCase);
}
}
public sealed record MccModelTurn(
string ModelId,
string? RoutedProvider,
string AssistantContent,
IReadOnlyList<MccModelToolCall> ToolCalls);
public sealed record MccModelToolCall(string CallId, string Name, string ArgumentsJson);

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,40 @@
using DebugTools.MccMcpWebPlayground.Api;
using DebugTools.MccMcpWebPlayground.Harness;
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
using Microsoft.AspNetCore.Http.Timeouts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<MccWebHarnessOptions>()
.Bind(builder.Configuration.GetSection(MccWebHarnessOptions.SectionName));
builder.Services.AddRequestTimeouts(options =>
{
options.AddPolicy("mcc-stream", new RequestTimeoutPolicy
{
Timeout = TimeSpan.FromMinutes(10)
});
});
builder.Services.AddHttpClient("openrouter", client =>
{
client.Timeout = TimeSpan.FromMinutes(15);
});
builder.Services.AddSingleton<MccMcpSessionFactory>();
builder.Services.AddSingleton<MccGuidanceSource>();
builder.Services.AddSingleton<MccContextCompressor>();
builder.Services.AddSingleton<MccFinalizer>();
builder.Services.AddSingleton<MccPromptComposer>();
builder.Services.AddSingleton<OpenRouterChatClient>();
builder.Services.AddScoped<IMccAgentRunService, MccAgentRunService>();
var app = builder.Build();
app.UseRequestTimeouts();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapMccPlaygroundEndpoints();
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5295",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7104;http://localhost:5295",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"MccWebHarness": {
"AllowFallbacks": false,
"DisableParallelToolCalls": true,
"ExposeInventoryWindowAction": false,
"ExposeInternalCommandTool": false
}
}

View file

@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"MccWebHarness": {
"OpenRouterBaseUrl": "https://openrouter.ai/api/v1",
"McpEndpoint": "http://127.0.0.1:33333/mcp",
"MaxTurns": 48,
"MaxToolCalls": 120,
"MaxWallClockSeconds": 240,
"SoftFinishRemainingTurns": 3,
"SoftFinishRemainingToolCalls": 8,
"SoftFinishRemainingSeconds": 30,
"RequireProviderParameters": true,
"AllowFallbacks": false,
"DisableParallelToolCalls": true,
"ExposeInventoryWindowAction": false,
"ExposeInternalCommandTool": false
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,310 @@
const html = document.documentElement;
const statusEl = document.getElementById("status");
const sendBtn = document.getElementById("send");
const stopBtn = document.getElementById("stop");
const clearBtn = document.getElementById("clear");
const clearChatBtn = document.getElementById("clear-chat-btn");
const clearToolsBtn = document.getElementById("clear-tools-btn");
const promptEl = document.getElementById("prompt");
const chatEl = document.getElementById("chat");
const toolsEl = document.getElementById("tools");
const emptyStateEl = document.getElementById("empty-state");
const toolsEmptyStateEl = document.getElementById("tools-empty-state");
const typingIndicatorEl = document.getElementById("typing-indicator");
const themeToggleBtn = document.getElementById("theme-toggle");
const themeToggleIconEl = document.getElementById("theme-toggle-icon");
let history = [];
let activeAssistantBody = null;
let abortController = null;
stopBtn.disabled = true;
loadTheme();
loadConfig();
themeToggleBtn.addEventListener("click", () => {
const next = html.getAttribute("data-theme") === "dark" ? "light" : "dark";
setTheme(next);
});
sendBtn.addEventListener("click", sendPrompt);
stopBtn.addEventListener("click", () => abortController?.abort());
clearBtn.addEventListener("click", () => {
history = [];
removeAllMessages();
removeAllTimelineEvents();
promptEl.value = "";
activeAssistantBody = null;
updateEmptyStates();
});
clearChatBtn.addEventListener("click", () => {
history = [];
removeAllMessages();
promptEl.value = "";
activeAssistantBody = null;
updateEmptyStates();
});
clearToolsBtn.addEventListener("click", () => {
removeAllTimelineEvents();
updateEmptyStates();
});
promptEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendPrompt();
}
});
async function loadConfig() {
try {
const response = await fetch("/api/config");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const config = await response.json();
const modelLabel = config.model ? config.model : "Model not configured";
statusEl.textContent = config.hasApiKey ? modelLabel : `${modelLabel} / missing OPENROUTER_API_KEY`;
} catch (error) {
statusEl.textContent = `Config error: ${error.message}`;
}
}
async function sendPrompt() {
const prompt = promptEl.value.trim();
if (!prompt || abortController) {
return;
}
history.push({ role: "user", content: prompt });
addMessage("user", prompt);
promptEl.value = "";
activeAssistantBody = addMessage("assistant", "");
setBusy(true);
abortController = new AbortController();
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
signal: abortController.signal
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finalAssistantText = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = parseSseChunk(buffer, (eventName, envelope) => {
addTimelineEvent(eventName, envelope);
if (eventName === "error") {
const errorMessage = envelope.data?.message ?? "Unknown error";
addMessage("error", errorMessage);
}
if (eventName === "final") {
finalAssistantText = formatFinalText(envelope.data);
activeAssistantBody.textContent = finalAssistantText;
}
if (eventName === "state_summary") {
const turnCount = envelope.data?.turnCount ?? "?";
const toolCallCount = envelope.data?.toolCallCount ?? "?";
statusEl.textContent = `Running turn ${turnCount}, tools ${toolCallCount}`;
}
});
}
if (finalAssistantText.trim().length > 0) {
history.push({ role: "assistant", content: finalAssistantText });
}
} catch (error) {
if (error.name !== "AbortError") {
addMessage("error", `Request failed: ${error.message}`);
addTimelineEvent("error", {
kind: "error",
data: {
code: "request_failed",
message: error.message
}
});
}
} finally {
abortController = null;
activeAssistantBody = null;
setBusy(false);
}
}
function parseSseChunk(buffer, onEvent) {
let blockIndex;
while ((blockIndex = buffer.indexOf("\n\n")) >= 0) {
const rawBlock = buffer.slice(0, blockIndex);
buffer = buffer.slice(blockIndex + 2);
let eventName = "message";
let dataText = "";
for (const line of rawBlock.split("\n")) {
if (line.startsWith("event:")) {
eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataText += line.slice(5).trim();
}
}
if (!dataText) {
continue;
}
try {
onEvent(eventName, JSON.parse(dataText));
} catch (error) {
onEvent("error", {
kind: "error",
data: {
code: "invalid_sse_payload",
message: "Failed to parse SSE payload.",
detail: dataText
}
});
}
}
return buffer;
}
function addMessage(role, content) {
const wrapper = document.createElement("div");
wrapper.className = `message ${role}`;
const label = document.createElement("div");
label.className = "message-label";
label.textContent = role;
const body = document.createElement("div");
body.className = "message-body";
body.textContent = content;
wrapper.append(label, body);
chatEl.insertBefore(wrapper, typingIndicatorEl);
chatEl.scrollTop = chatEl.scrollHeight;
updateEmptyStates();
return body;
}
function addTimelineEvent(kind, envelope) {
const event = document.createElement("div");
event.className = `timeline-event kind-${kind}`;
const label = document.createElement("div");
label.className = "timeline-label";
label.textContent = kind.replaceAll("_", " ");
const body = document.createElement("div");
body.className = "timeline-body-text";
body.textContent = JSON.stringify(envelope.data ?? envelope, null, 2);
event.append(label, body);
toolsEl.appendChild(event);
toolsEl.scrollTop = toolsEl.scrollHeight;
updateEmptyStates();
}
function formatFinalText(data) {
if (!data) {
return "The run completed without a final payload.";
}
const lines = [];
if (data.headline) {
lines.push(data.headline);
lines.push("");
}
if (data.answerMarkdown) {
lines.push(data.answerMarkdown);
}
if (Array.isArray(data.verifiedFacts) && data.verifiedFacts.length > 0) {
lines.push("");
lines.push("Verified facts:");
for (const fact of data.verifiedFacts) {
lines.push(`- ${fact}`);
}
}
if (Array.isArray(data.openIssues) && data.openIssues.length > 0) {
lines.push("");
lines.push("Open issues:");
for (const issue of data.openIssues) {
lines.push(`- ${issue}`);
}
}
if (data.nextAction) {
lines.push("");
lines.push(`Next action: ${data.nextAction}`);
}
return lines.join("\n");
}
function setBusy(busy) {
sendBtn.disabled = busy;
stopBtn.disabled = !busy;
promptEl.disabled = busy;
typingIndicatorEl.classList.toggle("visible", busy);
statusEl.classList.toggle("busy", busy);
if (!busy) {
loadConfig();
} else {
statusEl.textContent = "Streaming run...";
}
}
function removeAllMessages() {
for (const message of chatEl.querySelectorAll(".message")) {
message.remove();
}
}
function removeAllTimelineEvents() {
for (const event of toolsEl.querySelectorAll(".timeline-event")) {
event.remove();
}
}
function updateEmptyStates() {
emptyStateEl.style.display = chatEl.querySelectorAll(".message").length === 0 ? "" : "none";
toolsEmptyStateEl.style.display = toolsEl.querySelectorAll(".timeline-event").length === 0 ? "" : "none";
}
function loadTheme() {
const theme = localStorage.getItem("mcc-playground-theme") || "dark";
setTheme(theme);
}
function setTheme(theme) {
html.setAttribute("data-theme", theme);
themeToggleIconEl.textContent = theme === "dark" ? "◎" : "◐";
localStorage.setItem("mcc-playground-theme", theme);
}

View file

@ -0,0 +1,79 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCC MCP Playground</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/site.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="brand-dot"></div>
<div>
<div class="brand-title"><span>MCC</span> MCP Playground</div>
<div class="brand-subtitle">Canonical guidance bootstrap, typed run state, verified completion</div>
</div>
</div>
<div class="topbar-actions">
<div id="status" class="status-pill">Booting...</div>
<button id="theme-toggle" class="icon-button" title="Toggle theme" aria-label="Toggle theme">
<span id="theme-toggle-icon"></span>
</button>
</div>
</header>
<main class="layout">
<section class="panel chat-panel">
<div class="panel-header">
<h1>Conversation</h1>
<div class="panel-actions">
<button id="clear-chat-btn" class="icon-button" title="Clear chat">Clear Chat</button>
</div>
</div>
<div id="chat" class="panel-body chat-body">
<div id="empty-state" class="empty-state">
<p>No messages yet.</p>
<p>Ask the harness to inspect or act through MCC's MCP server.</p>
</div>
<div id="typing-indicator" class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</section>
<section class="panel timeline-panel">
<div class="panel-header">
<h1>Run Timeline</h1>
<div class="panel-actions">
<button id="clear-tools-btn" class="icon-button" title="Clear timeline">Clear Events</button>
</div>
</div>
<div id="tools" class="panel-body timeline-body">
<div id="tools-empty-state" class="empty-state">
<p>No run events yet.</p>
<p>Typed SSE events will appear here as the harness runs.</p>
</div>
</div>
</section>
</main>
<footer class="composer">
<div class="composer-row">
<textarea id="prompt" placeholder="Ask MCC to inspect, move, or interact through the web harness..."></textarea>
</div>
<div class="composer-footer">
<div class="composer-hint">Press <kbd>Enter</kbd> to send, <kbd>Shift</kbd> + <kbd>Enter</kbd> for a new line.</div>
<div class="composer-actions">
<button id="stop" class="ghost-button">Stop</button>
<button id="clear" class="ghost-button">Clear All</button>
<button id="send" class="primary-button">Send</button>
</div>
</div>
</footer>
<script src="/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,383 @@
:root {
--bg: #07111e;
--bg-alt: #0b1828;
--panel: rgba(10, 21, 36, 0.88);
--panel-strong: rgba(8, 18, 30, 0.96);
--border: rgba(111, 179, 255, 0.18);
--text: #dce9ff;
--text-dim: #8ca4c8;
--text-soft: #607695;
--accent: #75e7c7;
--accent-strong: #4ad3ff;
--warning: #ffcc66;
--danger: #ff7b8b;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
[data-theme="light"] {
--bg: #edf4ff;
--bg-alt: #dfeaff;
--panel: rgba(255, 255, 255, 0.88);
--panel-strong: rgba(255, 255, 255, 0.96);
--border: rgba(28, 89, 164, 0.14);
--text: #172843;
--text-dim: #4d6383;
--text-soft: #7d90ad;
--accent: #0f936d;
--accent-strong: #006cbb;
--warning: #a56700;
--danger: #ba2741;
--shadow: 0 20px 60px rgba(61, 89, 138, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 16px;
padding: 18px;
color: var(--text);
font-family: "Space Mono", monospace;
background:
radial-gradient(circle at top left, rgba(74, 211, 255, 0.12), transparent 35%),
radial-gradient(circle at right center, rgba(117, 231, 199, 0.08), transparent 40%),
linear-gradient(160deg, var(--bg), var(--bg-alt));
}
button,
textarea {
font: inherit;
}
.topbar,
.panel,
.composer {
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
box-shadow: 0 0 18px rgba(117, 231, 199, 0.55);
}
.brand-title {
font-family: "Syne", sans-serif;
font-size: 1rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.brand-title span {
color: var(--accent);
}
.brand-subtitle {
margin-top: 4px;
color: var(--text-dim);
font-size: 0.74rem;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.status-pill {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.72rem;
white-space: nowrap;
}
.status-pill.busy {
color: var(--accent-strong);
border-color: rgba(74, 211, 255, 0.4);
}
.icon-button,
.ghost-button,
.primary-button {
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
padding: 10px 14px;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.icon-button:hover,
.ghost-button:hover,
.primary-button:hover {
transform: translateY(-1px);
border-color: rgba(117, 231, 199, 0.4);
}
.primary-button {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #06101a;
font-weight: 700;
}
.layout {
min-height: 0;
display: grid;
grid-template-columns: 1.2fr 0.9fr;
gap: 16px;
}
.panel {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.panel-header h1 {
margin: 0;
font-family: "Syne", sans-serif;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.panel-body {
min-height: 0;
overflow: auto;
padding: 18px;
}
.chat-body,
.timeline-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.message,
.timeline-event {
border-radius: 16px;
border: 1px solid var(--border);
padding: 14px 16px;
background: var(--panel-strong);
}
.message.user {
background: rgba(74, 211, 255, 0.09);
}
.message.assistant {
background: rgba(117, 231, 199, 0.06);
}
.message.error {
background: rgba(255, 123, 139, 0.08);
border-color: rgba(255, 123, 139, 0.2);
}
.message-label,
.timeline-label {
margin-bottom: 8px;
color: var(--text-dim);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.message-body,
.timeline-body-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
font-size: 0.82rem;
}
.timeline-event.kind-tool_called {
border-left: 4px solid var(--accent-strong);
}
.timeline-event.kind-tool_result {
border-left: 4px solid var(--accent);
}
.timeline-event.kind-error {
border-left: 4px solid var(--danger);
}
.timeline-event.kind-budget {
border-left: 4px solid var(--warning);
}
.timeline-event.kind-final {
border-left: 4px solid var(--accent);
}
.empty-state {
padding: 30px 18px;
text-align: center;
color: var(--text-soft);
border: 1px dashed var(--border);
border-radius: 14px;
}
.typing-indicator {
display: none;
gap: 6px;
align-items: center;
padding: 10px 4px 0;
}
.typing-indicator.visible {
display: flex;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent-strong);
animation: bounce 1s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.16s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.45;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}
.composer {
padding: 16px 18px;
}
.composer-row {
display: flex;
}
#prompt {
width: 100%;
min-height: 78px;
max-height: 240px;
resize: vertical;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
color: var(--text);
padding: 14px 16px;
}
#prompt:focus {
outline: 2px solid rgba(74, 211, 255, 0.35);
border-color: rgba(74, 211, 255, 0.45);
}
.composer-footer {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.composer-hint {
color: var(--text-dim);
font-size: 0.74rem;
}
kbd {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
font-size: 0.74rem;
}
.composer-actions {
display: flex;
gap: 10px;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
body {
padding: 12px;
}
.topbar,
.composer {
padding: 14px;
}
.composer-footer {
flex-direction: column;
align-items: stretch;
}
.composer-actions {
justify-content: stretch;
}
.composer-actions > button {
flex: 1;
}
}

11
Directory.Build.props Normal file
View file

@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<DefaultItemExcludes>$(DefaultItemExcludes);**/bin/**;**/obj/**</DefaultItemExcludes>
</PropertyGroup>
<PropertyGroup Condition="'$(MCC_BUILD_ROOT)' != ''">
<BaseOutputPath>$(MCC_BUILD_ROOT)/$(MSBuildProjectName)/bin/</BaseOutputPath>
<BaseIntermediateOutputPath>$(MCC_BUILD_ROOT)/$(MSBuildProjectName)/obj/</BaseIntermediateOutputPath>
<MSBuildProjectExtensionsPath>$(BaseIntermediateOutputPath)</MSBuildProjectExtensionsPath>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,176 @@
using MinecraftClient.ChatBots;
using MinecraftClient.Scripting;
namespace MinecraftClient.Tests;
public sealed class AutoRelogRetryPolicyTests
{
[Fact]
public void DefaultConfigurationUsesUnlimitedRetries()
{
Assert.Equal(-1, new AutoRelog.Configs().Retries);
}
[Fact]
public void UnlimitedRetriesNeverExhaust()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
for (int attempt = 1; attempt <= 100; attempt++)
{
Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft));
Assert.Equal(-1, retriesLeft);
Assert.Equal(attempt, policy.Attempts);
}
}
[Fact]
public void ZeroRetriesDisablesReconnect()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.False(policy.TryReserveAttempt(0, out int retriesLeft));
Assert.Equal(0, retriesLeft);
Assert.Equal(0, policy.Attempts);
}
[Fact]
public void FiniteRetryLimitIsExact()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(3, out int firstRetriesLeft));
Assert.True(policy.TryReserveAttempt(3, out int secondRetriesLeft));
Assert.True(policy.TryReserveAttempt(3, out int thirdRetriesLeft));
Assert.False(policy.TryReserveAttempt(3, out int exhaustedRetriesLeft));
Assert.Equal(2, firstRetriesLeft);
Assert.Equal(1, secondRetriesLeft);
Assert.Equal(0, thirdRetriesLeft);
Assert.Equal(0, exhaustedRetriesLeft);
}
[Fact]
public void RejectedRestartDoesNotConsumeRetry()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(1, out _));
policy.RollBackReservedAttempt();
Assert.True(policy.TryReserveAttempt(1, out int retriesLeft));
Assert.Equal(0, retriesLeft);
}
[Fact]
public void CoalescedDuplicateRollsBackOnlyItsOwnReservation()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(2, out int firstRetriesLeft));
Assert.True(policy.TryReserveAttempt(2, out int duplicateRetriesLeft));
policy.RollBackReservedAttempt();
Assert.Equal(1, firstRetriesLeft);
Assert.Equal(0, duplicateRetriesLeft);
Assert.Equal(1, policy.Attempts);
Assert.True(policy.TryReserveAttempt(2, out int secondFailureRetriesLeft));
Assert.Equal(0, secondFailureRetriesLeft);
Assert.False(policy.TryReserveAttempt(2, out _));
}
[Fact]
public void UnlimitedDuplicateRollbackKeepsUnlimitedBudget()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(-1, out _));
Assert.True(policy.TryReserveAttempt(-1, out _));
policy.RollBackReservedAttempt();
Assert.Equal(1, policy.Attempts);
Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft));
Assert.Equal(-1, retriesLeft);
}
[Fact]
public void StableConnectionResetsRetryBudget()
{
var timeProvider = new ManualTimeProvider();
var policy = new AutoRelogRetryPolicy(timeProvider);
Assert.True(policy.TryReserveAttempt(1, out _));
policy.MarkJoined();
timeProvider.Advance(AutoRelogRetryPolicy.StableConnectionThreshold - TimeSpan.FromMilliseconds(1));
Assert.False(policy.ResetAfterStableConnection());
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
Assert.True(policy.ResetAfterStableConnection());
Assert.Equal(0, policy.Attempts);
Assert.True(policy.TryReserveAttempt(1, out _));
}
[Fact]
public void TransportLossAlwaysReconnectsWhenEnabled()
{
bool reconnect = AutoRelogRetryPolicy.ShouldReconnect(
ChatBot.DisconnectReason.ConnectionLost,
"A transport-specific error without a configured phrase",
ignoreKickMessage: false,
["Server is restarting"],
out string? matchedMessage);
Assert.True(reconnect);
Assert.Null(matchedMessage);
}
[Theory]
[InlineData(ChatBot.DisconnectReason.InGameKick)]
[InlineData(ChatBot.DisconnectReason.LoginRejected)]
public void ServerMessageMatchingIsCaseInsensitive(ChatBot.DisconnectReason reason)
{
bool reconnect = AutoRelogRetryPolicy.ShouldReconnect(
reason,
"THE SERVER IS RESTARTING NOW",
ignoreKickMessage: false,
["server is restarting"],
out string? matchedMessage);
Assert.True(reconnect);
Assert.Equal("server is restarting", matchedMessage);
}
[Fact]
public void UserLogoutNeverReconnects()
{
Assert.False(AutoRelogRetryPolicy.ShouldReconnect(
ChatBot.DisconnectReason.UserLogout,
"Server is restarting",
ignoreKickMessage: true,
["Server is restarting"],
out _));
}
[Fact]
public void IgnoreKickMessageAllowsNonmatchingServerKick()
{
Assert.True(AutoRelogRetryPolicy.ShouldReconnect(
ChatBot.DisconnectReason.InGameKick,
"Administrative removal",
ignoreKickMessage: true,
[],
out _));
}
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset utcNow = DateTimeOffset.UnixEpoch;
public override DateTimeOffset GetUtcNow() => utcNow;
internal void Advance(TimeSpan duration)
{
utcNow += duration;
}
}
}

View file

@ -0,0 +1,108 @@
using MinecraftClient.Mapping;
using MinecraftClient.Mapping.BlockPalettes;
namespace MinecraftClient.Tests;
public sealed class BlockStatePropertiesTests
{
private readonly Palette262 _palette = new();
[Theory]
[InlineData(32162, "north", "true", "inactive")]
[InlineData(32168, "north", "false", "unlocking")]
[InlineData(32193, "east", "false", "ejecting")]
public void VaultStatesExposeAllProperties(
int stateId,
string facing,
string ominous,
string vaultState)
{
IReadOnlyDictionary<string, string> properties = _palette.GetStateProperties(stateId);
Assert.Equal(facing, properties["facing"]);
Assert.Equal(ominous, properties["ominous"]);
Assert.Equal(vaultState, properties["vault_state"]);
}
[Fact]
public void PropertiesUseServerReportedStateStride()
{
IReadOnlyDictionary<string, string> properties = _palette.GetStateProperties(3989);
Assert.Equal("left", properties["type"]);
Assert.Equal("north", properties["facing"]);
Assert.Equal("true", properties["waterlogged"]);
}
[Fact]
public void StateWithoutPropertiesReturnsEmptyMap()
{
IReadOnlyDictionary<string, string> properties = _palette.GetStateProperties(1);
Assert.Empty(properties);
}
public static TheoryData<string, BlockPalette> ModernPalettes => new()
{
{ "1.13.2", new Palette113() },
{ "1.14.4", new Palette114() },
{ "1.15.2", new Palette115() },
{ "1.16.5", new Palette116() },
{ "1.17.1", new Palette117() },
{ "1.19.2", new Palette119() },
{ "1.19.3", new Palette1193() },
{ "1.19.4", new Palette1194() },
{ "1.20", new Palette120() },
{ "1.20.4", new Palette1204() },
{ "1.20.6", new Palette1206() },
{ "1.21.2", new Palette1212() },
{ "1.21.4", new Palette1214() },
{ "1.21.5", new Palette1215() },
{ "1.21.6", new Palette1216() },
{ "1.21.9", new Palette1219() },
{ "26.1", new Palette261() },
{ "26.2", new Palette262() }
};
[Theory]
[MemberData(nameof(ModernPalettes))]
public void EveryModernPaletteExposesOakLogAxis(string version, BlockPalette palette)
{
bool foundExpectedState = false;
for (int stateId = 0; stateId <= ushort.MaxValue; stateId++)
{
if (palette.FromId(stateId) != Material.OakLog)
continue;
IReadOnlyDictionary<string, string> properties = palette.GetStateProperties(stateId);
if (properties.TryGetValue("axis", out string? axis) && axis == "x")
{
foundExpectedState = true;
break;
}
}
Assert.True(foundExpectedState, $"Minecraft {version} did not expose oak_log[axis=x]");
}
[Fact]
public void LegacyPaletteExposesPackedMetadata()
{
BlockPalette previousPalette = Block.Palette;
try
{
Block.Palette = new Palette112();
Block block = new(17, 4);
IReadOnlyDictionary<string, string> properties = block.GetStateProperties();
Assert.Equal(276, block.StateId);
Assert.Equal("4", properties["metadata"]);
}
finally
{
Block.Palette = previousPalette;
}
}
}

View file

@ -0,0 +1,14 @@
using MinecraftClient.Scripting;
namespace MinecraftClient.Tests;
public sealed class CSharpRunnerTests
{
[Theory]
[InlineData("//using MinecraftClient.CommandHandler", "using MinecraftClient.CommandHandler;")]
[InlineData("//using MinecraftClient.CommandHandler;", "using MinecraftClient.CommandHandler;")]
public void NormalizeUsingDirectiveAddsMissingSemicolon(string directive, string expected)
{
Assert.Equal(expected, CSharpRunner.NormalizeUsingDirective(directive));
}
}

View file

@ -0,0 +1,159 @@
using MinecraftClient.Protocol.Handlers;
using MinecraftClient.Protocol.Message;
namespace MinecraftClient.Tests;
public sealed class ChatTypeHolderTests
{
[Fact]
public void ReferenceHolderUsesOneBasedWireIdIn121AndNewer()
{
var dataTypes = new DataTypes(Protocol18Handler.MC_1_21_Version);
var packetData = new Queue<byte>(DataTypes.GetVarInt(2));
int chatTypeId = ChatParser.ReadChatTypeHolder(
dataTypes,
packetData,
Protocol18Handler.MC_1_21_Version,
out var directDecoration);
Assert.Equal(1, chatTypeId);
Assert.Null(directDecoration);
Assert.Empty(packetData);
}
[Fact]
public void RegistryIdRemainsUnchangedBefore121()
{
var dataTypes = new DataTypes(Protocol18Handler.MC_1_20_6_Version);
var packetData = new Queue<byte>(DataTypes.GetVarInt(2));
int chatTypeId = ChatParser.ReadChatTypeHolder(
dataTypes,
packetData,
Protocol18Handler.MC_1_20_6_Version,
out var directDecoration);
Assert.Equal(2, chatTypeId);
Assert.Null(directDecoration);
Assert.Empty(packetData);
}
[Fact]
public void DirectHolderConsumesChatAndNarrationDecorations()
{
var dataTypes = new DataTypes(Protocol18Handler.MC_1_21_Version);
var packetBytes = new List<byte>();
packetBytes.AddRange(DataTypes.GetVarInt(0));
AddDecoration(packetBytes, dataTypes, "chat.type.text", 0, 2);
AddDecoration(packetBytes, dataTypes, "chat.type.text.narrate", 0, 2);
var packetData = new Queue<byte>(packetBytes);
int chatTypeId = ChatParser.ReadChatTypeHolder(
dataTypes,
packetData,
Protocol18Handler.MC_1_21_Version,
out var directDecoration);
Assert.Equal(-1, chatTypeId);
Assert.NotNull(directDecoration);
Assert.Equal("chat.type.text", directDecoration.TranslationKey);
Assert.Equal(
[ChatParser.ChatTypeParameter.Sender, ChatParser.ChatTypeParameter.Content],
directDecoration.Parameters);
Assert.Empty(packetData);
}
[Fact]
public void RegistryDecorationControlsParameterSelectionAndOrdering()
{
Dictionary<int, ChatParser.MessageType>? originalChatTypes = ChatParser.ChatId2Type;
try
{
ChatParser.ClearChatTypeDecorations();
ChatParser.ChatId2Type = [];
var chatTypeData = new Dictionary<string, object>
{
["chat"] = new Dictionary<string, object>
{
["translation_key"] = "commands.message.display.outgoing",
["parameters"] = new object[] { "target", "content" }
}
};
ChatParser.ReadChatType(42, "example:custom_chat", chatTypeData);
var message = new ChatMessage(
"hello",
false,
42,
Guid.Empty,
null,
"Alice",
"Bob",
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
null,
false);
string rendered = ChatParser.ParseSignedChat(message);
Assert.Equal("You whisper to Bob: hello", rendered);
}
finally
{
ChatParser.ClearChatTypeDecorations();
ChatParser.ChatId2Type = originalChatTypes;
}
}
[Fact]
public void UnknownTranslationKeyIsUsedAsVanillaFormatPattern()
{
Dictionary<int, ChatParser.MessageType>? originalChatTypes = ChatParser.ChatId2Type;
try
{
ChatParser.ClearChatTypeDecorations();
ChatParser.ChatId2Type = [];
var chatTypeData = new Dictionary<string, object>
{
["chat"] = new Dictionary<string, object>
{
["translation_key"] = "%s",
["parameters"] = new object[] { "sender", "content" }
}
};
ChatParser.ReadChatType(42, "ordinary:custom_chat", chatTypeData);
var message = new ChatMessage(
"hello",
false,
42,
Guid.Empty,
null,
"Alice » hello",
null,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
null,
false);
string rendered = ChatParser.ParseSignedChat(message);
Assert.Equal("Alice » hello", rendered);
}
finally
{
ChatParser.ClearChatTypeDecorations();
ChatParser.ChatId2Type = originalChatTypes;
}
}
private static void AddDecoration(
List<byte> packetBytes,
DataTypes dataTypes,
string translationKey,
params int[] parameters)
{
packetBytes.AddRange(dataTypes.GetString(translationKey));
packetBytes.AddRange(DataTypes.GetVarInt(parameters.Length));
foreach (int parameter in parameters)
packetBytes.AddRange(DataTypes.GetVarInt(parameter));
packetBytes.AddRange(dataTypes.GetNbtTag(new Dictionary<string, object>()));
}
}

View file

@ -0,0 +1,122 @@
using MinecraftClient.Scripting;
namespace MinecraftClient.Tests;
public sealed class McClientConnectionFailureTests
{
[Fact]
public void LoginRejectedClaimPreventsSyntheticConnectionLostFallback()
{
var lifecycle = new ConnectionAttemptLifecycle();
Assert.True(lifecycle.TryBeginDisconnect());
Assert.True(lifecycle.IsFailureClaimed);
Assert.False(lifecycle.TryBeginDisconnect());
lifecycle.CompleteDisconnect();
Assert.True(lifecycle.IsFailureClaimed);
Assert.False(lifecycle.TryBeginDisconnect());
}
[Fact]
public void UnclaimedGenericFailureCanBeClaimedExactlyOnce()
{
var lifecycle = new ConnectionAttemptLifecycle();
Assert.False(lifecycle.IsFailureClaimed);
Assert.True(lifecycle.TryBeginDisconnect());
Assert.False(lifecycle.TryBeginDisconnect());
}
[Fact]
public void HeldBotsAreRestoredBeforeFailureAndReceiveOriginalMessageOnce()
{
const string rejectionMessage = "You are not white-listed on this server!";
var bot = new RecordingBot();
List<ChatBot> heldBots = [bot];
var loadedBots = new List<ChatBot>();
ConnectionAttemptLifecycle.RestoreHeldBots(heldBots, loadedBots.Add);
foreach (ChatBot loadedBot in loadedBots)
loadedBot.OnDisconnect(ChatBot.DisconnectReason.LoginRejected, rejectionMessage);
Assert.Empty(heldBots);
Assert.Single(loadedBots);
Assert.Equal(1, bot.DisconnectCount);
Assert.Equal(ChatBot.DisconnectReason.LoginRejected, bot.LastReason);
Assert.Equal(rejectionMessage, bot.LastMessage);
}
[Fact]
public void OfflineRouteStaysOwnedAcrossReplacementAndSuccessfulHandoff()
{
var route = new AttemptOwnedRoute();
int activations = 0;
int deactivations = 0;
Assert.True(route.TryActivate(7, 7, () => activations++));
Assert.False(route.TryActivate(7, 7, () => activations++));
Assert.True(route.TryTransfer(7, 8));
Assert.False(route.TryDeactivate(7, () => deactivations++));
Assert.Equal(8, route.OwnerAttempt);
Assert.True(route.TryDeactivate(8, () => deactivations++));
Assert.Equal(1, activations);
Assert.Equal(1, deactivations);
Assert.Equal(-1, route.OwnerAttempt);
}
[Fact]
public void InitialConnectionAttemptCanOwnOfflineRoute()
{
var route = new AttemptOwnedRoute();
int activations = 0;
Assert.True(route.TryActivate(0, 0, () => activations++));
Assert.Equal(1, activations);
Assert.Equal(0, route.OwnerAttempt);
}
[Fact]
public void OlderAttemptCannotTakeAnEmptyOfflineRoute()
{
var route = new AttemptOwnedRoute();
int activations = 0;
Assert.False(route.TryActivate(4, 5, () => activations++));
Assert.Equal(0, activations);
Assert.Equal(-1, route.OwnerAttempt);
}
[Fact]
public void StaleCleanupCannotClearNewerOfflineRoute()
{
var route = new AttemptOwnedRoute();
int deactivations = 0;
Assert.True(route.TryActivate(10, 10, () => { }));
Assert.True(route.TryActivate(11, 11, () => { }));
Assert.False(route.TryDeactivate(10, () => deactivations++));
Assert.Equal(11, route.OwnerAttempt);
Assert.Equal(0, deactivations);
}
private sealed class RecordingBot : ChatBot
{
internal int DisconnectCount { get; private set; }
internal DisconnectReason? LastReason { get; private set; }
internal string? LastMessage { get; private set; }
public override bool OnDisconnect(DisconnectReason reason, string message)
{
DisconnectCount++;
LastReason = reason;
LastMessage = message;
return false;
}
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MinecraftClient\MinecraftClient.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,302 @@
namespace MinecraftClient.Tests;
public sealed class RestartCoordinatorTests
{
[Fact]
public async Task PreparationCompletesBeforeRequestCanExecute()
{
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
bool prepared = false;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
(request, cancellationToken) =>
{
Assert.True(Volatile.Read(ref prepared));
Assert.True(coordinator.TryBeginCommit(request, out _));
completed.SetResult();
return Task.CompletedTask;
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(
new RestartRequest(1, TimeSpan.Zero, true),
() =>
{
Volatile.Write(ref prepared, true);
return true;
}));
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task RejectedPreparationDoesNotPublishOrAdvanceAttempt()
{
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int executions = 0;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
(request, cancellationToken) =>
{
Interlocked.Increment(ref executions);
Assert.True(coordinator.TryBeginCommit(request, out _));
completed.SetResult();
return Task.CompletedTask;
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.False(coordinator.TrySchedule(
new RestartRequest(2, TimeSpan.Zero, true),
() => false));
Assert.False(coordinator.HasScheduledRestart(2));
Assert.True(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true)));
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, executions);
}
[Fact]
public async Task FaultedSourceCleanupPreventsRestartExecution()
{
var failureReported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
int executions = 0;
using var coordinator = new RestartCoordinator(
(_, _) =>
{
Interlocked.Increment(ref executions);
return Task.CompletedTask;
},
exception => failureReported.SetResult(exception));
var cleanupFailure = new InvalidOperationException("cleanup failed");
Assert.True(coordinator.TrySchedule(new RestartRequest(
3,
TimeSpan.Zero,
true,
SourceCleanupCompletion: Task.FromException(cleanupFailure))));
Exception reportedException = await failureReported.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Same(cleanupFailure, reportedException);
Assert.Equal(0, executions);
}
[Fact]
public async Task AutomaticSameAttemptIsCoalescedWhileQueued()
{
var blockerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseBlocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var queuedCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int queuedExecutions = 0;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
if (request.ConnectionAttempt == 9)
{
blockerStarted.SetResult();
await releaseBlocker.Task.WaitAsync(cancellationToken);
return;
}
Interlocked.Increment(ref queuedExecutions);
Assert.True(coordinator.TryBeginCommit(request, out _));
queuedCompleted.SetResult();
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(9, TimeSpan.Zero, true)));
await blockerStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
Assert.False(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
Assert.True(coordinator.HasScheduledRestart(10));
releaseBlocker.SetResult();
await queuedCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, queuedExecutions);
}
[Fact]
public async Task AutomaticSameAttemptIsCoalescedDuringCallback()
{
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int executions = 0;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
Interlocked.Increment(ref executions);
callbackStarted.SetResult();
await releaseCallback.Task.WaitAsync(cancellationToken);
Assert.True(coordinator.TryBeginCommit(request, out _));
callbackCompleted.SetResult();
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(42, TimeSpan.Zero, true)));
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(coordinator.TrySchedule(new RestartRequest(42, TimeSpan.Zero, true)));
releaseCallback.SetResult();
await callbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, executions);
}
[Fact]
public async Task ExplicitReplacementDuringDelayUsesLatestSnapshotWithoutAnotherExecution()
{
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var allowCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
RestartRequest committedRequest = default;
int executions = 0;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
Interlocked.Increment(ref executions);
callbackStarted.SetResult();
await allowCommit.Task.WaitAsync(cancellationToken);
Assert.True(coordinator.TryBeginCommit(request, out committedRequest));
callbackCompleted.SetResult();
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(
50,
TimeSpan.FromSeconds(10),
true,
CreateSettingsSnapshot("first"))));
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(coordinator.TrySchedule(new RestartRequest(
50,
TimeSpan.Zero,
true,
CreateSettingsSnapshot("replacement"),
ReplaceUntilCommit: true)));
allowCommit.SetResult();
await callbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, executions);
Assert.Equal("replacement", committedRequest.SettingsSnapshot?.Account.Login);
}
[Fact]
public async Task RejectsSameAttemptReplacementAfterCommit()
{
var commitStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
RestartRequest committedRequest = default;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
Assert.True(coordinator.TryBeginCommit(request, out committedRequest));
commitStarted.SetResult();
await releaseCommit.Task.WaitAsync(cancellationToken);
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(
60,
TimeSpan.Zero,
true,
CreateSettingsSnapshot("committed"))));
await commitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(coordinator.TrySchedule(new RestartRequest(
60,
TimeSpan.Zero,
true,
CreateSettingsSnapshot("rejected"),
ReplaceUntilCommit: true)));
Assert.Equal("committed", committedRequest.SettingsSnapshot?.Account.Login);
releaseCommit.SetResult();
}
[Fact]
public void RejectsStaleAttempt()
{
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
(_, _) => Task.CompletedTask,
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
Assert.False(coordinator.TrySchedule(new RestartRequest(19, TimeSpan.Zero, true)));
}
[Fact]
public async Task RejectsCompletedAttempt()
{
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
(request, cancellationToken) =>
{
Assert.True(coordinator.TryBeginCommit(request, out _));
completed.SetResult();
return Task.CompletedTask;
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(SpinWait.SpinUntil(() => !coordinator.HasScheduledRestart(20), TimeSpan.FromSeconds(5)));
Assert.False(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
}
[Fact]
public async Task TerminalStopCancelsInFlightWorkAndRejectsFurtherRestarts()
{
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var coordinator = new RestartCoordinator(
async (_, cancellationToken) =>
{
callbackStarted.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
Assert.True(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true)));
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
coordinator.Stop();
Assert.False(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true)));
Assert.False(coordinator.HasScheduledRestart(1));
}
private static RestartSettingsSnapshot CreateSettingsSnapshot(string account)
{
return new RestartSettingsSnapshot(
new Settings.MainConfigHelper.MainConfig.AccountInfoConfig(account, "-"),
"localhost",
25565);
}
}

View file

@ -0,0 +1,34 @@
using System.Net;
using System.Net.Sockets;
using MinecraftClient.Protocol.Handlers;
namespace MinecraftClient.Tests;
public sealed class SocketWrapperTests
{
[Fact]
public async Task GracefulPeerCloseEndsReadInsteadOfSpinning()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
using var client = new TcpClient();
Task<TcpClient> acceptTask = listener.AcceptTcpClientAsync();
await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint);
using TcpClient peer = await acceptTask;
var wrapper = new SocketWrapper(client);
peer.Client.Shutdown(SocketShutdown.Both);
peer.Close();
Assert.True(SpinWait.SpinUntil(wrapper.HasDataAvailable, TimeSpan.FromSeconds(5)));
await Assert.ThrowsAsync<EndOfStreamException>(
() => Task.Run(() => wrapper.ReadDataRAW(1)).WaitAsync(TimeSpan.FromSeconds(5)));
}
finally
{
listener.Stop();
}
}
}

View file

@ -0,0 +1 @@
global using Xunit;

View file

@ -7,27 +7,95 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinecraftClient", "Minecraf
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleInteractive", "ConsoleInteractive\ConsoleInteractive\ConsoleInteractive\ConsoleInteractive.csproj", "{93DA4D71-EFAD-4493-BE21-A105AF663660}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DebugTools", "DebugTools", "{02313C6C-37F1-D66D-F235-6A4537C03113}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpStdioHarness", "DebugTools\MccMcpStdioHarness\MccMcpStdioHarness.csproj", "{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpWebPlayground", "DebugTools\MccMcpWebPlayground\MccMcpWebPlayground.csproj", "{5F620CF6-BC7D-449A-B779-2D51985059C6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinecraftClient.Tests", "MinecraftClient.Tests\MinecraftClient.Tests.csproj", "{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x64.ActiveCfg = Debug|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x64.Build.0 = Debug|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x86.ActiveCfg = Debug|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x86.Build.0 = Debug|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|Any CPU.Build.0 = Release|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x64.ActiveCfg = Release|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x64.Build.0 = Release|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x86.ActiveCfg = Release|Any CPU
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x86.Build.0 = Release|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x64.ActiveCfg = Debug|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x64.Build.0 = Debug|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x86.ActiveCfg = Debug|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x86.Build.0 = Debug|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|Any CPU.Build.0 = Release|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x64.ActiveCfg = Release|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x64.Build.0 = Release|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x86.ActiveCfg = Release|Any CPU
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x86.Build.0 = Release|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x64.ActiveCfg = Debug|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x64.Build.0 = Debug|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x86.ActiveCfg = Debug|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x86.Build.0 = Debug|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|Any CPU.Build.0 = Release|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x64.ActiveCfg = Release|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x64.Build.0 = Release|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x86.ActiveCfg = Release|Any CPU
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x86.Build.0 = Release|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x64.ActiveCfg = Debug|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x64.Build.0 = Debug|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x86.ActiveCfg = Debug|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x86.Build.0 = Debug|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|Any CPU.Build.0 = Release|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.ActiveCfg = Release|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.Build.0 = Release|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.ActiveCfg = Release|Any CPU
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.Build.0 = Release|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x64.ActiveCfg = Debug|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x64.Build.0 = Debug|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x86.ActiveCfg = Debug|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x86.Build.0 = Debug|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|Any CPU.Build.0 = Release|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x64.ActiveCfg = Release|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x64.Build.0 = Release|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x86.ActiveCfg = Release|Any CPU
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6} = {02313C6C-37F1-D66D-F235-6A4537C03113}
{5F620CF6-BC7D-449A-B779-2D51985059C6} = {02313C6C-37F1-D66D-F235-6A4537C03113}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
RESX_ShowErrorsInErrorList = False
SolutionGuid = {6DED60F4-9CF4-4DB3-8966-582B2EBE8487}
RESX_ShowErrorsInErrorList = False
RESX_SortFileContentOnSave = False
EndGlobalSection
EndGlobal

View file

@ -127,7 +127,7 @@ namespace MinecraftClient.ChatBots
private void DoAntiAfkStuff()
{
var isMovementLocked = BotMovementLock.Instance;
if (Config.Use_Terrain_Handling && GetTerrainEnabled() && isMovementLocked is {IsLocked: false})
if (Config.Use_Terrain_Handling && GetTerrainEnabled() && isMovementLocked is { IsLocked: false })
{
var currentLocation = GetCurrentLocation();

View file

@ -28,7 +28,7 @@ namespace MinecraftClient.ChatBots
public PriorityType Priority = PriorityType.distance;
[TomlInlineComment("$ChatBot.AutoAttack.Cooldown_Time$")]
public CooldownConfig Cooldown_Time = new(false, 1.0);
public CooldownConfig Cooldown_Time = new();
[TomlInlineComment("$ChatBot.AutoAttack.Interaction$")]
public InteractType Interaction = InteractType.Attack;
@ -50,10 +50,19 @@ namespace MinecraftClient.ChatBots
public void OnSettingUpdate()
{
if (Cooldown_Time.Custom && Cooldown_Time.value <= 0)
if (Cooldown_Time.Custom)
{
LogToConsole(BotName, Translations.bot_autoAttack_invalidcooldown);
Cooldown_Time.value = 1.0;
if (Cooldown_Time.Min <= 0)
Cooldown_Time.Min = 0.1;
if (Cooldown_Time.Max <= 0)
Cooldown_Time.Max = 0.1;
if (Cooldown_Time.Min > Cooldown_Time.Max)
{
double temp = Cooldown_Time.Min;
Cooldown_Time.Min = Cooldown_Time.Max;
Cooldown_Time.Max = temp;
}
}
if (Attack_Range < 1.0)
@ -72,24 +81,16 @@ namespace MinecraftClient.ChatBots
public struct CooldownConfig
{
public bool Custom;
public double value;
public bool RandomMode = false;
public double Min = 1.5;
public double Max = 2.5;
public CooldownConfig()
{
Custom = false;
value = 0;
}
public CooldownConfig(double value)
{
Custom = true;
this.value = value;
}
public CooldownConfig(bool Override, double value)
{
this.Custom = Override;
this.value = value;
RandomMode = false;
Min = 1.5;
Max = 2.5;
}
}
}
@ -105,13 +106,14 @@ namespace MinecraftClient.ChatBots
private float health = 100;
private readonly bool attackHostile = true;
private readonly bool attackPassive = false;
private readonly Random _random = new();
public AutoAttack()
{
overrideAttackSpeed = Config.Cooldown_Time.Custom;
if (Config.Cooldown_Time.Custom)
{
attackCooldownSeconds = Config.Cooldown_Time.value;
attackCooldownSeconds = Config.Cooldown_Time.Min;
attackCooldown = SecondsToAttackCooldownTicks(attackCooldownSeconds);
}
@ -137,6 +139,12 @@ namespace MinecraftClient.ChatBots
if (attackCooldownCounter == 0)
{
if (Config.Cooldown_Time.Custom && Config.Cooldown_Time.RandomMode)
{
double randomSeconds = _random.NextDouble() * (Config.Cooldown_Time.Max - Config.Cooldown_Time.Min) + Config.Cooldown_Time.Min;
attackCooldown = SecondsToAttackCooldownTicks(randomSeconds);
}
attackCooldownCounter = attackCooldown;
if (entitiesToAttack.Count > 0)
{
@ -177,6 +185,8 @@ namespace MinecraftClient.ChatBots
InteractEntity(priorityEntity, Config.Interaction); // hit the entity!
SendAnimation(Inventory.Hand.MainHand); // Arm animation
}
}
}
else
@ -188,6 +198,7 @@ namespace MinecraftClient.ChatBots
{
InteractEntity(entity.Key, Config.Interaction); // hit the entity!
}
}
SendAnimation(Inventory.Hand.MainHand); // Arm animation
}

View file

@ -30,6 +30,12 @@ namespace MinecraftClient.ChatBots
[TomlInlineComment("$ChatBot.AutoDig.Auto_Tool_Switch$")]
public bool Auto_Tool_Switch = false;
[TomlInlineComment("$ChatBot.AutoDig.Apply_Efficiency_Enchantments$")]
public bool Apply_Efficiency_Enchantments = true;
[TomlInlineComment("$ChatBot.AutoDig.Apply_Haste_Effects$")]
public bool Apply_Haste_Effects = true;
[TomlInlineComment("$ChatBot.AutoDig.Durability_Limit$")]
public int Durability_Limit = 2;
@ -322,6 +328,15 @@ namespace MinecraftClient.ChatBots
return !IsBelowDurabilityLimit(currentTool);
}
private static MiningCalculator.MiningOptions GetMiningOptions()
{
return new MiningCalculator.MiningOptions
{
ApplyEfficiencyEnchantments = Config.Apply_Efficiency_Enchantments,
ApplyHasteEffects = Config.Apply_Haste_Effects
};
}
public override void Update()
{
lock (stateLock)
@ -393,7 +408,7 @@ namespace MinecraftClient.ChatBots
if (!EnsureSuitableTool(block.Type))
return false;
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: false))
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: false, miningOptions: GetMiningOptions()))
{
currentDig = blockLoc;
if (Config.Log_Block_Dig)
@ -457,7 +472,7 @@ namespace MinecraftClient.ChatBots
if (!EnsureSuitableTool(targetBlock.Type))
return false;
if (DigBlock(target, Direction.Down, lookAtBlock: true))
if (DigBlock(target, Direction.Down, lookAtBlock: true, miningOptions: GetMiningOptions()))
{
currentDig = target;
if (Config.Log_Block_Dig)
@ -494,7 +509,7 @@ namespace MinecraftClient.ChatBots
if (!EnsureSuitableTool(block.Type))
return false;
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: true))
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: true, miningOptions: GetMiningOptions()))
{
currentDig = blockLoc;
if (Config.Log_Block_Dig)

View file

@ -23,7 +23,7 @@ namespace MinecraftClient.ChatBots
public Range Delay = new(3);
[TomlInlineComment("$ChatBot.AutoRelog.Retries$")]
public int Retries = 3;
public int Retries = -1;
[TomlInlineComment("$ChatBot.AutoRelog.Ignore_Kick_Message$")]
public bool Ignore_Kick_Message = false;
@ -31,27 +31,27 @@ namespace MinecraftClient.ChatBots
[TomlPrecedingComment("$ChatBot.AutoRelog.Kick_Messages$")]
public string[] Kick_Messages = new string[] { "Connection has been lost", "Server is restarting", "Server is full", "Too Many people" };
[NonSerialized]
public static int _BotRecoAttempts = 0;
public void OnSettingUpdate()
{
Kick_Messages ??= Array.Empty<string>();
if (!double.IsFinite(Delay.min))
Delay.min = 0.1;
if (!double.IsFinite(Delay.max))
Delay.max = 0.1;
Delay.min = Math.Max(0.1, Delay.min);
Delay.max = Math.Max(0.1, Delay.max);
double maxDelaySeconds = int.MaxValue / (double)Settings.ClientTicksPerSecond;
double maxDelaySeconds = (uint.MaxValue - 1) / 1000D;
Delay.min = Math.Min(maxDelaySeconds, Delay.min);
Delay.max = Math.Min(maxDelaySeconds, Delay.max);
if (Delay.min > Delay.max)
(Delay.min, Delay.max) = (Delay.max, Delay.min);
if (Retries == -1)
Retries = int.MaxValue;
if (Enabled)
for (int i = 0; i < Kick_Messages.Length; i++)
Kick_Messages[i] = Kick_Messages[i].ToLower();
if (Retries < -1)
Retries = -1;
}
public struct Range
@ -77,7 +77,8 @@ namespace MinecraftClient.ChatBots
}
}
private static readonly Random random = new();
private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System);
private readonly long? sourceConnectionAttempt;
/// <summary>
/// This bot automatically re-join the server if kick message contains predefined string
@ -85,8 +86,13 @@ namespace MinecraftClient.ChatBots
/// <param name="DelayBeforeRelogMin">Minimum delay before re-joining the server (in seconds)</param>
/// <param name="DelayBeforeRelogMax">Maximum delay before re-joining the server (in seconds)</param>
/// <param name="retries">Number of retries if connection fails (-1 = infinite)</param>
public AutoRelog()
public AutoRelog() : this(null)
{
}
private AutoRelog(long? sourceConnectionAttempt)
{
this.sourceConnectionAttempt = sourceConnectionAttempt;
LogDebugToConsole(string.Format(Translations.bot_autoRelog_launch, Config.Retries));
}
@ -97,7 +103,12 @@ namespace MinecraftClient.ChatBots
public override void AfterGameJoined()
{
Configs._BotRecoAttempts = 0;
s_retryPolicy.MarkJoined();
}
public override void Update()
{
ResetRetriesAfterStableJoin();
}
private void _Initialize()
@ -114,59 +125,75 @@ namespace MinecraftClient.ChatBots
if (reason == DisconnectReason.UserLogout)
{
LogDebugToConsole(Translations.bot_autoRelog_ignore_user_logout);
return false;
}
else if (Config.Retries < 0 || Configs._BotRecoAttempts < Config.Retries)
message = GetVerbatim(message);
LogDebugToConsole(string.Format(Translations.bot_autoRelog_disconnect_msg, message));
if (!AutoRelogRetryPolicy.ShouldReconnect(
reason,
message,
Config.Ignore_Kick_Message,
Config.Kick_Messages,
out string? matchedMessage))
{
message = GetVerbatim(message);
string comp = message.ToLower();
LogDebugToConsole(string.Format(Translations.bot_autoRelog_disconnect_msg, message));
if (Config.Ignore_Kick_Message)
{
Configs._BotRecoAttempts++;
LaunchDelayedReconnection(null);
return true;
}
foreach (string msg in Config.Kick_Messages)
{
if (comp.Contains(msg))
{
Configs._BotRecoAttempts++;
LaunchDelayedReconnection(msg);
return true;
}
}
LogDebugToConsole(Translations.bot_autoRelog_reconnect_ignore);
return false;
}
return false;
return LaunchDelayedReconnection(matchedMessage);
}
private void LaunchDelayedReconnection(string? msg)
private static void ResetRetriesAfterStableJoin()
{
double delay = random.NextDouble() * (Config.Delay.max - Config.Delay.min) + Config.Delay.min;
if (s_retryPolicy.ResetAfterStableConnection())
McClient.ReconnectionAttemptsLeft = Config.Retries;
}
private static bool HasUnlimitedRetries()
{
return Config.Retries == -1;
}
private bool LaunchDelayedReconnection(string? msg)
{
if (!s_retryPolicy.TryReserveAttempt(Config.Retries, out int retriesLeft))
return false;
double delay = Random.Shared.NextDouble() * (Config.Delay.max - Config.Delay.min) + Config.Delay.min;
LogDebugToConsole(string.Format(string.IsNullOrEmpty(msg) ? Translations.bot_autoRelog_reconnect_always : Translations.bot_autoRelog_reconnect, msg));
int retriesLeft = Config.Retries - Configs._BotRecoAttempts;
if (retriesLeft < 0)
retriesLeft = 0;
string retriesDisplay = Config.Retries == int.MaxValue
string retriesDisplay = HasUnlimitedRetries()
? Translations.bot_autoRelog_retries_unlimited
: retriesLeft.ToString();
LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay));
ReconnectToTheServer(retriesLeft, (int)Math.Floor(delay), true);
McClient.ReconnectionAttemptsLeft = retriesLeft;
long connectionAttempt = sourceConnectionAttempt ?? Handler.ConnectionAttempt;
if (Program.TryRestart(
connectionAttempt,
TimeSpan.FromSeconds(delay),
keepAccountAndServerSettings: true,
sourceCleanupCompletion: sourceConnectionAttempt.HasValue ? null : Handler.DisconnectCompletion))
{
LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay));
return true;
}
s_retryPolicy.RollBackReservedAttempt();
return Program.HasRestartPending(connectionAttempt);
}
public static bool OnDisconnectStatic(DisconnectReason reason, string message)
{
return OnDisconnectStatic(reason, message, Program.CurrentConnectionAttempt);
}
internal static bool OnDisconnectStatic(DisconnectReason reason, string message, long sourceConnectionAttempt)
{
if (Config.Enabled)
{
AutoRelog bot = new();
AutoRelog bot = new(sourceConnectionAttempt);
bot.Initialize();
return bot.OnDisconnect(reason, message);
}

View file

@ -0,0 +1,108 @@
using System;
using System.Threading;
using MinecraftClient.Scripting;
namespace MinecraftClient.ChatBots
{
internal sealed class AutoRelogRetryPolicy
{
internal static readonly TimeSpan StableConnectionThreshold = TimeSpan.FromSeconds(60);
private readonly Lock stateLock = new();
private readonly TimeProvider timeProvider;
private int attempts;
private DateTimeOffset? joinedAt;
internal AutoRelogRetryPolicy(TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(timeProvider);
this.timeProvider = timeProvider;
}
internal int Attempts
{
get
{
lock (stateLock)
return attempts;
}
}
internal void MarkJoined()
{
lock (stateLock)
joinedAt = timeProvider.GetUtcNow();
}
internal bool ResetAfterStableConnection()
{
lock (stateLock)
{
if (attempts == 0 || joinedAt is not DateTimeOffset connectionStart)
return false;
if (timeProvider.GetUtcNow() - connectionStart < StableConnectionThreshold)
return false;
attempts = 0;
joinedAt = null;
return true;
}
}
internal bool TryReserveAttempt(int retryLimit, out int retriesLeft)
{
lock (stateLock)
{
bool unlimited = retryLimit == -1;
if (!unlimited && attempts >= retryLimit)
{
retriesLeft = 0;
return false;
}
attempts++;
joinedAt = null;
retriesLeft = unlimited ? -1 : Math.Max(0, retryLimit - attempts);
return true;
}
}
internal void RollBackReservedAttempt()
{
lock (stateLock)
{
if (attempts > 0)
attempts--;
}
}
internal static bool ShouldReconnect(
ChatBot.DisconnectReason reason,
string message,
bool ignoreKickMessage,
ReadOnlySpan<string> kickMessages,
out string? matchedMessage)
{
matchedMessage = null;
if (reason == ChatBot.DisconnectReason.UserLogout)
return false;
if (reason == ChatBot.DisconnectReason.ConnectionLost || ignoreKickMessage)
return true;
foreach (string candidate in kickMessages)
{
if (!string.IsNullOrEmpty(candidate)
&& message.Contains(candidate, StringComparison.OrdinalIgnoreCase))
{
matchedMessage = candidate;
return true;
}
}
return false;
}
}
}

View file

@ -73,7 +73,7 @@ namespace MinecraftClient.ChatBots
public override void Initialize()
{
if (GetProtocolVersion() < Protocol18Handler.MC_1_13_Version)
if (GetProtocolVersion() < Protocol18Handler.MC_1_8_Version)
{
LogToConsole(Translations.bot_farmer_not_implemented);
return;
@ -149,6 +149,10 @@ namespace MinecraftClient.ChatBots
if (running)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.bot_farmer_already_running);
if (!IsCropAvailableForProtocol(whatToFarm, GetProtocolVersion()))
return r.SetAndReturn(CmdResult.Status.Fail,
string.Format(Translations.bot_farmer_crop_unavailable, whatToFarm, "1.9"));
var movementLock = BotMovementLock.Instance;
if (movementLock is { IsLocked: true })
return r.SetAndReturn(CmdResult.Status.Fail,
@ -369,11 +373,11 @@ namespace MinecraftClient.ChatBots
break;
}
var loc = new Location(Math.Floor(location.X), Math.Floor(location2.Y),
var loc = new Location(Math.Floor(location.X), Math.Floor(location.Y),
Math.Floor(location.Z));
LogDebug("Sending placeblock to: " + loc);
SendPlaceBlock(loc, Direction.Up);
SendPlaceBlock(loc, Direction.Up, lookAtBlock: true);
Thread.Sleep(300);
}
else LogDebug("Can't move to: " + location2);
@ -496,7 +500,7 @@ namespace MinecraftClient.ChatBots
{
// TODO: Do a check if the carrot/potato is on the first growth stage
// if so, use: new Location(location.X, (double)(location.Y - 1) + (double)0.93750, location.Z)
SendPlaceBlock(location2, Direction.Down);
SendPlaceBlock(location2, Direction.Down, lookAtBlock: true);
}
Thread.Sleep(100);
@ -591,6 +595,15 @@ namespace MinecraftClient.ChatBots
};
}
private static bool IsCropAvailableForProtocol(CropType type, int protocolVersion)
{
return type switch
{
CropType.Beetroot => protocolVersion >= Protocol18Handler.MC_1_9_Version,
_ => true
};
}
private List<Location> FindEmptyFarmland(int radius)
{
return GetWorld()
@ -616,16 +629,19 @@ namespace MinecraftClient.ChatBots
if (fullyGrown && material is Material.Melon or Material.Pumpkin)
return true;
var isFullyGrown = IsCropFullyGrown(GetWorld().GetBlock(location), cropType);
var isFullyGrown = IsCropFullyGrown(GetWorld().GetBlock(location), cropType, location);
return fullyGrown ? isFullyGrown : !isFullyGrown;
})
.ToList();
}
private bool IsCropFullyGrown(Block block, CropType cropType)
private bool IsCropFullyGrown(Block block, CropType cropType, Location? location = null)
{
var protocolVersion = GetProtocolVersion();
if (protocolVersion < Protocol18Handler.MC_1_13_Version)
return IsLegacyCropFullyGrown(block, cropType, location);
switch (cropType)
{
case CropType.Beetroot:
@ -781,6 +797,44 @@ namespace MinecraftClient.ChatBots
return false;
}
private bool IsLegacyCropFullyGrown(Block block, CropType cropType, Location? location)
{
return cropType switch
{
CropType.Beetroot => block.BlockId == 207 && block.BlockMeta >= 3,
CropType.Carrot => block.BlockId == 141 && block.BlockMeta >= 7,
CropType.Melon => block.BlockId == 105
&& (block.BlockMeta >= 7 || HasAdjacentBlock(location, Material.Melon)),
CropType.NetherWart => block.BlockId == 115 && block.BlockMeta >= 3,
CropType.Pumpkin => block.BlockId == 104
&& (block.BlockMeta >= 7 || HasAdjacentBlock(location, Material.Pumpkin)),
CropType.Potato => block.BlockId == 142 && block.BlockMeta >= 7,
CropType.Wheat => block.BlockId == 59 && block.BlockMeta >= 7,
_ => false
};
}
private bool HasAdjacentBlock(Location? location, Material material)
{
if (location is not Location stemLocation)
return false;
var world = GetWorld();
int x = (int)Math.Floor(stemLocation.X);
int y = (int)Math.Floor(stemLocation.Y);
int z = (int)Math.Floor(stemLocation.Z);
Location[] adjacentLocations =
[
new(x + 1, y, z),
new(x - 1, y, z),
new(x, y, z + 1),
new(x, y, z - 1)
];
return adjacentLocations.Any(adjacentLocation => world.GetBlock(adjacentLocation).Type == material);
}
// Yoinked from ReinforceZwei's AutoTree and adapted to search the whole of inventory in additon to the hotbar
private bool SwitchToItem(ItemType itemType)
{
@ -854,4 +908,4 @@ namespace MinecraftClient.ChatBots
else LogDebugToConsole(text);
}
}
}
}

View file

@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Threading;
using Brigadier.NET;
using Brigadier.NET.Builder;
using ImageMagick;
@ -11,6 +12,7 @@ using MinecraftClient.CommandHandler;
using MinecraftClient.CommandHandler.Patch;
using MinecraftClient.Mapping;
using MinecraftClient.Scripting;
using MinecraftClient.Tui;
using Tomlet.Attributes;
namespace MinecraftClient.ChatBots
@ -142,7 +144,12 @@ namespace MinecraftClient.ChatBots
SaveToFile(map);
if (Config.Render_In_Console)
RenderInConsole(map);
{
if (ConsoleIO.Backend is TuiConsoleBackend)
RenderInTui(map);
else
RenderInConsole(map);
}
return r.SetAndReturn(CmdResult.Status.Done);
}
@ -213,7 +220,12 @@ namespace MinecraftClient.ChatBots
SaveToFile(map);
if (Config.Render_In_Console)
RenderInConsole(map);
{
if (ConsoleIO.Backend is TuiConsoleBackend)
RenderInTui(map);
else
RenderInConsole(map);
}
}
}
@ -342,6 +354,24 @@ namespace MinecraftClient.ChatBots
}
}
private static void RenderInTui(McMap map)
{
var view = TuiConsoleBackend.Instance?.GetView();
if (view is null)
return;
Dispatcher.UIThread.Post(() =>
{
if (view.HasOverlay && view.OverlayContent is MapOverlay existing)
{
existing.UpdateMap(map);
return;
}
view.ShowOverlay(new MapOverlay(map));
});
}
private static void RenderInConsole(McMap map)
{
StringBuilder sb = new();
@ -447,109 +477,62 @@ namespace MinecraftClient.ChatBots
public DateTime LastUpdated { get; set; }
}
internal class MapColors
/// <summary>
/// Map packet base color palette. Colors are loaded from the embedded
/// MinimapBlockColors.json resource (map_palette section) generated by
/// tools/gen_block_color_map.py, which parses MapColor.java.
/// </summary>
internal static class MapColors
{
// When colors are updated in a new update, you can get them using the game code: net\minecraft\world\level\material\MaterialColor.java
public static Dictionary<byte, byte[]> Colors = new()
private static readonly Dictionary<byte, byte[]> Colors;
private static readonly byte[] ShadeMultipliers = [180, 220, 255, 135];
static MapColors()
{
//Color ID R G B
{0, new byte[]{0, 0, 0}},
{1, new byte[]{127, 178, 56}},
{2, new byte[]{247, 233, 163}},
{3, new byte[]{199, 199, 199}},
{4, new byte[]{255, 0, 0}},
{5, new byte[]{160, 160, 255}},
{6, new byte[]{167, 167, 167}},
{7, new byte[]{0, 124, 0}},
{8, new byte[]{255, 255, 255}},
{9, new byte[]{164, 168, 184}},
{10, new byte[]{151, 109, 77}},
{11, new byte[]{112, 112, 112}},
{12, new byte[]{64, 64, 255}},
{13, new byte[]{143, 119, 72}},
{14, new byte[]{255, 252, 245}},
{15, new byte[]{216, 127, 51}},
{16, new byte[]{178, 76, 216}},
{17, new byte[]{102, 153, 216}},
{18, new byte[]{229, 229, 51}},
{19, new byte[]{127, 204, 25}},
{20, new byte[]{242, 127, 165}},
{21, new byte[]{76, 76, 76}},
{22, new byte[]{153, 153, 153}},
{23, new byte[]{76, 127, 153}},
{24, new byte[]{127, 63, 178}},
{25, new byte[]{51, 76, 178}},
{26, new byte[]{102, 76, 51}},
{27, new byte[]{102, 127, 51}},
{28, new byte[]{153, 51, 51}},
{29, new byte[]{25, 25, 25}},
{30, new byte[]{250, 238, 77}},
{31, new byte[]{92, 219, 213}},
{32, new byte[]{74, 128, 255}},
{33, new byte[]{0, 217, 58}},
{34, new byte[]{129, 86, 49}},
{35, new byte[]{112, 2, 0}},
{36, new byte[]{209, 177, 161}},
{37, new byte[]{159, 82, 36}},
{38, new byte[]{149, 87, 108}},
{39, new byte[]{112, 108, 138}},
{40, new byte[]{186, 133, 36}},
{41, new byte[]{103, 117, 53}},
{42, new byte[]{160, 77, 78}},
{43, new byte[]{57, 41, 35}},
{44, new byte[]{135, 107, 98}},
{45, new byte[]{87, 92, 92}},
{46, new byte[]{122, 73, 88}},
{47, new byte[]{76, 62, 92}},
{48, new byte[]{76, 50, 35}},
{49, new byte[]{76, 82, 42}},
{50, new byte[]{142, 60, 46}},
{51, new byte[]{37, 22, 16}},
{52, new byte[]{189, 48, 49}},
{53, new byte[]{148, 63, 97}},
{54, new byte[]{92, 25, 29}},
{55, new byte[]{22, 126, 134}},
{56, new byte[]{58, 142, 140}},
{57, new byte[]{86, 44, 62}},
{58, new byte[]{20, 180, 133}},
{59, new byte[]{100, 100, 100}},
{60, new byte[]{216, 175, 147}},
{61, new byte[]{127, 167, 150}}
};
Colors = new Dictionary<byte, byte[]>();
try
{
using var stream = System.Reflection.Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MinimapBlockColors.json");
if (stream is not null)
{
using var doc = System.Text.Json.JsonDocument.Parse(stream);
if (doc.RootElement.TryGetProperty("map_palette", out var palette))
{
foreach (var prop in palette.EnumerateObject())
{
if (!byte.TryParse(prop.Name, out byte id))
continue;
var arr = prop.Value;
Colors[id] = [
arr[0].GetByte(),
arr[1].GetByte(),
arr[2].GetByte()
];
}
}
}
}
catch (Exception ex)
{
ConsoleIO.WriteLogLine($"[Map] Failed to load map palette: {ex.Message}");
}
}
public static ColorRGBA ColorByteToRGBA(byte receivedColorId)
{
// Divide received color id by 4 to get the base color id
// Much thanks to DevBobcorn
byte baseColorId = (byte)(receivedColorId >> 2);
// Any new colors that we haven't added will be purple like in the missing CS: Source Texture
if (!Colors.ContainsKey(baseColorId))
if (!Colors.TryGetValue(baseColorId, out byte[]? rgb))
return new(248, 0, 248, 255, true);
byte shadeId = (byte)(receivedColorId % 4);
byte shadeMultiplier = 255;
switch (shadeId)
{
case 0:
shadeMultiplier = 180;
break;
case 1:
shadeMultiplier = 220;
break;
case 3:
// NOTE: If we ever add map support below 1.8, this needs to be 220 before 1.8
shadeMultiplier = 135;
break;
}
byte multiplier = ShadeMultipliers[receivedColorId & 3];
return new(
r: (byte)((Colors[baseColorId][0] * shadeMultiplier) / 255),
g: (byte)((Colors[baseColorId][1] * shadeMultiplier) / 255),
b: (byte)((Colors[baseColorId][2] * shadeMultiplier) / 255),
r: (byte)(rgb[0] * multiplier / 255),
g: (byte)(rgb[1] * multiplier / 255),
b: (byte)(rgb[2] * multiplier / 255),
a: 255
);
}

View file

@ -0,0 +1,272 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Mcp;
using MinecraftClient.Scripting;
using Tomlet.Attributes;
namespace MinecraftClient.ChatBots
{
public class McpServer : ChatBot
{
public static Configs Config = new();
[TomlDoNotInlineObject]
public class Configs
{
[NonSerialized]
private const string BotName = "McpServer";
[TomlInlineComment("$ChatBot.McpServer.Enabled$")]
public bool Enabled = false;
[TomlPrecedingComment("$ChatBot.McpServer.Transport$")]
public MccMcpTransportConfig Transport = new();
[TomlPrecedingComment("$ChatBot.McpServer.Capabilities$")]
public MccMcpCapabilityToggles Capabilities = new();
public void OnSettingUpdate()
{
Transport ??= new MccMcpTransportConfig();
Capabilities ??= new MccMcpCapabilityToggles();
if (Transport.Port is < 1 or > 65535)
Transport.Port = 33333;
if (string.IsNullOrWhiteSpace(Transport.BindHost))
Transport.BindHost = "127.0.0.1";
if (string.IsNullOrWhiteSpace(Transport.Route))
Transport.Route = "/mcp";
if (!Transport.Route.StartsWith('/'))
Transport.Route = "/" + Transport.Route;
if (string.IsNullOrWhiteSpace(Transport.AuthTokenEnvVar))
Transport.AuthTokenEnvVar = "MCC_MCP_AUTH_TOKEN";
}
}
private MccEmbeddedMcpHost? host;
public override void Initialize()
{
Config.OnSettingUpdate();
}
public override void AfterGameJoined()
{
if (!Config.Enabled)
return;
ClearStores();
MccMcpConfig mcpConfig = new()
{
Enabled = Config.Enabled,
Transport = Config.Transport,
Capabilities = Config.Capabilities
};
host ??= new MccEmbeddedMcpHost(mcpConfig, new MccMcpCapabilities(() => Config.Capabilities));
if (host.IsRunning)
return;
LogToConsole(Translations.bot_mcpserver_starting);
if (!host.Start(out string? error))
{
if (error == "missing_auth_token")
LogToConsole(string.Format(Translations.bot_mcpserver_missing_auth_token, Config.Transport.AuthTokenEnvVar));
LogToConsole(string.Format(Translations.bot_mcpserver_start_failed, error ?? "unknown"));
return;
}
LogToConsole(string.Format(Translations.bot_mcpserver_started, host.Endpoint));
}
public override bool OnDisconnect(DisconnectReason reason, string message)
{
MccObservedStateStore.AddRecentEvent("disconnect", new
{
reason = reason.ToString(),
message
});
StopHost();
ClearStores();
return false;
}
public override void OnUnload()
{
StopHost();
ClearStores();
}
public override void GetText(string text, string? json)
{
string clean = GetVerbatim(text);
if (string.IsNullOrWhiteSpace(clean))
return;
string kind = "system";
string? sender = null;
string? message = null;
string parsedMessage = string.Empty;
string parsedSender = string.Empty;
if (IsPrivateMessage(clean, ref parsedMessage, ref parsedSender))
{
kind = "private";
sender = parsedSender;
message = parsedMessage;
}
else if (IsChatMessage(clean, ref parsedMessage, ref parsedSender))
{
kind = "chat";
sender = parsedSender;
message = parsedMessage;
}
MccObservedStateStore.AddChatHistoryEntry(new MccChatHistoryEntry
{
TimestampUtc = DateTimeOffset.UtcNow,
Kind = kind,
Text = clean,
Sender = sender,
Message = message,
Json = json
});
}
public override void OnTimeUpdate(long WorldAge, long TimeOfDay)
{
MccObservedStateStore.SetTime(WorldAge, TimeOfDay);
}
public override void OnRainLevelChange(float level)
{
MccObservedStateStore.SetRainLevel(level);
MccObservedStateStore.AddRecentEvent("weather_rain", new { level });
}
public override void OnThunderLevelChange(float level)
{
MccObservedStateStore.SetThunderLevel(level);
MccObservedStateStore.AddRecentEvent("weather_thunder", new { level });
}
public override void OnDeath()
{
MccObservedStateStore.AddRecentEvent("death");
}
public override void OnRespawn()
{
MccObservedStateStore.AddRecentEvent("respawn");
}
public override void OnPlayerJoin(Guid uuid, string name)
{
MccObservedStateStore.AddRecentEvent("player_join", new
{
uuid,
name
});
}
public override void OnPlayerLeave(Guid uuid, string? name)
{
MccObservedStateStore.AddRecentEvent("player_leave", new
{
uuid,
name
});
}
public override void OnInventoryOpen(int inventoryId)
{
MccObservedStateStore.AddRecentEvent("inventory_open", new { inventoryId });
}
public override void OnInventoryClose(int inventoryId)
{
MccObservedStateStore.AddRecentEvent("inventory_close", new { inventoryId });
}
public override void OnTitle(int action, string titletext, string subtitletext, string actionbartext, int fadein, int stay, int fadeout, string json)
{
if (action == 2)
{
MccObservedStateStore.AddRecentEvent("actionbar", new
{
action,
text = actionbartext,
fadein,
stay,
fadeout,
json
});
return;
}
if (action is 0 or 1)
{
MccObservedStateStore.AddRecentEvent("title", new
{
action,
titleText = titletext,
subtitleText = subtitletext,
fadein,
stay,
fadeout,
json
});
}
}
public override void OnBlockBreakAnimation(Entity entity, Location location, byte stage)
{
MccObservedStateStore.AddRecentEvent("block_break_animation", new
{
entityId = entity.ID,
entityType = entity.Type.ToString(),
stage,
location = new
{
x = location.X,
y = location.Y,
z = location.Z
}
});
}
public override void OnEntityAnimation(Entity entity, byte animation)
{
MccObservedStateStore.AddRecentEvent("entity_animation", new
{
entityId = entity.ID,
entityType = entity.Type.ToString(),
animation,
name = entity.Name,
customName = entity.CustomName
});
}
private void StopHost()
{
if (host is null || !host.IsRunning)
return;
if (host.Stop(out string? error))
LogToConsole(Translations.bot_mcpserver_stopped);
else
LogToConsole(string.Format(Translations.bot_mcpserver_stop_failed, error ?? "unknown"));
}
private static void ClearStores()
{
MccObservedStateStore.ClearAll();
}
}
}

View file

@ -43,8 +43,7 @@ namespace MinecraftClient.ChatBots
public override void Initialize()
{
SetNetworkPacketEventEnabled(true);
replay = new ReplayHandler(GetProtocolVersion());
replay.MetaData.serverName = GetServerHost() + GetServerPort();
replay = new ReplayHandler(GetProtocolVersion(), $"{GetServerHost()}:{GetServerPort()}");
backupCounter = Settings.DoubleToTick(Config.Backup_Interval);
McClient.dispatcher.Register(l => l.Literal("help")
@ -68,6 +67,8 @@ namespace MinecraftClient.ChatBots
{
McClient.dispatcher.Unregister(CommandName);
McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName);
replay?.Dispose();
replay = null;
}
private int OnCommandHelp(CmdResult r, string? cmd)
@ -85,9 +86,9 @@ namespace MinecraftClient.ChatBots
{
try
{
if (replay!.RecordRunning)
if (replay is { RecordRunning: true })
{
replay.CreateBackupReplay(Path.Combine("replay_recordings", replay.GetReplayDefaultName()));
replay.CreateBackupReplay(Path.Combine(replay.ReplayFileDirectory, replay.GetReplayDefaultName()));
return r.SetAndReturn(CmdResult.Status.Done, Translations.bot_replayCapture_created);
}
else
@ -103,7 +104,7 @@ namespace MinecraftClient.ChatBots
{
try
{
if (replay!.RecordRunning)
if (replay is { RecordRunning: true })
{
replay.OnShutDown();
return r.SetAndReturn(CmdResult.Status.Done, Translations.bot_replayCapture_stopped);
@ -119,16 +120,16 @@ namespace MinecraftClient.ChatBots
public override void OnNetworkPacket(int packetID, List<byte> packetData, bool isLogin, bool isInbound)
{
replay!.AddPacket(packetID, packetData, isLogin, isInbound);
replay?.AddPacket(packetID, packetData, isLogin, isInbound);
}
public override void Update()
{
if (Config.Backup_Interval > 0 && replay!.RecordRunning)
if (Config.Backup_Interval > 0 && replay is { RecordRunning: true })
{
if (backupCounter <= 0)
{
replay.CreateBackupReplay(Path.Combine("recording_cache", "REPLAY_BACKUP.mcpr"));
replay.CreateBackupReplay(replay.GetBackupReplayPath());
backupCounter = Settings.DoubleToTick(Config.Backup_Interval);
}
else backupCounter--;
@ -137,7 +138,7 @@ namespace MinecraftClient.ChatBots
public override bool OnDisconnect(DisconnectReason reason, string message)
{
replay!.OnShutDown();
replay?.OnShutDown();
return base.OnDisconnect(reason, message);
}
}

View file

@ -25,6 +25,7 @@ namespace MinecraftClient.ChatBots
private bool csharp;
private Thread? thread;
private readonly Dictionary<string, object>? localVars;
private readonly string? scriptOwnerKey;
public Script(string filename)
{
@ -38,6 +39,13 @@ namespace MinecraftClient.ChatBots
this.localVars = localVars;
}
internal Script(string filename, string? ownername, Dictionary<string, object>? localVars, string? scriptOwnerKey)
: this(filename, ownername, localVars)
{
this.scriptOwnerKey = scriptOwnerKey;
SetScriptOwnerKey(scriptOwnerKey);
}
private void ParseArguments(string argstr)
{
List<string> args = new();
@ -86,7 +94,7 @@ namespace MinecraftClient.ChatBots
public static bool LookForScript(ref string filename)
{
//Automatically look in subfolders and try to add ".txt" file extension
char dir_slash = Path.DirectorySeparatorChar;
char dir_slash = Path.DirectorySeparatorChar;
string[] files = new string[]
{
filename,
@ -149,6 +157,12 @@ namespace MinecraftClient.ChatBots
}
}
public override bool OnDisconnect(DisconnectReason reason, string message)
{
UnloadBot();
return false;
}
public override void Update()
{
if (csharp) //C# compiled script
@ -160,7 +174,7 @@ namespace MinecraftClient.ChatBots
{
try
{
CSharpRunner.Run(this, lines, args, localVars, scriptName: file!);
CSharpRunner.Run(this, lines, args, localVars, scriptName: file!, scriptOwnerKey: scriptOwnerKey);
}
catch (CSharpException e)
{
@ -213,7 +227,7 @@ namespace MinecraftClient.ChatBots
.ToLower();
processedLine = string.Join("", processedLine.Split(default(string[]), StringSplitOptions.RemoveEmptyEntries));
var parts = processedLine.Contains("to") ? processedLine.Split("to") : processedLine.Split("-");
if (parts.Length == 2)
{
var min = Convert.ToInt32(parts[0]);
@ -224,10 +238,12 @@ namespace MinecraftClient.ChatBots
(min, max) = (max, min);
LogToConsole(Translations.cmd_wait_random_min_bigger);
}
ticks = new Random().Next(min, max);
} else ticks = Convert.ToInt32(instruction_line[5..]);
} else ticks = Convert.ToInt32(instruction_line[5..]);
}
else ticks = Convert.ToInt32(instruction_line[5..]);
}
else ticks = Convert.ToInt32(instruction_line[5..]);
}
catch { }
sleepticks = ticks;

View file

@ -183,64 +183,54 @@ namespace MinecraftClient.ChatBots
private int verifytasks_timeleft = Settings.ClientTicksPerSecond;
private readonly int verifytasks_delay = Settings.ClientTicksPerSecond;
public override void AfterGameJoined()
{
if (serverlogin_done)
return;
serverlogin_done = true;
verifytasks_timeleft = verifytasks_delay;
RunLoginTasks();
}
public override void Update()
{
if (!serverlogin_done)
return;
if (verifytasks_timeleft <= 0)
{
verifytasks_timeleft = verifytasks_delay;
if (serverlogin_done)
for (int taskIndex = 0; taskIndex < Config.TaskList.Length; taskIndex++)
{
foreach (TaskConfig task in Config.TaskList)
TaskConfig task = Config.TaskList[taskIndex];
if (task.Trigger_On_Times.Enable)
{
if (task.Trigger_On_Times.Enable)
{
bool matching_time_found = false;
bool matching_time_found = false;
foreach (TimeSpan time in task.Trigger_On_Times.Times)
foreach (TimeSpan time in task.Trigger_On_Times.Times)
{
if (time.Hours == DateTime.Now.Hour && time.Minutes == DateTime.Now.Minute)
{
if (time.Hours == DateTime.Now.Hour && time.Minutes == DateTime.Now.Minute)
matching_time_found = true;
if (!task.Trigger_On_Time_Already_Triggered)
{
matching_time_found = true;
if (!task.Trigger_On_Time_Already_Triggered)
{
task.Trigger_On_Time_Already_Triggered = true;
LogDebugToConsole(string.Format(Translations.bot_scriptScheduler_running_time, task.Action));
CmdResult response = new();
PerformInternalCommand(task.Action, ref response);
if (response.status != CmdResult.Status.Done || !string.IsNullOrWhiteSpace(response.result))
LogToConsole(response);
}
task.Trigger_On_Time_Already_Triggered = true;
RunTaskAction(task, taskIndex, string.Format(Translations.bot_scriptScheduler_running_time, task.Action));
}
}
if (!matching_time_found)
task.Trigger_On_Time_Already_Triggered = false;
}
if (!matching_time_found)
task.Trigger_On_Time_Already_Triggered = false;
}
}
else
{
foreach (TaskConfig task in Config.TaskList)
{
if (task.Trigger_On_Login || (firstlogin_done == false && task.Trigger_On_First_Login))
{
LogDebugToConsole(string.Format(Translations.bot_scriptScheduler_running_login, task.Action));
CmdResult response = new();
PerformInternalCommand(task.Action, ref response);
if (response.status != CmdResult.Status.Done || !string.IsNullOrWhiteSpace(response.result))
LogToConsole(response);
}
}
firstlogin_done = true;
serverlogin_done = true;
}
}
else verifytasks_timeleft--;
foreach (TaskConfig task in Config.TaskList)
for (int taskIndex = 0; taskIndex < Config.TaskList.Length; taskIndex++)
{
TaskConfig task = Config.TaskList[taskIndex];
if (task.Trigger_On_Interval.Enable)
{
if (task.Trigger_On_Interval_Countdown == 0)
@ -248,11 +238,7 @@ namespace MinecraftClient.ChatBots
task.Trigger_On_Interval_Countdown = random.Next(
Settings.DoubleToTick(task.Trigger_On_Interval.MinTime), Settings.DoubleToTick(task.Trigger_On_Interval.MaxTime)
);
LogDebugToConsole(string.Format(Translations.bot_scriptScheduler_running_inverval, task.Action));
CmdResult response = new();
PerformInternalCommand(task.Action, ref response);
if (response.status != CmdResult.Status.Done || !string.IsNullOrWhiteSpace(response.result))
LogToConsole(response);
RunTaskAction(task, taskIndex, string.Format(Translations.bot_scriptScheduler_running_inverval, task.Action));
}
else task.Trigger_On_Interval_Countdown--;
}
@ -265,6 +251,58 @@ namespace MinecraftClient.ChatBots
return false;
}
private void RunLoginTasks()
{
bool isFirstLogin = !firstlogin_done;
for (int taskIndex = 0; taskIndex < Config.TaskList.Length; taskIndex++)
{
TaskConfig task = Config.TaskList[taskIndex];
if (task.Trigger_On_Login || (isFirstLogin && task.Trigger_On_First_Login))
RunTaskAction(task, taskIndex, string.Format(Translations.bot_scriptScheduler_running_login, task.Action));
}
firstlogin_done = true;
}
private void RunTaskAction(TaskConfig task, int taskIndex, string debugMessage)
{
LogDebugToConsole(debugMessage);
if (TryRunOwnedScript(task, taskIndex))
return;
CmdResult response = new();
PerformInternalCommand(task.Action, ref response);
if (response.status != CmdResult.Status.Done || !string.IsNullOrWhiteSpace(response.result))
LogToConsole(response);
}
private bool TryRunOwnedScript(TaskConfig task, int taskIndex)
{
string action = task.Action.Trim();
const string scriptCommand = "script";
if (!action.StartsWith(scriptCommand, StringComparison.OrdinalIgnoreCase))
return false;
if (action.Length == scriptCommand.Length || !char.IsWhiteSpace(action[scriptCommand.Length]))
return false;
string scriptArgs = action[scriptCommand.Length..].Trim();
if (string.IsNullOrWhiteSpace(scriptArgs))
return false;
string scriptOwnerKey = BuildScriptOwnerKey(task, taskIndex);
Handler.UnloadBotsByScriptOwnerKey(scriptOwnerKey);
Handler.BotLoad(new Script(scriptArgs, null, null, scriptOwnerKey));
return true;
}
private static string BuildScriptOwnerKey(TaskConfig task, int taskIndex)
{
return $"{nameof(ScriptScheduler)}:{taskIndex}:{task.Task_Name}:{task.Action.Trim()}";
}
private static string Task2String(TaskConfig task)
{
return string.Format(

View file

@ -194,7 +194,7 @@ namespace MinecraftClient.ChatBots
else message = text;
SendMessage(message);
SendRawMessage(message);
}
public void SendMessage(string message)
@ -213,6 +213,22 @@ namespace MinecraftClient.ChatBots
}
}
public void SendRawMessage(string message)
{
if (!CanSendMessages() || string.IsNullOrEmpty(message))
return;
try
{
botClient!.SendMessage(Config.ChannelId.Trim(), message).Wait(Config.Message_Send_Timeout);
}
catch (Exception e)
{
LogToConsole("§§4§l§f" + Translations.bot_TelegramBridge_canceled_sending);
LogDebugToConsole(e);
}
}
public void SendImage(string filePath, string? text = null)
{
if (!CanSendMessages())
@ -352,7 +368,7 @@ namespace MinecraftClient.ChatBots
replyParameters: message.MessageId,
cancellationToken: _cancellationToken,
parseMode: ParseMode.Markdown);
return;;
return; ;
}
CmdResult result = new();

View file

@ -1,12 +1,81 @@
using System;
using System.Text.RegularExpressions;
namespace MinecraftClient
{
/// <summary>
/// Console backend wrapping the ConsoleInteractive library (existing behavior).
/// </summary>
public class ClassicConsoleBackend : IConsoleBackend
public partial class ClassicConsoleBackend : IConsoleBackend
{
private static readonly (byte R, byte G, byte B, char Code)[] McStandardColors =
[
(0, 0, 0, '0'), // black
(0, 0, 170, '1'), // dark_blue
(0, 170, 0, '2'), // dark_green
(0, 170, 170, '3'), // dark_aqua
(170, 0, 0, '4'), // dark_red
(170, 0, 170, '5'), // dark_purple
(255, 170, 0, '6'), // gold
(170, 170, 170, '7'), // gray
(85, 85, 85, '8'), // dark_gray
(85, 85, 255, '9'), // blue
(85, 255, 85, 'a'), // green
(85, 255, 255, 'b'), // aqua
(255, 85, 85, 'c'), // red
(255, 85, 255, 'd'), // light_purple
(255, 255, 85, 'e'), // yellow
(255, 255, 255, 'f'), // white
];
[GeneratedRegex("§#([0-9a-fA-F]{6})")]
private static partial Regex HexColorRegex();
private static char NearestMcColor(byte r, byte g, byte b)
{
int bestIdx = 0;
long bestDist = long.MaxValue;
for (int i = 0; i < McStandardColors.Length; i++)
{
var (sr, sg, sb, _) = McStandardColors[i];
long dr = r - sr;
long dg = g - sg;
long db = b - sb;
long dist = dr * dr + dg * dg + db * db;
if (dist < bestDist)
{
bestDist = dist;
bestIdx = i;
}
}
return McStandardColors[bestIdx].Code;
}
private static string ResolveHexColors(string text)
{
if (string.IsNullOrEmpty(text) || !text.Contains("§#", StringComparison.Ordinal))
return text;
return HexColorRegex().Replace(text, match =>
{
ReadOnlySpan<char> hex = match.Groups[1].ValueSpan;
byte r = (byte)((HexVal(hex[0]) << 4) | HexVal(hex[1]));
byte g = (byte)((HexVal(hex[2]) << 4) | HexVal(hex[3]));
byte b = (byte)((HexVal(hex[4]) << 4) | HexVal(hex[5]));
return ColorHelper.GetColorEscapeCode(r, g, b, foreground: true);
});
}
private static int HexVal(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => 0
};
public event EventHandler<string>? MessageReceived;
public event EventHandler<ConsoleInputBuffer>? OnInputChange;
@ -28,7 +97,7 @@ namespace MinecraftClient
public void WriteLineFormatted(string text)
{
ConsoleInteractive.ConsoleWriter.WriteLineFormatted(text);
ConsoleInteractive.ConsoleWriter.WriteLineFormatted(ResolveHexColors(text));
}
public void BeginReadThread()
@ -63,6 +132,12 @@ namespace MinecraftClient
ConsoleInteractive.ConsoleReader.ClearBuffer();
}
public void ClearScreen()
{
Console.Clear();
ConsoleInteractive.ConsoleSuggestion.ClearSuggestions();
}
public void SetInputVisible(bool visible)
{
ConsoleInteractive.ConsoleReader.SetInputVisible(visible);

View file

@ -1,4 +1,4 @@
using System;
using System;
using static MinecraftClient.Settings.ConsoleConfigHealper.ConsoleConfig;
namespace MinecraftClient
@ -100,9 +100,9 @@ namespace MinecraftClient
}
}
if (foreground)
return $"§{best_idx:X}";
return $"§{best_idx:x}";
else
return $"§§{best_idx:X}";
return $"§§{best_idx:x}";
}
case ConsoleColorModeType.vt100_4bit:

View file

@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Inventory;
using MinecraftClient.Tui;
namespace MinecraftClient.Commands
{
public class Book : Command
{
private const char PageDelimiter = '\f';
public override string CmdName => "book";
public override string CmdUsage => Translations.cmd_book_usage;
public override string CmdDesc => Translations.cmd_book_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
{
dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CmdName)
.Executes(r => GetUsage(r.Source, string.Empty))
.Then(l => l.Literal("read").Executes(r => GetUsage(r.Source, "read")))
.Then(l => l.Literal("write").Executes(r => GetUsage(r.Source, "write")))
.Then(l => l.Literal("edit").Executes(r => GetUsage(r.Source, "edit")))
.Then(l => l.Literal("sign").Executes(r => GetUsage(r.Source, "sign")))));
dispatcher.Register(l => l.Literal(CmdName)
.Then(l => l.Literal("read")
.Executes(r => ReadBook(r.Source, null))
.Then(l => l.Argument("Page", Arguments.Integer(min: 1))
.Executes(r => ReadBook(r.Source, Arguments.GetInteger(r, "Page")))))
.Then(l => l.Literal("write")
.Then(l => l.Literal("text")
.Then(l => l.Argument("Text", Arguments.GreedyString())
.Executes(r => WriteBook(r.Source, Arguments.GetString(r, "Text")))))
.Then(l => l.Literal("file")
.Then(l => l.Argument("Path", Arguments.GreedyString())
.Executes(r => WriteBookFromFile(r.Source, Arguments.GetString(r, "Path"))))))
.Then(l => l.Literal("edit")
.Executes(r => OpenEditor(r.Source))
.Then(l => l.Literal("page")
.Then(l => l.Argument("Page", Arguments.Integer(min: 1))
.Then(l => l.Argument("Text", Arguments.GreedyString())
.Executes(r => EditPage(r.Source, Arguments.GetInteger(r, "Page"), Arguments.GetString(r, "Text"))))))
.Then(l => l.Literal("insert")
.Then(l => l.Argument("Page", Arguments.Integer(min: 1))
.Then(l => l.Argument("Text", Arguments.GreedyString())
.Executes(r => InsertPage(r.Source, Arguments.GetInteger(r, "Page"), Arguments.GetString(r, "Text"))))))
.Then(l => l.Literal("delete")
.Then(l => l.Argument("Page", Arguments.Integer(min: 1))
.Executes(r => DeletePage(r.Source, Arguments.GetInteger(r, "Page"))))))
.Then(l => l.Literal("sign")
.Then(l => l.Argument("Title", Arguments.GreedyString())
.Executes(r => SignBook(r.Source, Arguments.GetString(r, "Title")))))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source, string.Empty))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))));
}
private int GetUsage(CmdResult r, string? cmd)
{
return r.SetAndReturn(cmd switch
{
"read" => Translations.cmd_book_help_read,
"write" => Translations.cmd_book_help_write,
"edit" => Translations.cmd_book_help_edit,
"sign" => Translations.cmd_book_help_sign,
_ => GetCmdDescTranslated()
});
}
private int ReadBook(CmdResult r, int? page)
{
McClient handler = CmdResult.currentHandler!;
if (!EnsureInventory(r, handler))
return -1;
if (!handler.TryGetHeldBookContent(out BookContent content))
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_not_holding_book);
if (page is null && BookTuiHost.TryOpen(handler, BookHand.Main, editable: false))
return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_tui_opened);
handler.Log.Info(FormatBook(content, page));
return r.SetAndReturn(CmdResult.Status.Done);
}
private int OpenEditor(CmdResult r)
{
McClient handler = CmdResult.currentHandler!;
if (!EnsureWritable(r, handler, out _, Translations.cmd_book_cannot_edit_signed))
return -1;
return BookTuiHost.TryOpen(handler, BookHand.Main, editable: true)
? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_tui_opened)
: r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_tui_required);
}
private int WriteBook(CmdResult r, string text)
{
McClient handler = CmdResult.currentHandler!;
if (!EnsureWritable(r, handler, out _, Translations.cmd_book_cannot_edit_signed))
return -1;
IReadOnlyList<string> pages = SplitPages(text);
if (!Validate(r, handler, pages, title: null))
return -1;
return handler.SendBookEdit(pages)
? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_write_sent)
: r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed);
}
private int WriteBookFromFile(CmdResult r, string path)
{
if (!File.Exists(path))
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_file_not_found, path));
return WriteBook(r, File.ReadAllText(path, Encoding.UTF8));
}
private int EditPage(CmdResult r, int page, string text)
{
McClient handler = CmdResult.currentHandler!;
if (!EnsureWritable(r, handler, out BookContent content, Translations.cmd_book_cannot_edit_signed))
return -1;
List<string> pages = content.Pages.ToList();
if (page > pages.Count)
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_out_of_range, page, pages.Count));
pages[page - 1] = DecodeInlineText(text);
if (!Validate(r, handler, pages, title: null))
return -1;
return handler.SendBookEdit(pages)
? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_edit_sent)
: r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed);
}
private int InsertPage(CmdResult r, int page, string text)
{
McClient handler = CmdResult.currentHandler!;
if (!EnsureWritable(r, handler, out BookContent content, Translations.cmd_book_cannot_edit_signed))
return -1;
List<string> pages = content.Pages.ToList();
if (page > pages.Count + 1)
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_out_of_range, page, pages.Count));
pages.Insert(page - 1, DecodeInlineText(text));
if (!Validate(r, handler, pages, title: null))
return -1;
return handler.SendBookEdit(pages)
? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_edit_sent)
: r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed);
}
private int DeletePage(CmdResult r, int page)
{
McClient handler = CmdResult.currentHandler!;
if (!EnsureWritable(r, handler, out BookContent content, Translations.cmd_book_cannot_edit_signed))
return -1;
List<string> pages = content.Pages.ToList();
if (page > pages.Count)
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_out_of_range, page, pages.Count));
pages.RemoveAt(page - 1);
if (pages.Count == 0)
pages.Add(string.Empty);
if (!Validate(r, handler, pages, title: null))
return -1;
return handler.SendBookEdit(pages)
? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_edit_sent)
: r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed);
}
private int SignBook(CmdResult r, string title)
{
McClient handler = CmdResult.currentHandler!;
if (!EnsureWritable(r, handler, out BookContent content, Translations.cmd_book_already_signed))
return -1;
string normalizedTitle = title.Trim();
if (!Validate(r, handler, content.Pages, normalizedTitle))
return -1;
return handler.SendBookEdit(content.Pages, normalizedTitle)
? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_sign_sent)
: r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed);
}
private static bool EnsureInventory(CmdResult r, McClient handler)
{
if (handler.GetInventoryEnabled())
return true;
r.SetAndReturn(CmdResult.Status.FailNeedInventory);
return false;
}
private static bool EnsureWritable(CmdResult r, McClient handler, out BookContent content, string signedBookMessage)
{
content = BookContent.EmptyWritable;
if (!EnsureInventory(r, handler))
return false;
Item? item = handler.GetHeldBook();
if (!BookContentHelper.IsWritableBook(item))
{
r.SetAndReturn(CmdResult.Status.Fail, GetWritableBookFailureMessage(item, signedBookMessage));
return false;
}
return BookContentHelper.TryRead(item, out content);
}
private static string GetWritableBookFailureMessage(Item? item, string signedBookMessage)
{
return BookContentHelper.TryRead(item, out BookContent content) && content.IsSigned
? signedBookMessage
: Translations.cmd_book_not_holding_writable;
}
private static IReadOnlyList<string> SplitPages(string text)
{
return BookContentHelper.NormalizePages(DecodeInlineText(text).Split(PageDelimiter));
}
private static string DecodeInlineText(string text)
{
return text.Replace("\\f", PageDelimiter.ToString(), StringComparison.Ordinal)
.Replace("\\n", "\n", StringComparison.Ordinal);
}
private static bool Validate(CmdResult r, McClient handler, IReadOnlyList<string> pages, string? title)
{
BookLimits limits = BookLimits.ForProtocol(handler.GetProtocolVersion());
if (pages.Count > limits.MaxPages)
{
r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_too_many_pages, pages.Count, limits.MaxPages));
return false;
}
for (int i = 0; i < pages.Count; i++)
{
if (pages[i].Length > limits.MaxPageLength)
{
r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_too_long, i + 1, pages[i].Length, limits.MaxPageLength));
return false;
}
}
if (title is not null && (title.Length == 0 || title.Length > limits.MaxTitleLength))
{
r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_title_invalid, limits.MaxTitleLength));
return false;
}
return true;
}
private static string FormatBook(BookContent content, int? page)
{
StringBuilder sb = new();
sb.AppendLine(content.IsSigned
? string.Format(Translations.cmd_book_header_signed, content.Title ?? string.Empty, content.Author ?? string.Empty)
: Translations.cmd_book_header_writable);
if (page is not null)
{
int index = page.Value - 1;
if (index < 0 || index >= content.Pages.Count)
return string.Format(Translations.cmd_book_page_out_of_range, page.Value, content.Pages.Count);
sb.AppendLine(string.Format(Translations.cmd_book_page_header, page.Value, content.Pages.Count));
sb.Append(content.Pages[index]);
return sb.ToString();
}
for (int i = 0; i < content.Pages.Count; i++)
{
sb.AppendLine(string.Format(Translations.cmd_book_page_header, i + 1, content.Pages.Count));
sb.AppendLine(content.Pages[i]);
}
return sb.ToString().TrimEnd();
}
}
}

View file

@ -0,0 +1,45 @@
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
namespace MinecraftClient.Commands
{
public class ClearConsole : Command
{
public override string CmdName => "clear-console";
public override string CmdUsage => "clear-console";
public override string CmdDesc => Translations.cmd_clear_console_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
{
dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CmdName)
.Executes(r => GetUsage(r.Source))
)
);
var clearConsole = dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => Execute(r.Source))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
);
dispatcher.Register(l => l.Literal("cc")
.Executes(r => Execute(r.Source))
.Redirect(clearConsole)
);
}
private int GetUsage(CmdResult result)
{
return result.SetAndReturn(GetCmdDescTranslated());
}
private int Execute(CmdResult result)
{
ConsoleIO.ClearConsole();
return result.SetAndReturn(CmdResult.Status.Done);
}
}
}

Some files were not shown because too many files have changed in this diff Show more