Skip to content

Commit 99e177f

Browse files
authored
Merge pull request #156 from tpwalke2/feature/tpwalke2/144-context
Feature/tpwalke2/144 context
2 parents 72d4280 + 742aa1c commit 99e177f

7 files changed

Lines changed: 675 additions & 26 deletions

File tree

AGENTS.md

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,19 @@ permission on PRs from forks by default in a public repo.
4646

4747
### Entry point and Minecraft hooks
4848

49-
`BlueMapSignMarkersMod` (`DedicatedServerModInitializer`) wires three lifecycle hooks:
50-
- `SERVER_STARTING``SignProvider.loadSigns(...)` reads the world's persisted `signs.json`
49+
`BlueMapSignMarkersMod` (`DedicatedServerModInitializer`) wires four lifecycle hooks:
50+
- `SERVER_STARTING``SignProvider.loadSigns(...)` reads the world's persisted, region-sharded sign storage
51+
(migrating a pre-sharding single `signs.json` on first boot after an upgrade)
5152
- `SERVER_STOPPING``SignProvider.saveSigns(...)` then `SignManager.stop()`
5253
- `BLOCK_ENTITY_LOAD` → for any loaded `SignBlockEntity`, calls `SignManager.addOrUpdate(...)`
54+
- `CHUNK_LOAD` → reconciles signs the mod's cache still knows about in a loading chunk against what's actually
55+
there; if a tracked sign's block is gone (e.g. its region file was deleted/regenerated externally while
56+
unloaded), calls `SignManager.remove(...)` for it. Addresses GitHub issue #110.
5357

54-
The markers file path is per-world: `config/bluemapsignmarkers/<world-save-name>/signs.json`.
58+
Sign state is stored per-world, region-sharded: one JSON file per (dimension, 32x32-chunk region) under
59+
`{server_root}/bluemapsignmarkers/{level}/{dimension_namespace}/{dimension_path}/r.{regionX}.{regionZ}.json`. A
60+
pre-sharding single `config/bluemapsignmarkers/<world-save-name>/signs.json` is migrated in place on first boot
61+
after upgrading (backed up, not deleted).
5562

5663
Two Mixins (`src/main/resources/bluemapsignmarkers.mixins.json`) catch the events the lifecycle hooks above can't:
5764
- `SignBlockEntityInject` injects into `SignBlockEntity.updateSignText` (a player edits a sign) → `SignManager.addOrUpdate`
@@ -62,13 +69,16 @@ Two Mixins (`src/main/resources/bluemapsignmarkers.mixins.json`) catch the event
6269

6370
1. **`SignHelper`** builds a `SignEntry` (immutable snapshot: position/dimension key, player id, parsed front/back
6471
text) from a `SignBlockEntity`, running sign text through a `SignLinesParser` configured with the current
65-
`MarkerGroup`s.
72+
`MarkerGroup`s. `SignHelper.reloadParser()` rebuilds that parser from the current config; it's called on every
73+
config reload (see below) so a sign parsed after `/bluemap reload` picks up an edited prefix/matchType.
6674
2. **`SignManager`** (singleton, holds a `ConcurrentMap<SignEntryKey, SignEntry>` cache of all known signs) is the
6775
decision point: given a new/updated `SignEntry`, it figures out whether this is an add, update, remove, or
6876
prefix-change (remove-then-add into a different marker group) relative to what's cached, then dispatches the
6977
corresponding `MarkerAction` (`AddMarkerAction`/`UpdateMarkerAction`/`RemoveMarkerAction`, built via
70-
`ActionFactory`) to the BlueMap connector. It also implements `IResetHandler.reset()` to replay the whole sign
71-
cache when BlueMap resets its state.
78+
`ActionFactory`) to the BlueMap connector. It also implements `IResetHandler.reset()`, which BlueMap fires on
79+
`/bluemap reload`: reloads config (`ConfigManager.reload()`, `SignHelper.reloadParser()`, rebuilding its
80+
prefix→group lookup and `ActionFactory`) then replays the whole sign cache through the same add/update/remove
81+
logic, so an edited marker-group's icon/offset/visibility/prefix takes effect without a server restart.
7282
3. **`BlueMapAPIConnector`** owns the `ReactiveQueue<MarkerAction>` and all actual BlueMap API calls. Because the
7383
BlueMap API is only available while BlueMap itself is enabled, actions are queued and only drained
7484
(`markerActionQueue.process()`) while `BlueMapAPI.getInstance().isPresent()`; `BlueMapAPI.onEnable`/`onDisable`
@@ -90,12 +100,17 @@ line to match the pattern (unlike `STARTS_WITH`, a regex prefix can't share its
90100

91101
### Sign persistence and versioning
92102

93-
Sign state is persisted per-world as `signs.json`, wrapped in a `VersionedSignFile` envelope (`{version, data}`) so
94-
the format can evolve without breaking old saves. `SignProvider.loadSigns` tries, in order: the versioned-file loader
95-
(`VersionedFileSignEntryLoader`, handling V2→V3 migration via `Version3Converter`, and current V3 files directly),
96-
then falls back to `Version1SignEntryLoader` for pre-versioning files. When adding a new persisted field, bump
103+
Sign state is stored per-world, region-sharded (one file per dimension + 32x32-chunk region — see "Entry point"
104+
above), with each region file wrapped in a `VersionedSignFile` envelope (`{version, data}`) so the format can evolve
105+
without breaking old saves. `SignProvider.loadSigns` checks whether the storage root already has region files
106+
(`RegionShardedSignEntryLoader.hasSignData`); if so, it loads every region file the same version-aware way as
107+
before sharding — the versioned-file loader (`VersionedFileSignEntryLoader`, handling V2→V3 migration via
108+
`Version3Converter`, and current V3 files directly), falling back to `Version1SignEntryLoader` for pre-versioning
109+
files. If no region files exist yet, `LegacySignFileMigrator` reads a pre-sharding single `signs.json` (if present)
110+
through that same version chain, writes it out region-sharded, and backs up the legacy file (renamed, not deleted)
111+
only once every expected region file is confirmed on disk. When adding a new persisted field, bump
97112
`SignFileVersions` and add a loader/converter rather than changing an existing version's shape in place — old
98-
`signs.json` files on live servers must keep loading.
113+
region files (or a not-yet-migrated legacy `signs.json`) on live servers must keep loading.
99114

100115
### Adding a new marker/BlueMap action
101116

@@ -107,15 +122,17 @@ compile.
107122
### Testable vs. game-coupled code
108123

109124
When adding logic, prefer keeping it in plain Java classes with no Minecraft/Fabric/BlueMap API types in their
110-
signature (like `SignLinesParser`, `SignEntryHelper`, `MarkerGroup`/`MarkerGroupMatchType`, `ConfigManager`/
111-
`ConfigProvider`, `ReactiveQueue`, the persistence loaders/converters, `ActionFactory`/`MarkerSetIdentifierCollection`)
125+
signature (like `SignLinesParser`/`ParsingContext`, `SignEntry`/`SignEntryHelper`, `SignChunkKey`/`SignChunkIndex`,
126+
`MarkerGroup`/`MarkerGroupMatchType`, `ConfigManager`/`ConfigProvider`, `ReactiveQueue`, `HtmlUtils`, `FileUtils`,
127+
the persistence loaders/converters (including `Version1SignEntryLoader`), `ActionFactory`/`MarkerSetIdentifierCollection`)
112128
— these can be unit tested directly (see `src/test/java/.../core/signs/SignLinesParserTest.java` for the pattern).
113129
Code that must reference game types (`SignHelper`, the mixins, `BlueMapSignMarkersMod`, `BlueMapAPIConnector`)
114130
should stay thin glue around the testable core, since it can only be verified manually via `runServer`.
115131

116-
There is a documented, currently-unfixed gap here: `BlueMapAPIConnector` passes sign text into BlueMap's POI marker
117-
`detail` field unescaped, and BlueMap renders `detail` as raw HTML (unlike `label`, which BlueMap escapes itself) —
118-
see `plans/html-detail-escaping-plan.md` for the planned fix.
132+
`BlueMapAPIConnector` escapes sign text (`HtmlUtils.toHtmlDetail`, in `common`) before it reaches BlueMap's POI
133+
marker `detail` field — BlueMap renders `detail` as raw HTML (unlike `label`, which BlueMap escapes itself), and
134+
sign text is player-controlled, so this closes a live XSS vector. See `plans/html-detail-escaping-plan.md` for the
135+
design. Persisted sign data stays raw/unescaped; escaping happens only at this BlueMap API call site.
119136

120137
## Planning documents
121138

agent-context/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Agent Context — BlueMap Sign Markers
2+
3+
BlueMap Sign Markers is a server-side Fabric mod (Java 25) for Minecraft. It watches in-game signs and, when a
4+
sign's text matches a configured prefix (e.g. `[poi]`), creates/updates/removes a corresponding marker on a
5+
[BlueMap](https://bluemap.bluecolored.de/) map. Signs are tracked persistently, sharded into one JSON file per
6+
(dimension, 32x32-chunk region) under `{server_root}/bluemapsignmarkers/{level}/`, so markers survive restarts.
7+
Multiple "marker groups" (prefix, match rule, icon, visibility) can be configured at once via
8+
`config/bluemapsignmarkers/BMSM-Core.json`.
9+
10+
Human-facing docs already exist:
11+
- `README.md` (repo root) — end-user install/config instructions and the marker-group config format
12+
- `AGENTS.md` / `CLAUDE.md` (repo root) — AI agent guidance (build commands, high-level architecture, conventions)
13+
14+
This `agent-context/` directory goes deeper than `AGENTS.md` on implementation specifics — exact decision logic,
15+
method-level behavior, data-shape history — so an agent doesn't have to re-derive it from source every time.
16+
17+
## Documents
18+
19+
| Document | What it covers |
20+
|----------|----------------|
21+
| `context/architecture.md` | Tech stack, directory map, package taxonomy, build/CI/publish tooling |
22+
| `context/core-pipeline.md` | Sign text → marker action: parsing state machine, `SignManager` decision logic, chunk-load sign reconciliation (`SignChunkKey`/`SignChunkIndex`), `ReactiveQueue`/`BlueMapAPIConnector` mechanics, marker ID/set identity scheme |
23+
| `context/config-and-persistence.md` | Marker-group config loading/migration (V1→V2), region-sharded sign persistence, per-region-file versioning (V1→V2→V3), legacy-file migration and backup-on-migrate behavior |
24+
| `context/testing.md` | Test infra, testable-vs-game-coupled split, CI test-result summarization, current coverage and known gaps |
25+
26+
## Document Scopes
27+
28+
| Document | Codebase paths watched |
29+
|----------|------------------------|
30+
| `context/architecture.md` | `build.gradle`, `gradle.properties`, `settings.gradle`, top-level dirs, `.github/workflows/` |
31+
| `context/core-pipeline.md` | `src/main/java/.../core/signs/` (excl. `persistence/`), `core/bluemap/`, `core/reactive/`, `core/markers/`, `mixin/`, `BlueMapSignMarkersMod.java` |
32+
| `context/config-and-persistence.md` | `src/main/java/.../config/`, `core/signs/persistence/`, `README.md` (config format section) |
33+
| `context/testing.md` | `src/test/java/`, `.github/workflows/build.yml`, `.github/workflows/publish.yml` |
34+
35+
---
36+
*Last updated: 2026-07-21 | Verified against: 26.2-0.17.0 (72d4280)*
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Architecture
2+
3+
## Tech stack
4+
5+
| Layer | Technology |
6+
|-------|-----------|
7+
| Language | Java 25 (`sourceCompatibility`/`targetCompatibility` = `VERSION_25` in `build.gradle`) |
8+
| Mod platform | Fabric, `DedicatedServerModInitializer` (server-only, no client logic) |
9+
| Build | Gradle wrapper + Fabric Loom `1.17-SNAPSHOT` |
10+
| Target Minecraft | version pinned in `gradle.properties` (`minecraft_version`), currently `26.2` |
11+
| Fabric loader/API | `loader_version` / `fabric_api_version` in `gradle.properties` |
12+
| External integration | BlueMap API `blue_map_api_version` (`compileOnly` — provided by BlueMap itself at runtime), targeting BlueMap `blue_map_version` |
13+
| JSON | Gson, `Strictness.LENIENT` |
14+
| Logging | SLF4J, all loggers named via `Constants.MOD_ID` |
15+
| Testing | JUnit 5 (Jupiter), via `junit-bom:5.11.4` |
16+
| Publish target | Modrinth, via `com.modrinth.minotaur` Gradle plugin |
17+
| CI | GitHub Actions |
18+
19+
All version numbers live in `gradle.properties` — never hardcode a version in `build.gradle` or source.
20+
`mod_version` follows `<minecraft_version>-<mod_semver>` (e.g. `26.2-0.17.0`).
21+
22+
## Top-level layout
23+
24+
```
25+
src/main/java/com/tpwalke2/bluemapsignmarkers/ mod source
26+
src/main/resources/ fabric.mod.json, mixins config, icon asset
27+
src/test/java/com/tpwalke2/bluemapsignmarkers/ unit tests (plain-Java logic only)
28+
plans/ design/implementation plans, written before larger changes
29+
.github/workflows/ build.yml (CI), publish.yml (manual Modrinth publish)
30+
build.gradle, gradle.properties build config and all version numbers
31+
README.md end-user install + config-format docs
32+
AGENTS.md / CLAUDE.md AI agent guidance (source of truth CLAUDE.md defers to AGENTS.md)
33+
```
34+
35+
## Package taxonomy
36+
37+
| Package | Contents |
38+
|---------|----------|
39+
| `com.tpwalke2.bluemapsignmarkers` (root) | `BlueMapSignMarkersMod` (entrypoint, lifecycle hooks; implements `ServerPathProvider`), `Constants` (`MOD_ID`), `ServerPathProvider` (interface: `getMarkerStorageRoot(MinecraftServer): Path`, resolves `{server_root}/bluemapsignmarkers/{level}`) |
40+
| `common` | `FileUtils``createBackup`/`moveToBackup(originalPath, suffix, description)` (copy- vs. rename-based backup, used by config/sign migration code before overwriting or retiring an old-format file); `HtmlUtils``escape`/`toHtmlDetail`, escapes player-supplied sign text before it crosses into BlueMap's HTML-rendered marker `detail` field |
41+
| `config` | `ConfigManager` (lazy `volatile` singleton over the loaded config, `reload()` on first null `get()`), `ConfigProvider` (load/save/migrate `BMSM-Core.json`) |
42+
| `config.models` | `BMSMConfigV1` (legacy, single `poiPrefix` field), `BMSMConfigV2` (current runtime shape — `MarkerGroup[]`, ctor also seeds Gson defaults) |
43+
| `config.persistence` | `LoadingBMSMConfigV2` / `LoadingMarkerGroupV2` — a *nullable-boxed-field* mirror of `BMSMConfigV2`/`MarkerGroup` used only for Gson deserialization, so partially-specified user JSON can be detected and defaulted field-by-field in `ConfigProvider.convertToLoadedMarkerGroup` rather than Gson silently zeroing missing primitives |
44+
| `core` | `WorldMap` — just the `UNKNOWN` dimension-key sentinel used when a sign's `Level` is null |
45+
| `core.bluemap` | `BlueMapAPIConnector` (the only class that touches the BlueMap API), `IResetHandler` (callback interface `SignManager` implements to replay its cache on BlueMap reset) |
46+
| `core.bluemap.actions` | `MarkerAction` (abstract base, holds a `MarkerIdentifier`), `AddMarkerAction`/`UpdateMarkerAction`/`RemoveMarkerAction`, `ActionFactory` (builds actions, resolves `MarkerSetIdentifier`s) |
47+
| `core.markers` | `MarkerGroup` (record: the config unit), `MarkerGroupMatchType` (`STARTS_WITH`/`REGEX`), `MarkerGroupType` (currently only `POI`), `MarkerIdentifier`/`MarkerSetIdentifier`/`MarkerSetIdentifierCollection` (marker identity + dedup) |
48+
| `core.reactive` | `ReactiveQueue<T>` (generic buffer-while-unavailable queue) + its three functional-interface callbacks (`ShouldRunCallback`, `MessageProcessorCallback<T>`, `MessageProcessorErrorCallback`) — see `core-pipeline.md` |
49+
| `core.signs` | `SignEntry`/`SignEntryKey` (immutable sign snapshot + position/dimension key), `SignHelper` (game-coupled: builds `SignEntry` from a `SignBlockEntity`), `SignEntryHelper` (plain-Java: derives label/detail/prefix from a `SignEntry`), `SignLinesParser`/`ParsingContext`/`SignLinesParseResult` (plain-Java parsing state machine), `SignManager` (singleton decision point — cache + dispatch), `SignChunkKey`/`SignChunkIndex` (plain-Java chunk-position lookup backing chunk-load sign reconciliation, see `core-pipeline.md` §4) |
50+
| `core.signs.persistence` | `SignProvider` (load/save, dispatches to the region-sharded loader/writer or migration), `SignFileVersions` (`V1`/`V2`/`V3` — per-region-file schema version, unchanged by sharding), `VersionedSignFile` (`{version, data}` envelope), `SignRegionKey` (record: dimension + region coords; `forPosition` does the `floorDiv` region math, `relativeFilePath` builds the `{namespace}/{path}/r.{x}.{z}.json` path), `SignRegionPartitioner` (groups `SignEntry`s by `SignRegionKey`), `RegionShardedSignEntryWriter` (writes each region's file; region files that dropped to zero signs are quarantined with a `.stale` suffix, not deleted, unless any region write failed — in which case quarantine is skipped entirely, to avoid mistaking a load failure for a genuine "signs removed" case), `LegacySignFileMigrator` (one-shot: reads the pre-sharding single file through the existing V1/V2/V3 chain, writes it out sharded, backs up the legacy file only once every expected region file is confirmed written to disk) |
51+
| `core.signs.persistence.loaders` | `VersionedFileSignEntryLoader` (tries versioned envelope; V2 → converts + backs up), `Version1SignEntryLoader` (pre-versioning fallback; also normalizes legacy `nether`/`end`/`overworld` map-id strings to real dimension identifiers — the identifiers are spelled out as literal strings rather than read from `net.minecraft.world.level.Level` statics, so the class has no Minecraft-type dependency and is fully unit-testable), `Version3Converter` (V2 model → current `SignEntry`), `RegionShardedSignEntryLoader` (walks a storage root's directory tree, loads each region file via `VersionedFileSignEntryLoader`, flattens to one list; `hasSignData(Path)` is the migration-trigger check) |
52+
| `core.signs.persistence.models` | `SignEntryV2`, `SignLinesParseResultV2`, `MarkerTypeV2` — frozen shapes representing the on-disk V2 format, kept solely so `Version3Converter` can still read old files; do not evolve these, they must stay byte-compatible with historical `signs.json` |
53+
| `mixin` | `SignBlockEntityInject` (injects `SignBlockEntity.updateSignText` TAIL), `AbstractBlockInject` (injects `BlockBehaviour.affectNeighborsAfterRemoval` HEAD) |
54+
55+
## Import / module resolution
56+
57+
Standard Java package imports. Fabric Loom remaps Minecraft/Fabric API artifacts at build time; `build.gradle`'s
58+
`loom { mods { "bluemapsignmarkers" { sourceSet sourceSets.main } } }` registers the main source set as the mod's
59+
dev source for `runServer`/`runClient`.
60+
61+
## Build / CI / publish tooling
62+
63+
- `./gradlew build` — compile, run tests, produce jar in `build/libs/`; fails on any test failure
64+
- `./gradlew test` — JUnit 5 suite only (`src/test/java`)
65+
- `./gradlew runServer` / `runClient` — dev Minecraft instance with the mod + BlueMap loaded
66+
- `.github/workflows/build.yml` — runs on push/PR to `main` and `releases/**`: `./gradlew test` → summarize JUnit
67+
XML results to `$GITHUB_STEP_SUMMARY` (plain shell `sed`, no third-party reporter action — see `testing.md` for
68+
why) → `./gradlew build` → upload `build/libs/` as an artifact
69+
- `.github/workflows/publish.yml` — manual `workflow_dispatch` only: runs tests first (failure blocks publish), then
70+
`./gradlew modrinth -PbuildNumber=<run_number>` (alpha, from a branch dispatch) or the same with `-PisRelease`
71+
(release, from a `v*` tag dispatch)
72+
73+
---
74+
*Last updated: 2026-07-21 | Verified against: 26.2-0.17.0 (72d4280)*
75+

0 commit comments

Comments
 (0)