Compare commits
13 Commits
2b7e0e090d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b4e76ad49 | |||
| 6e2739672c | |||
| 376d2d0507 | |||
| 47490309fe | |||
| 52417a7b92 | |||
| 0d8e670cd8 | |||
| 890ce46c4f | |||
| ef47d4481a | |||
| 52583f2056 | |||
| 41034356ac | |||
| 73e10d34e5 | |||
| ab94cca1b2 | |||
| 53cba762b4 |
@@ -14,10 +14,16 @@ in Claude Code's plan mode.
|
|||||||
- `forge-1_12_2/` — **primary leaf**, MC 1.12.2 Forge. First implemented; this is the actual
|
- `forge-1_12_2/` — **primary leaf**, MC 1.12.2 Forge. First implemented; this is the actual
|
||||||
driving use case (an Enigmatica 2 modpack server). Builds with legacy ForgeGradle 2.3 via
|
driving use case (an Enigmatica 2 modpack server). Builds with legacy ForgeGradle 2.3 via
|
||||||
anatawa12's Gradle-7-compatible fork (see `THIRD_PARTY_NOTICES.md`).
|
anatawa12's Gradle-7-compatible fork (see `THIRD_PARTY_NOTICES.md`).
|
||||||
- `forge-1_7_10/` — MC 1.7.10 Forge. Second priority. Builds with legacy ForgeGradle 1.2, same
|
- `forge-1_7_10/` — MC 1.7.10 Forge (Phase 9). Second priority, fully wired against the same
|
||||||
fork family, one Forge-tooling generation further back.
|
`common` interfaces as `forge-1_12_2`. Builds with legacy ForgeGradle 1.2, same fork family, one
|
||||||
- `neoforge-26_1/` — MC 26.1.2 (NeoForge, assumed). Lowest MVP priority. Structurally scaffolded,
|
Forge-tooling generation further back — pre-block-state (raw `Block` + metadata int, no
|
||||||
not yet wired into the default build (see `settings.gradle`).
|
`IBlockState`) and pre-FML-repackage (`cpw.mods.fml.*`, not `net.minecraftforge.fml.*`).
|
||||||
|
- `neoforge-26_1/` — MC 26.1.2 (Phase 10; loader confirmed NeoForge). Lowest MVP priority, fully
|
||||||
|
wired against the same `common` interfaces as the two legacy leaves. Post-Flattening, mixin-era
|
||||||
|
API: block reads are `BlockState`, not raw id+meta; events/commands/config live under
|
||||||
|
`net.neoforged.*` with constructor-injected event buses instead of `@Mod.EventHandler` methods
|
||||||
|
and Brigadier commands instead of `CommandBase`. Builds standalone via ModDevGradle — see
|
||||||
|
"Building" below, not part of the root multi-project build (see `settings.gradle`).
|
||||||
|
|
||||||
## Version targets and priority
|
## Version targets and priority
|
||||||
|
|
||||||
@@ -38,12 +44,28 @@ Both legacy leaves share one root build, on Gradle 7.6 / JDK 8 (pinned via
|
|||||||
```
|
```
|
||||||
|
|
||||||
The first build downloads Minecraft/Forge artifacts and MCP mappings from Forge's Maven and can
|
The first build downloads Minecraft/Forge artifacts and MCP mappings from Forge's Maven and can
|
||||||
take a while / needs network access. `neoforge-26_1` is not yet buildable from the root — it
|
take a while / needs network access.
|
||||||
needs Gradle 8+ and JDK 17+ (ModDevGradle), incompatible with the legacy leaves' toolchain
|
|
||||||
within one Gradle invocation; see `settings.gradle`'s comment for the workaround until Phase 10
|
|
||||||
gives it a proper isolated build.
|
|
||||||
|
|
||||||
## Configuration (Phase 1: forge-1_12_2)
|
`neoforge-26_1` is **not** part of the root build — it needs Gradle 8+ and a Java 25 toolchain
|
||||||
|
(Minecraft itself now requires Java 25 as of the 26.x cycle), incompatible with the legacy
|
||||||
|
leaves' Gradle-7/JDK-8 pin within one Gradle invocation. It has its own wrapper; build it from
|
||||||
|
inside its own directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd neoforge-26_1
|
||||||
|
./gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Gradle *daemon* can run on any modern JDK on `PATH`/`JAVA_HOME` (or pinned via
|
||||||
|
`org.gradle.java.home` in a `$GRADLE_USER_HOME/gradle.properties`, same mechanism as the legacy
|
||||||
|
leaves' JDK 8 pin — see `settings.gradle`'s comment) — it does not need to already be JDK 25
|
||||||
|
itself. `settings.gradle` applies the Foojay toolchain resolver so Gradle auto-provisions an
|
||||||
|
actual JDK 25 (into its own `GRADLE_USER_HOME` cache, not a system-wide install) for the
|
||||||
|
compile/run tasks. The first build also downloads and decompiles/patches Minecraft itself via
|
||||||
|
NeoForge's NeoForm pipeline (the modern equivalent of the legacy leaves' MCP step) — expect it to
|
||||||
|
take several minutes and a real chunk of disk/network the first time; subsequent builds are fast.
|
||||||
|
|
||||||
|
## Configuration (all three leaves)
|
||||||
|
|
||||||
On first server start the leaf writes `config/mcmapper.cfg` with defaults. Set:
|
On first server start the leaf writes `config/mcmapper.cfg` with defaults. Set:
|
||||||
|
|
||||||
@@ -54,11 +76,84 @@ serverToken=<token from MCMapper-Backend's `bun run seed`>
|
|||||||
|
|
||||||
then restart. With those set, the mod connects to the backend, backfills already-loaded
|
then restart. With those set, the mod connects to the backend, backfills already-loaded
|
||||||
overworld chunks, and streams event-driven column deltas (block place/break) in batches every
|
overworld chunks, and streams event-driven column deltas (block place/break) in batches every
|
||||||
`deltaFlushIntervalTicks` (default 20 = 1s). No periodic reconciliation sweep yet (Phase 7),
|
`deltaFlushIntervalTicks` (default 20 = 1s). A periodic reconciliation sweep (Phase 7) also
|
||||||
and only the overworld — other dimensions and `forge-1_7_10`/`neoforge-26_1` land in later
|
walks a bounded number of currently-loaded chunks per tick (`reconciliationChunksPerSweep`,
|
||||||
phases. The WS client and JSON encoding are hand-rolled (no third-party dependency) — see
|
default 4) and re-sends any that drifted from what the backend last acknowledged, to catch
|
||||||
|
mutations event hooks miss (world-gen, other mods, `/fill`). Only the overworld is tracked. The
|
||||||
|
WS client and JSON encoding are hand-rolled (no third-party dependency) — see
|
||||||
`common/src/main/java/.../ws/SimpleWebSocketClient.java` and `.../json/MiniJson.java` for why.
|
`common/src/main/java/.../ws/SimpleWebSocketClient.java` and `.../json/MiniJson.java` for why.
|
||||||
|
|
||||||
|
`forge-1_7_10` and `neoforge-26_1` implement the identical config/flush/reconciliation/
|
||||||
|
player-tracking behavior against their own API generation (see `Forge1710ChunkAdapter`'s and
|
||||||
|
`Neoforge261ChunkAdapter`'s javadocs for what differs). Neither has a `MultiPlaceEvent`-equivalent
|
||||||
|
hook wired (1.7.10: that class doesn't reliably exist at this Forge version; 26.1.2: skipped for
|
||||||
|
symmetry, no strong need identified), so multi-block placements like doors/beds are only caught
|
||||||
|
by the reconciliation sweep rather than immediately, unlike `forge-1_12_2`. `neoforge-26_1` writes
|
||||||
|
its config to `config/mcmapper-server.toml` (NeoForge's `ModConfigSpec`/TOML format, not the
|
||||||
|
legacy leaves' `.cfg`), and — being post-Flattening — carries the full registry-wide packed
|
||||||
|
`BlockState` id as `DeltaEvent#blockStateId` (an `int`, so this is lossless for the 2D column
|
||||||
|
pipeline); 3D section backfill (`SectionData#blocks`, a `char[]` for wire-size reasons inherited
|
||||||
|
from the pre-Flattening leaves) truncates to the low 16 bits, a known, documented collision risk
|
||||||
|
for very large modded registries — see `Neoforge261ChunkAdapter`'s javadoc.
|
||||||
|
|
||||||
|
### Player position tracking (Phase 7b)
|
||||||
|
|
||||||
|
When `playerTrackingEnabled` (default `true`), the leaf sends a throttled roster of online
|
||||||
|
overworld players (`{"type":"player_positions",...}`, see `DefaultBackendConnection`'s
|
||||||
|
class-level wire-protocol doc comment) every `playerPositionIntervalTicks` (default 40 = 2s) —
|
||||||
|
always the *full current roster*, not a diff, so a logged-out player simply stops appearing in
|
||||||
|
the next send. This is a mod-local toggle only: whether the backend actually relays positions on
|
||||||
|
to web viewers is a separate, independent per-server admin setting on the backend side (see
|
||||||
|
MCMapper-Backend's README) — a server operator can track positions server-side without exposing
|
||||||
|
them publicly, or vice versa.
|
||||||
|
|
||||||
|
### Real block textures (Phase 11)
|
||||||
|
|
||||||
|
Forge doesn't split client/server jars, so a dedicated server's own classpath already has every
|
||||||
|
loaded mod's `assets/<modid>/textures/blocks/*.png` (or `textures/block/` on newer conventions),
|
||||||
|
sitting there unused server-side. `forge-1_12_2` (this project's primary, most-modded target —
|
||||||
|
Enigmatica 2) reads them once at server-starting time via `BlockAssetExtractor`: `Block.REGISTRY`
|
||||||
|
gives every block's numeric id + registry name (same source as the existing column/section wire
|
||||||
|
encoding), and each block's texture is guessed by a best-effort *convention* match — the registry
|
||||||
|
name's path segment tried as a texture filename — not a real blockstate/model JSON resolution
|
||||||
|
(that's Phase 12's job; a block whose model doesn't follow the convention is just skipped, no
|
||||||
|
worse than before this phase). The registry mapping (`block_registry`) and extracted PNGs
|
||||||
|
(`block_textures`, batched 50/message) are sent to the backend once, right after the WS handshake
|
||||||
|
completes — via `BackendConnection#setReadyListener`, since `connect()` is async and a naive send
|
||||||
|
right after calling it would silently no-op before the handshake lands.
|
||||||
|
|
||||||
|
`forge-1_7_10` and `neoforge-26_1` don't implement extraction yet — `sendBlockRegistry`/
|
||||||
|
`sendBlockTextures`/`setReadyListener` live on the shared `BackendConnection` interface (so either
|
||||||
|
leaf can adopt them later with no protocol change) but only `forge-1_12_2` calls them so far,
|
||||||
|
matching this phase's Enigmatica-2-focused scope. See MCMapper-Backend's README for what the
|
||||||
|
backend currently does with this data (short version: stores it; per-server render-time
|
||||||
|
resolution is explicitly deferred, documented there).
|
||||||
|
|
||||||
|
### Real (non-cube) block models (Phase 13)
|
||||||
|
|
||||||
|
`BlockAssetExtractor#extractModels` ships every `assets/<modid>/blockstates/*.json` (flat, matching
|
||||||
|
vanilla's own layout — no subfolders expected) and `assets/<modid>/models/block/**/*.json`
|
||||||
|
(recursive — mods are free to nest these, e.g. a shared `models/block/base/` folder) file for every
|
||||||
|
currently-active mod, keyed the way MCMapper-Backend's `worker/src/models.rs` expects. Unlike
|
||||||
|
`extractTextures`, this can't use the classloader (there's no `ClassLoader` API to list a
|
||||||
|
directory's contents, only to read one known file by path) — it walks each mod's own source
|
||||||
|
instead, via `net.minecraftforge.fml.common.Loader#getActiveModList`'s `ModContainer#getSource`,
|
||||||
|
handling both shapes that can show up there: a packed jar (opened as a `ZipFile`) in production, or
|
||||||
|
a raw exploded directory when running from an IDE/dev environment.
|
||||||
|
|
||||||
|
The mod deliberately ships **every** model file it finds under a mod's `models/block/` tree rather
|
||||||
|
than trying to work out which ones a given blockstate actually references — a blockstate's
|
||||||
|
`"model"` field (or another model's `"parent"`) can point at a path that doesn't match the
|
||||||
|
referencing block's own registry path 1:1 (e.g. several blocks sharing one base model), and the
|
||||||
|
mod has no JSON parser to resolve that itself. That resolution — parent-chain walking, texture-
|
||||||
|
variable substitution, picking a representative variant — is entirely backend-side (see
|
||||||
|
MCMapper-Backend's README's own Phase 13 section for what it does and doesn't handle, e.g. no
|
||||||
|
`multipart` blockstate support, no property-based variant selection).
|
||||||
|
|
||||||
|
Sent as a new `block_models` WS message, batched 50/message like `block_textures`, from the same
|
||||||
|
ready-listener callback as the Phase 11 registry/texture dump. Verified via a real
|
||||||
|
`./gradlew :forge-1_12_2:compileJava` against the pinned legacy ForgeGradle toolchain.
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
See `THIRD_PARTY_NOTICES.md`.
|
See `THIRD_PARTY_NOTICES.md`.
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ publicly documented/community-reverse-engineered wire format, never from Xaero's
|
|||||||
- What was adapted: not adapted/copied — used as-is as a build-tool dependency (Gradle plugin),
|
- What was adapted: not adapted/copied — used as-is as a build-tool dependency (Gradle plugin),
|
||||||
same rationale as the FG2.3 fork above, one Forge-tooling generation further back for 1.7.10.
|
same rationale as the FG2.3 fork above, one Forge-tooling generation further back for 1.7.10.
|
||||||
|
|
||||||
|
## JourneyMap chat-waypoint bracket syntax
|
||||||
|
- Source: https://github.com/1whohears/JourneyMapQOL_1.7.10, corroborated by
|
||||||
|
https://github.com/TeamJM/journeymap/issues/465 ("Waypoint Chat option")
|
||||||
|
- License: MIT (JourneyMapQOL_1.7.10)
|
||||||
|
- Used in: `common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java`
|
||||||
|
(`journeyMapText`)
|
||||||
|
- What was adapted: not code — the documented plain-text convention JourneyMap itself
|
||||||
|
auto-detects in chat (`[x:..,y:..,z:..,dim:..,name:..,color:..,delete:..]`), reimplemented as
|
||||||
|
our own string builder from the publicly described field list.
|
||||||
|
|
||||||
|
## Xaero's Minimap `xaero-waypoint:` chat-share schema (community-reverse-engineered)
|
||||||
|
- Source: https://gist.github.com/macimas/937a392be075b1bce7a2ae69ea933ef5 ("my rough
|
||||||
|
interpretation on xaero-waypoint schema formatty")
|
||||||
|
- License: none stated (personal gist notes) — used only as a documentation reference for an
|
||||||
|
otherwise-undocumented wire format, no code copied
|
||||||
|
- Used in: `common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java`
|
||||||
|
(`xaeroText`)
|
||||||
|
- What was adapted: not code — the documented nine-field colon-separated schema
|
||||||
|
(`name:marker:x:y:z:color:use_yaw:yaw:dimension`), reimplemented as our own string builder.
|
||||||
|
Xaero's Minimap/Worldmap are closed-source and this format has no official documentation, so
|
||||||
|
this is flagged in the formatter's javadoc as best-effort/unverified — worth testing against a
|
||||||
|
real Xaero install before relying on it, since even community sources disagree on field count
|
||||||
|
for this specific format.
|
||||||
|
|
||||||
Further entries will be added here as more third-party material lands, in the form:
|
Further entries will be added here as more third-party material lands, in the form:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,3 +17,20 @@ Minecraft/Forge/NeoForge API usage — it must compile standalone under plain `j
|
|||||||
request/response). Mirrors the WS protocol described in the root plan.
|
request/response). Mirrors the WS protocol described in the root plan.
|
||||||
- `config/` — the common config model (backend URL, server token, tracking/reconciliation
|
- `config/` — the common config model (backend URL, server token, tracking/reconciliation
|
||||||
intervals) each leaf loads via its own loader-specific config system.
|
intervals) each leaf loads via its own loader-specific config system.
|
||||||
|
- `json/`, `ws/` — the hand-rolled JSON codec and RFC 6455 WS client `DefaultBackendConnection`
|
||||||
|
is built on (no third-party dependency, for the same "no shading through legacy ForgeGradle"
|
||||||
|
reason this module stays dependency-free generally).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```
|
||||||
|
bash run-tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Compiles `src/main/java` + `src/test/java` and runs every `*Test.java` class's `main()`. No
|
||||||
|
JUnit/Gradle — same "must compile standalone under plain `javac`" constraint as the module
|
||||||
|
itself, and test code never ships in the mod jar so it doesn't need to match either leaf's JDK 8
|
||||||
|
target. Anything needing a live Forge/Minecraft world (event hook wiring, world reads) isn't
|
||||||
|
unit-testable this way — those stay integration-tested against a real running
|
||||||
|
MCMapper-Backend instance instead (see the Phase 1/2 commit messages for how that's been done
|
||||||
|
so far).
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compiles and runs common/'s standalone test classes (no JUnit, no Gradle — see README.md for
|
||||||
|
# why: common/ must compile under plain javac, independent of either leaf's ForgeGradle
|
||||||
|
# toolchain, and test code never ships in the mod jar so it doesn't need to match the leaves'
|
||||||
|
# JDK 8 target either). Any JDK on PATH works.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
OUT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$OUT"' EXIT
|
||||||
|
|
||||||
|
javac -d "$OUT" $(find src/main/java src/test/java -name '*.java')
|
||||||
|
|
||||||
|
status=0
|
||||||
|
for class in $(find src/test/java -name '*Test.java' | sed 's#src/test/java/##; s#\.java$##; s#/#.#g'); do
|
||||||
|
echo "== $class =="
|
||||||
|
java -cp "$OUT" "$class" || status=1
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
|
||||||
|
exit $status
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
package com.octoturge.mcmapper.common;
|
package com.octoturge.mcmapper.common;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockTexture;
|
||||||
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
import com.octoturge.mcmapper.common.protocol.LinkRequest;
|
import com.octoturge.mcmapper.common.protocol.LinkRequest;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
|
||||||
import com.octoturge.mcmapper.common.protocol.SectionData;
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointShare;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -11,8 +16,9 @@ import java.util.List;
|
|||||||
* modules (pure Java, no Minecraft API usage) — a docker-network hostname, LAN IP, or public
|
* modules (pure Java, no Minecraft API usage) — a docker-network hostname, LAN IP, or public
|
||||||
* domain in {@code MapperConfig#backendUrl} all work identically.
|
* domain in {@code MapperConfig#backendUrl} all work identically.
|
||||||
*
|
*
|
||||||
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas) and
|
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas), grew
|
||||||
* grew {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill).
|
* {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill), and grew chat/linking in
|
||||||
|
* Phase 3.
|
||||||
*/
|
*/
|
||||||
public interface BackendConnection {
|
public interface BackendConnection {
|
||||||
void connect(String url, String serverToken);
|
void connect(String url, String serverToken);
|
||||||
@@ -23,8 +29,64 @@ public interface BackendConnection {
|
|||||||
|
|
||||||
void sendLinkRequest(LinkRequest request);
|
void sendLinkRequest(LinkRequest request);
|
||||||
|
|
||||||
|
/** Forwards one in-game chat message to the backend — see ChatBridge.OutboundSink's javadoc. */
|
||||||
|
void sendChatMessage(String uuid, String username, String message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the full current online-player roster for one dimension (Phase 7b) — see
|
||||||
|
* {@link PlayerPosition}'s javadoc for why this is always the whole roster, not a diff. An
|
||||||
|
* empty list is a meaningful, intentional send (everyone logged out), so callers should call
|
||||||
|
* this every throttle tick rather than skipping it when there are no players.
|
||||||
|
*/
|
||||||
|
void sendPlayerPositions(int dimension, List<PlayerPosition> players);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends this leaf's numeric-blockId -> registry-name dump (Phase 11) — see {@link
|
||||||
|
* BlockRegistryEntry}'s javadoc. Leaves that don't implement Phase 11 texture extraction
|
||||||
|
* (see {@link #sendBlockTextures}) can simply never call this; an empty/never-called dump is
|
||||||
|
* harmless, not an error, on the backend side.
|
||||||
|
*/
|
||||||
|
void sendBlockRegistry(List<BlockRegistryEntry> entries);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends this leaf's best-effort classloader-extracted block textures (Phase 11) — see {@link
|
||||||
|
* BlockTexture}'s javadoc. Implementations should batch calls (many small messages rather
|
||||||
|
* than one huge frame) rather than requiring the caller to pre-batch.
|
||||||
|
*/
|
||||||
|
void sendBlockTextures(List<BlockTexture> textures);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends this leaf's raw blockstate/model JSON dump (Phase 13) — see {@link
|
||||||
|
* BlockModelFile}'s javadoc. Implementations should batch calls the same way {@link
|
||||||
|
* #sendBlockTextures} does, for the same heavily-modded-pack reason.
|
||||||
|
*/
|
||||||
|
void sendBlockModels(List<BlockModelFile> files);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a callback fired every time the connection successfully authenticates (including
|
||||||
|
* after an automatic reconnect) — {@code connect()} itself is async (the real handshake
|
||||||
|
* completes on a later WS frame), so callers with a one-time "just after connecting" send
|
||||||
|
* (see Phase 11's registry/texture dump) need this rather than calling right after {@code
|
||||||
|
* connect()}, which would silently no-op (every {@code send*} method is a no-op until ready).
|
||||||
|
*/
|
||||||
|
void setReadyListener(Runnable onReady);
|
||||||
|
|
||||||
|
/** Registers the callback for web-originated chat messages the backend relays back to us. */
|
||||||
|
void setChatListener(ChatListener listener);
|
||||||
|
|
||||||
|
/** Registers the callback for markers a web visitor shared to chat — see WaypointShare's javadoc. */
|
||||||
|
void setWaypointShareListener(WaypointShareListener listener);
|
||||||
|
|
||||||
void disconnect();
|
void disconnect();
|
||||||
|
|
||||||
|
interface ChatListener {
|
||||||
|
void onChatMessage(String username, String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaypointShareListener {
|
||||||
|
void onWaypointShare(WaypointShare share);
|
||||||
|
}
|
||||||
|
|
||||||
final class NoOp implements BackendConnection {
|
final class NoOp implements BackendConnection {
|
||||||
@Override
|
@Override
|
||||||
public void connect(String url, String serverToken) {
|
public void connect(String url, String serverToken) {
|
||||||
@@ -42,6 +104,38 @@ public interface BackendConnection {
|
|||||||
public void sendLinkRequest(LinkRequest request) {
|
public void sendLinkRequest(LinkRequest request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendChatMessage(String uuid, String username, String message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendPlayerPositions(int dimension, List<PlayerPosition> players) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendBlockRegistry(List<BlockRegistryEntry> entries) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendBlockTextures(List<BlockTexture> textures) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendBlockModels(List<BlockModelFile> files) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setReadyListener(Runnable onReady) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setChatListener(ChatListener listener) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setWaypointShareListener(WaypointShareListener listener) {
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void disconnect() {
|
public void disconnect() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ public interface ChunkAdapter {
|
|||||||
/** Register the loader-specific hooks (block place/break, chunk load/unload) that feed the dirty buffer. */
|
/** Register the loader-specific hooks (block place/break, chunk load/unload) that feed the dirty buffer. */
|
||||||
void registerEventHooks(DeltaSink sink);
|
void registerEventHooks(DeltaSink sink);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently-loaded chunk coordinates, packed as {@code (chunkX << 32) | (chunkZ & 0xFFFFFFFFL)}
|
||||||
|
* — the same packing the leaf modules' own dirty-chunk sets already use. Feeds
|
||||||
|
* {@link ReconciliationScheduler}, which picks a bounded rotating slice of this set to
|
||||||
|
* re-read and resend each periodic sweep (see the plan's "hybrid" change-detection decision).
|
||||||
|
*/
|
||||||
|
java.util.List<Long> loadedChunkKeys();
|
||||||
|
|
||||||
interface DeltaSink {
|
interface DeltaSink {
|
||||||
void onDelta(DeltaEvent event);
|
void onDelta(DeltaEvent event);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.octoturge.mcmapper.common;
|
package com.octoturge.mcmapper.common;
|
||||||
|
|
||||||
import com.octoturge.mcmapper.common.json.MiniJson;
|
import com.octoturge.mcmapper.common.json.MiniJson;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockTexture;
|
||||||
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
import com.octoturge.mcmapper.common.protocol.LinkRequest;
|
import com.octoturge.mcmapper.common.protocol.LinkRequest;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
|
||||||
import com.octoturge.mcmapper.common.protocol.SectionData;
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointShare;
|
||||||
import com.octoturge.mcmapper.common.ws.SimpleWebSocketClient;
|
import com.octoturge.mcmapper.common.ws.SimpleWebSocketClient;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -27,8 +32,29 @@ import java.util.function.Consumer;
|
|||||||
*
|
*
|
||||||
* mod -> api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
|
* mod -> api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
|
||||||
* mod -> api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":..,"blocks":"<base64>"}]}
|
* mod -> api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":..,"blocks":"<base64>"}]}
|
||||||
|
* mod -> api {"type":"link_request","code":"...","uuid":"...","username":"...","authMode":"online"}
|
||||||
|
* mod -> api {"type":"chat","uuid":"...","username":"...","message":"..."}
|
||||||
|
* api -> mod {"type":"chat","username":"...","message":"..."}
|
||||||
|
* api -> mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"}
|
||||||
|
*
|
||||||
|
* mod -> api {"type":"player_positions","dimension":0,"players":[{"uuid":"...","username":"...","x":..,"y":..,"z":..}]}
|
||||||
|
*
|
||||||
|
* mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
|
||||||
|
* mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
|
||||||
|
* mod -> api {"type":"block_models","entries":[{"kind":"blockstate","name":"botania:manapool","json":"..."},{"kind":"model","name":"botania:block/manapool","json":"..."}]}
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
|
* Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions
|
||||||
|
* is the in-game/web chat bridge (see {@link ChatBridge}) — inbound {@code chat} messages are
|
||||||
|
* delivered through whatever {@link ChatListener} the leaf registered via
|
||||||
|
* {@link #setChatListener}.
|
||||||
|
*
|
||||||
|
* Phase 4: {@code waypoint_share} is sent when a linked account shares a placed marker to chat
|
||||||
|
* (see MCMapper-Backend's {@code markers.ts}) — delivered through whatever
|
||||||
|
* {@link WaypointShareListener} the leaf registered via {@link #setWaypointShareListener}. The
|
||||||
|
* mod's job is just to turn the point into the right chat text (see
|
||||||
|
* {@link com.octoturge.mcmapper.common.protocol.WaypointChatFormatter}) and broadcast it.
|
||||||
|
*
|
||||||
* A "columns" message doubles as both initial backfill (one message per loaded chunk) and live
|
* A "columns" message doubles as both initial backfill (one message per loaded chunk) and live
|
||||||
* deltas (one message per flush tick) — see {@link DeltaEvent}'s javadoc for how a
|
* deltas (one message per flush tick) — see {@link DeltaEvent}'s javadoc for how a
|
||||||
* {@code List<DeltaEvent>} maps onto it. {@code dimension} in {@link DeltaEvent} is a string so
|
* {@code List<DeltaEvent>} maps onto it. {@code dimension} in {@link DeltaEvent} is a string so
|
||||||
@@ -42,8 +68,29 @@ import java.util.function.Consumer;
|
|||||||
* last flush).
|
* last flush).
|
||||||
*
|
*
|
||||||
* No offline queue: deltas sent while disconnected are dropped rather than buffered — the
|
* No offline queue: deltas sent while disconnected are dropped rather than buffered — the
|
||||||
* periodic reconciliation sweep (not yet built, see plan's Phase 7) is what's meant to catch
|
* periodic reconciliation sweep (see {@link com.octoturge.mcmapper.common.ReconciliationScheduler})
|
||||||
* whatever a disconnect window missed, so buffering here would be solving the same problem twice.
|
* is what's meant to catch whatever a disconnect window missed, so buffering here would be
|
||||||
|
* solving the same problem twice.
|
||||||
|
*
|
||||||
|
* Phase 7b: {@code player_positions} carries the full current online-player roster for one
|
||||||
|
* dimension (not a diff), throttled on a separate timer from the delta flush — see
|
||||||
|
* {@link PlayerPosition}'s javadoc. The backend's per-server {@code playerPositionsVisible}
|
||||||
|
* admin toggle decides whether this gets relayed on to web viewers; it's independent of the
|
||||||
|
* mod-local {@code playerTrackingEnabled} config, which decides whether the mod computes/sends
|
||||||
|
* this at all.
|
||||||
|
*
|
||||||
|
* Phase 11: {@code block_registry}/{@code block_textures} are sent once, shortly after connecting
|
||||||
|
* (a world's numeric-id assignments and mod-jar contents don't change without a server restart,
|
||||||
|
* which restarts the mod too) — see {@link BlockRegistryEntry}/{@link BlockTexture}'s javadocs.
|
||||||
|
* {@link #sendBlockTextures} batches into multiple messages (see {@code BLOCK_TEXTURE_BATCH_SIZE})
|
||||||
|
* rather than one huge frame, since a heavily-modded server can have thousands of block textures.
|
||||||
|
*
|
||||||
|
* Phase 13: {@code block_models} is sent the same way, once, alongside {@code block_registry}/
|
||||||
|
* {@code block_textures} — see {@link BlockModelFile}'s javadoc for why the mod ships every model
|
||||||
|
* file it finds rather than trying to resolve blockstate-to-model references itself.
|
||||||
|
* {@link #sendBlockModels} batches the same way {@link #sendBlockTextures} does, for the same
|
||||||
|
* reason (blockstate/model JSON files, while individually small, can number in the thousands on
|
||||||
|
* a heavily-modded pack).
|
||||||
*
|
*
|
||||||
* This class avoids a third-party JSON/WS library entirely (see {@link SimpleWebSocketClient}
|
* This class avoids a third-party JSON/WS library entirely (see {@link SimpleWebSocketClient}
|
||||||
* and {@link MiniJson}'s javadoc) to keep the mod's classpath free of anything that would need
|
* and {@link MiniJson}'s javadoc) to keep the mod's classpath free of anything that would need
|
||||||
@@ -61,6 +108,9 @@ public class DefaultBackendConnection implements BackendConnection {
|
|||||||
|
|
||||||
private String url;
|
private String url;
|
||||||
private String serverToken;
|
private String serverToken;
|
||||||
|
private volatile ChatListener chatListener;
|
||||||
|
private volatile WaypointShareListener waypointShareListener;
|
||||||
|
private volatile Runnable readyListener;
|
||||||
|
|
||||||
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
|
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
|
||||||
this.logInfo = logInfo;
|
this.logInfo = logInfo;
|
||||||
@@ -144,6 +194,8 @@ public class DefaultBackendConnection implements BackendConnection {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
serverReady = true;
|
serverReady = true;
|
||||||
logInfo.accept("authenticated with backend as server " + obj.get("serverId"));
|
logInfo.accept("authenticated with backend as server " + obj.get("serverId"));
|
||||||
|
Runnable listener = readyListener;
|
||||||
|
if (listener != null) listener.run();
|
||||||
} else {
|
} else {
|
||||||
serverReady = false;
|
serverReady = false;
|
||||||
logWarn.accept("backend rejected connection: " + obj.get("error"));
|
logWarn.accept("backend rejected connection: " + obj.get("error"));
|
||||||
@@ -151,6 +203,23 @@ public class DefaultBackendConnection implements BackendConnection {
|
|||||||
}
|
}
|
||||||
} else if ("error".equals(type)) {
|
} else if ("error".equals(type)) {
|
||||||
logWarn.accept("backend reported error: " + obj.get("error"));
|
logWarn.accept("backend reported error: " + obj.get("error"));
|
||||||
|
} else if ("chat".equals(type)) {
|
||||||
|
ChatListener listener = chatListener;
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onChatMessage(String.valueOf(obj.get("username")), String.valueOf(obj.get("message")));
|
||||||
|
}
|
||||||
|
} else if ("waypoint_share".equals(type)) {
|
||||||
|
WaypointShareListener listener = waypointShareListener;
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onWaypointShare(new WaypointShare(
|
||||||
|
String.valueOf(obj.get("name")),
|
||||||
|
((Number) obj.get("x")).intValue(),
|
||||||
|
((Number) obj.get("y")).intValue(),
|
||||||
|
((Number) obj.get("z")).intValue(),
|
||||||
|
String.valueOf(((Number) obj.get("dimension")).intValue()),
|
||||||
|
String.valueOf(obj.get("color")),
|
||||||
|
"xaero".equals(obj.get("format")) ? WaypointShare.Format.XAERO : WaypointShare.Format.JOURNEYMAP));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +283,119 @@ public class DefaultBackendConnection implements BackendConnection {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void sendLinkRequest(LinkRequest request) {
|
public void sendLinkRequest(LinkRequest request) {
|
||||||
// The `/mcmapper link` flow (Phase 3) isn't wired up yet — nothing calls this in Phase 1.
|
if (!serverReady) return;
|
||||||
logWarn.accept("sendLinkRequest called before Phase 3's link flow is implemented — ignoring");
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "link_request");
|
||||||
|
msg.put("code", request.code);
|
||||||
|
msg.put("uuid", request.uuid);
|
||||||
|
msg.put("username", request.username);
|
||||||
|
msg.put("authMode", request.authMode == LinkRequest.AuthMode.ONLINE ? "online" : "offline");
|
||||||
|
sendRaw("link_request", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendChatMessage(String uuid, String username, String message) {
|
||||||
|
if (!serverReady) return;
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "chat");
|
||||||
|
msg.put("uuid", uuid);
|
||||||
|
msg.put("username", username);
|
||||||
|
msg.put("message", message);
|
||||||
|
sendRaw("chat", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendPlayerPositions(int dimension, List<PlayerPosition> players) {
|
||||||
|
if (!serverReady) return;
|
||||||
|
List<Object> playerList = new ArrayList<>();
|
||||||
|
for (PlayerPosition p : players) {
|
||||||
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
|
obj.put("uuid", p.uuid);
|
||||||
|
obj.put("username", p.username);
|
||||||
|
obj.put("x", (double) p.x);
|
||||||
|
obj.put("y", (double) p.y);
|
||||||
|
obj.put("z", (double) p.z);
|
||||||
|
playerList.add(obj);
|
||||||
|
}
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "player_positions");
|
||||||
|
msg.put("dimension", (double) dimension);
|
||||||
|
msg.put("players", playerList);
|
||||||
|
sendRaw("player_positions", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int BLOCK_TEXTURE_BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendBlockRegistry(List<BlockRegistryEntry> entries) {
|
||||||
|
if (entries.isEmpty() || !serverReady) return;
|
||||||
|
List<Object> entryList = new ArrayList<>();
|
||||||
|
for (BlockRegistryEntry e : entries) {
|
||||||
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
|
obj.put("id", (double) e.id);
|
||||||
|
obj.put("name", e.name);
|
||||||
|
entryList.add(obj);
|
||||||
|
}
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "block_registry");
|
||||||
|
msg.put("entries", entryList);
|
||||||
|
sendRaw("block_registry", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendBlockTextures(List<BlockTexture> textures) {
|
||||||
|
if (textures.isEmpty() || !serverReady) return;
|
||||||
|
for (int start = 0; start < textures.size(); start += BLOCK_TEXTURE_BATCH_SIZE) {
|
||||||
|
int end = Math.min(start + BLOCK_TEXTURE_BATCH_SIZE, textures.size());
|
||||||
|
List<Object> textureList = new ArrayList<>();
|
||||||
|
for (BlockTexture t : textures.subList(start, end)) {
|
||||||
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
|
obj.put("name", t.name);
|
||||||
|
obj.put("dataBase64", Base64.getEncoder().encodeToString(t.pngBytes));
|
||||||
|
textureList.add(obj);
|
||||||
|
}
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "block_textures");
|
||||||
|
msg.put("textures", textureList);
|
||||||
|
sendRaw("block_textures", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int BLOCK_MODEL_BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendBlockModels(List<BlockModelFile> files) {
|
||||||
|
if (files.isEmpty() || !serverReady) return;
|
||||||
|
for (int start = 0; start < files.size(); start += BLOCK_MODEL_BATCH_SIZE) {
|
||||||
|
int end = Math.min(start + BLOCK_MODEL_BATCH_SIZE, files.size());
|
||||||
|
List<Object> entryList = new ArrayList<>();
|
||||||
|
for (BlockModelFile f : files.subList(start, end)) {
|
||||||
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
|
obj.put("kind", f.kind);
|
||||||
|
obj.put("name", f.name);
|
||||||
|
obj.put("json", f.json);
|
||||||
|
entryList.add(obj);
|
||||||
|
}
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "block_models");
|
||||||
|
msg.put("entries", entryList);
|
||||||
|
sendRaw("block_models", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setReadyListener(Runnable onReady) {
|
||||||
|
this.readyListener = onReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setChatListener(ChatListener listener) {
|
||||||
|
this.chatListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setWaypointShareListener(WaypointShareListener listener) {
|
||||||
|
this.waypointShareListener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendRaw(String label, Map<String, Object> message) {
|
private void sendRaw(String label, Map<String, Object> message) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.octoturge.mcmapper.common;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the short code a player reads off their screen and types into the web link page —
|
||||||
|
* see {@code /mcmapper link} on the mod side and {@code link.ts}'s storeLinkCode/redeemLinkCode
|
||||||
|
* on the backend. The mod generates this itself (rather than asking the backend for one) so it
|
||||||
|
* can show it to the player immediately, without waiting on a network round-trip.
|
||||||
|
*/
|
||||||
|
public final class LinkCodeGenerator {
|
||||||
|
// Excludes visually-confusable characters (0/O, 1/I/L) since this gets read off a chat line
|
||||||
|
// and typed back on a keyboard/phone.
|
||||||
|
static final String CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||||
|
private static final int LENGTH = 6;
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private LinkCodeGenerator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String generate() {
|
||||||
|
StringBuilder sb = new StringBuilder(LENGTH);
|
||||||
|
for (int i = 0; i < LENGTH; i++) {
|
||||||
|
sb.append(CHARSET.charAt(RANDOM.nextInt(CHARSET.length())));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.octoturge.mcmapper.common;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a bounded-size, rotating slice of the currently-loaded chunk set to re-read and resend
|
||||||
|
* on each periodic reconciliation sweep (see the plan's "hybrid" change-detection decision) —
|
||||||
|
* catches mutations that never fire a {@code BlockEvent} (world-gen, other mods writing blocks
|
||||||
|
* directly, {@code /fill}, piston pushes into unloaded-at-the-time chunks, etc.) without paying
|
||||||
|
* the cost of re-reading every loaded chunk on every sweep. Pure/loader-agnostic on purpose —
|
||||||
|
* enumerating "currently loaded chunks" stays in each leaf's {@link ChunkAdapter}.
|
||||||
|
*/
|
||||||
|
public class ReconciliationScheduler {
|
||||||
|
private int cursor = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param loadedChunkKeys all currently-loaded chunks, packed as {@code (chunkX << 32) | (chunkZ & 0xFFFFFFFFL)}
|
||||||
|
* (same packing MCMapperMod already uses for its own dirty-chunk sets)
|
||||||
|
* @param maxPerSweep upper bound on how many chunks to return this call
|
||||||
|
* @return up to {@code maxPerSweep} keys to reconcile this sweep; the internal cursor
|
||||||
|
* advances so the next call continues where this one left off, wrapping around once
|
||||||
|
* every key has been covered. Not a strict guarantee if the loaded set changes
|
||||||
|
* between calls (chunks unloading/loading) — best-effort coverage is the point, a
|
||||||
|
* missed chunk just gets picked up on a later sweep or the next event-driven change.
|
||||||
|
*/
|
||||||
|
public List<Long> next(Collection<Long> loadedChunkKeys, int maxPerSweep) {
|
||||||
|
if (loadedChunkKeys.isEmpty() || maxPerSweep <= 0) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Long> sorted = new ArrayList<>(loadedChunkKeys);
|
||||||
|
Collections.sort(sorted);
|
||||||
|
int size = sorted.size();
|
||||||
|
|
||||||
|
// A batch never wraps mid-call — it stops at the end of the current cycle instead of
|
||||||
|
// splicing the start back in, so no key is ever reconciled twice before every other key
|
||||||
|
// has had a turn. This means a batch can come back smaller than maxPerSweep right at a
|
||||||
|
// cycle boundary; the next call resumes at index 0 for a full-size batch again.
|
||||||
|
int start = cursor % size;
|
||||||
|
int count = Math.min(maxPerSweep, size - start);
|
||||||
|
List<Long> batch = new ArrayList<>(sorted.subList(start, start + count));
|
||||||
|
cursor = (start + count) % size;
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,6 @@ public class MapperConfig {
|
|||||||
public boolean playerTrackingEnabled = true;
|
public boolean playerTrackingEnabled = true;
|
||||||
public int deltaFlushIntervalTicks = 20;
|
public int deltaFlushIntervalTicks = 20;
|
||||||
public int reconciliationIntervalTicks = 20 * 60 * 5;
|
public int reconciliationIntervalTicks = 20 * 60 * 5;
|
||||||
|
public int reconciliationChunksPerSweep = 50;
|
||||||
|
public int playerPositionIntervalTicks = 20 * 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.octoturge.mcmapper.common.protocol;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One raw blockstate or model JSON file, extracted by a leaf off its own classloader (Phase 13) —
|
||||||
|
* same classloader-is-already-there rationale as {@link BlockTexture}. {@code kind} is either
|
||||||
|
* {@code "blockstate"} (a {@code assets/<modid>/blockstates/<path>.json} file, keyed by the
|
||||||
|
* block's own registry name) or {@code "model"} (a {@code assets/<modid>/models/block/<path>.json}
|
||||||
|
* file, keyed by {@code "<modid>:block/<path>"} — the same reference shape a blockstate's
|
||||||
|
* {@code "model"} field or another model's {@code "parent"} field uses to point at it). The mod
|
||||||
|
* ships every model file it finds under a mod's {@code models/block/} tree rather than trying to
|
||||||
|
* pick out just the ones a given blockstate needs — a blockstate can reference a model at a path
|
||||||
|
* that doesn't match the block's own registry path 1:1 (e.g. a shared base model reused by several
|
||||||
|
* blocks), and the mod has no JSON parser to work that out itself; that resolution is the
|
||||||
|
* backend's job (see MCMapper-Backend's {@code worker/src/models.rs}).
|
||||||
|
*/
|
||||||
|
public final class BlockModelFile {
|
||||||
|
public final String kind;
|
||||||
|
public final String name;
|
||||||
|
public final String json;
|
||||||
|
|
||||||
|
public BlockModelFile(String kind, String name, String json) {
|
||||||
|
this.kind = kind;
|
||||||
|
this.name = name;
|
||||||
|
this.json = json;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.octoturge.mcmapper.common.protocol;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of a leaf's numeric-blockId -> registry-name dump (Phase 11) — e.g.
|
||||||
|
* {@code (4000, "botania:manapool")}. Sent once per connection via {@code
|
||||||
|
* BackendConnection#sendBlockRegistry}, since a world's id assignments (and its mod list) are
|
||||||
|
* stable for the server's lifetime. Lets the backend eventually resolve the numeric
|
||||||
|
* {@code blockId}/{@code blockMeta} already carried by {@link DeltaEvent}/{@link SectionData}
|
||||||
|
* into a texture, for blocks {@link BlockTexture} shipped a texture for.
|
||||||
|
*/
|
||||||
|
public final class BlockRegistryEntry {
|
||||||
|
public final int id;
|
||||||
|
public final String name;
|
||||||
|
|
||||||
|
public BlockRegistryEntry(int id, String name) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.octoturge.mcmapper.common.protocol;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One block's texture, extracted by a leaf off its own classloader (Phase 11) — Forge doesn't
|
||||||
|
* split client/server jars, so every loaded mod's {@code assets/<modid>/textures/...} is already
|
||||||
|
* sitting on a dedicated server's own classpath, just unused server-side. {@code name} is the
|
||||||
|
* block's registry name (e.g. {@code "botania:manapool"}, matching {@link
|
||||||
|
* BlockRegistryEntry#name}) — a best-effort convention match against the block's own path
|
||||||
|
* segment, not a real blockstate/model JSON resolution (that's Phase 12's job), so not every
|
||||||
|
* block gets one: a leaf skips extraction rather than guessing when no file matches.
|
||||||
|
*/
|
||||||
|
public final class BlockTexture {
|
||||||
|
public final String name;
|
||||||
|
public final byte[] pngBytes;
|
||||||
|
|
||||||
|
public BlockTexture(String name, byte[] pngBytes) {
|
||||||
|
this.name = name;
|
||||||
|
this.pngBytes = pngBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.octoturge.mcmapper.common.protocol;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One online player's throttled position (Phase 7b), block-granularity. Sent as the full current
|
||||||
|
* roster on every flush (not a diff) — same "current state, not a diff" philosophy as
|
||||||
|
* {@link DeltaEvent}'s columns — so a player logging out simply stops appearing in the next
|
||||||
|
* roster rather than needing a separate leave message.
|
||||||
|
*/
|
||||||
|
public class PlayerPosition {
|
||||||
|
public final String uuid;
|
||||||
|
public final String username;
|
||||||
|
public final int x;
|
||||||
|
public final int y;
|
||||||
|
public final int z;
|
||||||
|
|
||||||
|
public PlayerPosition(String uuid, String username, int x, int y, int z) {
|
||||||
|
this.uuid = uuid;
|
||||||
|
this.username = username;
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.z = z;
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.octoturge.mcmapper.common.protocol;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a plain-text chat message that a player's own client-side map mod auto-detects and
|
||||||
|
* offers to import as a waypoint — neither format needs a click-event text component, just the
|
||||||
|
* right string shape in the message body (see the two format-specific methods for sources).
|
||||||
|
* Pure string logic, loader-independent, so it lives in {@code common} and is unit-tested
|
||||||
|
* directly (see common/run-tests.sh) rather than only exercised through a real Forge chat event.
|
||||||
|
*/
|
||||||
|
public final class WaypointChatFormatter {
|
||||||
|
private WaypointChatFormatter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String format(WaypointShare share) {
|
||||||
|
return share.format == WaypointShare.Format.XAERO ? xaeroText(share) : journeyMapText(share);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JourneyMap's own chat-waypoint auto-detect syntax: a message containing
|
||||||
|
* {@code [x:..,y:..,z:..,dim:..,name:..,color:..,delete:..]} is recognized and offered as an
|
||||||
|
* importable waypoint. Source: 1whohears/JourneyMapQOL_1.7.10 (MIT), corroborated by
|
||||||
|
* TeamJM/journeymap issue #465 ("Waypoint Chat option") — both public, no JourneyMap source
|
||||||
|
* copied, just the documented text convention.
|
||||||
|
*/
|
||||||
|
public static String journeyMapText(WaypointShare share) {
|
||||||
|
return "[x:" + share.x + ",y:" + share.y + ",z:" + share.z + ",dim:" + share.dimension
|
||||||
|
+ ",name:" + share.name + ",color:" + share.color + ",delete:false]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int XAERO_NAME_MAX = 32;
|
||||||
|
private static final int XAERO_MARKER_MAX = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xaero's Minimap intercepts chat messages starting with {@code xaero-waypoint:} and offers
|
||||||
|
* to add the encoded waypoint. Xaero's Minimap/Worldmap are closed-source, so this schema is
|
||||||
|
* *not* from Xaero's own docs (none exist) — it's community-reverse-engineered, per the gist
|
||||||
|
* "my rough interpretation on xaero-waypoint schema" by macimas
|
||||||
|
* (gist.github.com/macimas/937a392be075b1bce7a2ae69ea933ef5): nine colon-separated fields,
|
||||||
|
* {@code name:marker:x:y:z:color:use_yaw:yaw:dimension}, where {@code color} is 0-15
|
||||||
|
* (Minecraft's chat color codes in decimal) and {@code dimension} is empty (defaults to the
|
||||||
|
* player's current dimension) or {@code Internal-<overworld|the-nether|the-end>-waypoints}.
|
||||||
|
* Flagged as best-effort: even community sources disagree on field count for this
|
||||||
|
* undocumented format, so treat this as a starting point to verify against a real Xaero
|
||||||
|
* install rather than a guaranteed-correct implementation.
|
||||||
|
*/
|
||||||
|
public static String xaeroText(WaypointShare share) {
|
||||||
|
String name = share.name.length() > XAERO_NAME_MAX ? share.name.substring(0, XAERO_NAME_MAX) : share.name;
|
||||||
|
String marker = name.length() > XAERO_MARKER_MAX ? name.substring(0, XAERO_MARKER_MAX) : name;
|
||||||
|
marker = marker.toUpperCase();
|
||||||
|
|
||||||
|
return "xaero-waypoint:" + name + ":" + marker + ":" + share.x + ":" + share.y + ":" + share.z
|
||||||
|
+ ":" + nearestChatColorCode(share.color) + ":false:0:" + xaeroDimensionSet(share.dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String xaeroDimensionSet(String dimension) {
|
||||||
|
switch (dimension) {
|
||||||
|
case "0":
|
||||||
|
return "Internal-overworld-waypoints";
|
||||||
|
case "-1":
|
||||||
|
return "Internal-the-nether-waypoints";
|
||||||
|
case "1":
|
||||||
|
return "Internal-the-end-waypoints";
|
||||||
|
default:
|
||||||
|
// Anything else (a modded dimension id) isn't safely mappable to one of Xaero's
|
||||||
|
// three built-in waypoint sets — leave it empty so Xaero falls back to whichever
|
||||||
|
// dimension the receiving player is currently in.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 16 standard Minecraft chat/formatting colors, index == the decimal code Xaero expects.
|
||||||
|
private static final int[] CHAT_COLORS = {
|
||||||
|
0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xFFAA00, 0xAAAAAA,
|
||||||
|
0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nearest of the 16 Minecraft chat colors to an arbitrary "#RRGGBB" hex string, by squared RGB distance. */
|
||||||
|
public static int nearestChatColorCode(String hex) {
|
||||||
|
int rgb = Integer.parseInt(hex.replace("#", ""), 16);
|
||||||
|
int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
|
||||||
|
|
||||||
|
int bestCode = 0;
|
||||||
|
long bestDist = Long.MAX_VALUE;
|
||||||
|
for (int i = 0; i < CHAT_COLORS.length; i++) {
|
||||||
|
int cr = (CHAT_COLORS[i] >> 16) & 0xFF, cg = (CHAT_COLORS[i] >> 8) & 0xFF, cb = CHAT_COLORS[i] & 0xFF;
|
||||||
|
long dist = (long) (r - cr) * (r - cr) + (long) (g - cg) * (g - cg) + (long) (b - cb) * (b - cb);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestCode = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.octoturge.mcmapper.common;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class LinkCodeGeneratorTest {
|
||||||
|
private static int passed = 0;
|
||||||
|
private static int failed = 0;
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
test("generate() returns a 6-character code", LinkCodeGeneratorTest::isSixCharacters);
|
||||||
|
test("generate() only uses the unambiguous charset", LinkCodeGeneratorTest::usesExpectedCharset);
|
||||||
|
test("generate() doesn't repeat across many calls", LinkCodeGeneratorTest::isNotObviouslyRepeating);
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println(passed + " passed, " + failed + " failed");
|
||||||
|
if (failed > 0) {
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void isSixCharacters() {
|
||||||
|
String code = LinkCodeGenerator.generate();
|
||||||
|
assertEquals(6, code.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void usesExpectedCharset() {
|
||||||
|
String code = LinkCodeGenerator.generate();
|
||||||
|
for (char c : code.toCharArray()) {
|
||||||
|
if (LinkCodeGenerator.CHARSET.indexOf(c) < 0) {
|
||||||
|
throw new AssertionError("code '" + code + "' contains char '" + c + "' outside the expected charset");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void isNotObviouslyRepeating() {
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
for (int i = 0; i < 200; i++) {
|
||||||
|
seen.add(LinkCodeGenerator.generate());
|
||||||
|
}
|
||||||
|
// Not a proof of uniqueness (codes aren't guaranteed collision-free), but 200 calls
|
||||||
|
// landing on fewer than ~195 distinct values would indicate a broken/degenerate RNG.
|
||||||
|
if (seen.size() < 195) {
|
||||||
|
throw new AssertionError("expected close to 200 distinct codes from 200 calls, got " + seen.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void test(String name, Runnable body) {
|
||||||
|
try {
|
||||||
|
body.run();
|
||||||
|
passed++;
|
||||||
|
System.out.println("PASS " + name);
|
||||||
|
} catch (AssertionError e) {
|
||||||
|
failed++;
|
||||||
|
System.out.println("FAIL " + name + " — " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertEquals(Object expected, Object actual) {
|
||||||
|
if (expected == null ? actual != null : !expected.equals(actual)) {
|
||||||
|
throw new AssertionError("expected <" + expected + "> but got <" + actual + ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.octoturge.mcmapper.common;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class ReconciliationSchedulerTest {
|
||||||
|
private static int passed = 0;
|
||||||
|
private static int failed = 0;
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
test("empty loaded set returns an empty batch", ReconciliationSchedulerTest::emptySetReturnsEmpty);
|
||||||
|
test("maxPerSweep of zero (or negative) returns an empty batch", ReconciliationSchedulerTest::zeroMaxReturnsEmpty);
|
||||||
|
test("a batch is never larger than maxPerSweep", ReconciliationSchedulerTest::batchNeverExceedsMax);
|
||||||
|
test("maxPerSweep >= set size returns every key exactly once", ReconciliationSchedulerTest::coversWholeSetInOneCallWhenItFits);
|
||||||
|
test("repeated calls eventually cover every key exactly once per cycle, then wrap", ReconciliationSchedulerTest::rotatesThroughWholeSetWithoutRepeatsWithinACycle);
|
||||||
|
test("the cursor wraps around the end of the set back to the start", ReconciliationSchedulerTest::wrapsAroundTheEnd);
|
||||||
|
test("a shrinking loaded set doesn't throw or return stale keys", ReconciliationSchedulerTest::toleratesShrinkingSet);
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println(passed + " passed, " + failed + " failed");
|
||||||
|
if (failed > 0) {
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void emptySetReturnsEmpty() {
|
||||||
|
ReconciliationScheduler scheduler = new ReconciliationScheduler();
|
||||||
|
List<Long> batch = scheduler.next(new ArrayList<>(), 5);
|
||||||
|
assertEquals(0, batch.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void zeroMaxReturnsEmpty() {
|
||||||
|
ReconciliationScheduler scheduler = new ReconciliationScheduler();
|
||||||
|
Set<Long> loaded = new LinkedHashSet<>(Arrays.asList(1L, 2L, 3L));
|
||||||
|
assertEquals(0, scheduler.next(loaded, 0).size());
|
||||||
|
assertEquals(0, scheduler.next(loaded, -1).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void batchNeverExceedsMax() {
|
||||||
|
ReconciliationScheduler scheduler = new ReconciliationScheduler();
|
||||||
|
Set<Long> loaded = new LinkedHashSet<>();
|
||||||
|
for (long i = 0; i < 50; i++) loaded.add(i);
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
List<Long> batch = scheduler.next(loaded, 7);
|
||||||
|
if (batch.size() > 7) {
|
||||||
|
throw new AssertionError("batch size " + batch.size() + " exceeds maxPerSweep 7");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void coversWholeSetInOneCallWhenItFits() {
|
||||||
|
ReconciliationScheduler scheduler = new ReconciliationScheduler();
|
||||||
|
Set<Long> loaded = new LinkedHashSet<>(Arrays.asList(10L, 20L, 30L));
|
||||||
|
List<Long> batch = scheduler.next(loaded, 100);
|
||||||
|
assertEquals(3, batch.size());
|
||||||
|
assertEquals(new LinkedHashSet<>(Arrays.asList(10L, 20L, 30L)), new LinkedHashSet<>(batch));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rotatesThroughWholeSetWithoutRepeatsWithinACycle() {
|
||||||
|
ReconciliationScheduler scheduler = new ReconciliationScheduler();
|
||||||
|
Set<Long> loaded = new LinkedHashSet<>();
|
||||||
|
for (long i = 0; i < 10; i++) loaded.add(i);
|
||||||
|
|
||||||
|
Set<Long> seenThisCycle = new LinkedHashSet<>();
|
||||||
|
// 10 keys, batches of 3 -> 4 calls (3+3+3+1) to complete exactly one cycle.
|
||||||
|
for (int call = 0; call < 4; call++) {
|
||||||
|
List<Long> batch = scheduler.next(loaded, 3);
|
||||||
|
for (Long key : batch) {
|
||||||
|
if (!seenThisCycle.add(key)) {
|
||||||
|
throw new AssertionError("key " + key + " reconciled twice within a single cycle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(10, seenThisCycle.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wrapsAroundTheEnd() {
|
||||||
|
ReconciliationScheduler scheduler = new ReconciliationScheduler();
|
||||||
|
Set<Long> loaded = new LinkedHashSet<>(Arrays.asList(1L, 2L, 3L, 4L, 5L));
|
||||||
|
scheduler.next(loaded, 5); // consumes the whole set in one call — cursor wraps to 0
|
||||||
|
List<Long> batch = scheduler.next(loaded, 2); // should start a fresh cycle from the top
|
||||||
|
assertEquals(2, batch.size());
|
||||||
|
if (!batch.contains(1L) || !batch.contains(2L)) {
|
||||||
|
throw new AssertionError("expected the cursor to wrap back to the start of the set, got " + batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void toleratesShrinkingSet() {
|
||||||
|
ReconciliationScheduler scheduler = new ReconciliationScheduler();
|
||||||
|
Set<Long> loaded = new LinkedHashSet<>();
|
||||||
|
for (long i = 0; i < 20; i++) loaded.add(i);
|
||||||
|
scheduler.next(loaded, 15);
|
||||||
|
|
||||||
|
Set<Long> shrunk = new LinkedHashSet<>(Arrays.asList(0L, 1L, 2L));
|
||||||
|
List<Long> batch = scheduler.next(shrunk, 15); // must not throw despite cursor now out of range
|
||||||
|
assertEquals(3, batch.size());
|
||||||
|
for (Long key : batch) {
|
||||||
|
if (!shrunk.contains(key)) {
|
||||||
|
throw new AssertionError("batch contained a key no longer in the loaded set: " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void test(String name, Runnable body) {
|
||||||
|
try {
|
||||||
|
body.run();
|
||||||
|
passed++;
|
||||||
|
System.out.println("PASS " + name);
|
||||||
|
} catch (AssertionError e) {
|
||||||
|
failed++;
|
||||||
|
System.out.println("FAIL " + name + " — " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertEquals(Object expected, Object actual) {
|
||||||
|
if (expected == null ? actual != null : !expected.equals(actual)) {
|
||||||
|
throw new AssertionError("expected <" + expected + "> but got <" + actual + ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.octoturge.mcmapper.common.json;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled test runner (no JUnit) for {@link MiniJson}, following the same
|
||||||
|
* "common/ must compile and run standalone under plain javac" constraint as the class under
|
||||||
|
* test — see common/README.md for why, and common/run-tests.sh to actually run this.
|
||||||
|
*/
|
||||||
|
public class MiniJsonTest {
|
||||||
|
private static int passed = 0;
|
||||||
|
private static int failed = 0;
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
test("writeObject encodes strings, numbers, booleans, and null", MiniJsonTest::writesScalars);
|
||||||
|
test("writeObject escapes special characters in strings", MiniJsonTest::escapesStrings);
|
||||||
|
test("writeObject encodes nested objects and arrays", MiniJsonTest::writesNestedStructures);
|
||||||
|
test("parse reads a hello_ack-shaped object", MiniJsonTest::parsesHelloAck);
|
||||||
|
test("parse reads nested arrays of objects (a columns message)", MiniJsonTest::parsesColumnsMessage);
|
||||||
|
test("parse unescapes strings", MiniJsonTest::parseUnescapesStrings);
|
||||||
|
test("parse reads numbers as Double, including negatives", MiniJsonTest::parsesNumbers);
|
||||||
|
test("write-then-parse round-trips a columns-shaped message", MiniJsonTest::roundTripsColumnsMessage);
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println(passed + " passed, " + failed + " failed");
|
||||||
|
if (failed > 0) {
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writesScalars() {
|
||||||
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
|
obj.put("type", "hello");
|
||||||
|
obj.put("ok", true);
|
||||||
|
obj.put("count", 3.0);
|
||||||
|
obj.put("missing", null);
|
||||||
|
assertEquals("{\"type\":\"hello\",\"ok\":true,\"count\":3.0,\"missing\":null}", MiniJson.writeObject(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void escapesStrings() {
|
||||||
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
|
obj.put("text", "line1\nline2\t\"quoted\"\\backslash");
|
||||||
|
String json = MiniJson.writeObject(obj);
|
||||||
|
assertEquals("{\"text\":\"line1\\nline2\\t\\\"quoted\\\"\\\\backslash\"}", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writesNestedStructures() {
|
||||||
|
Map<String, Object> column = new LinkedHashMap<>();
|
||||||
|
column.put("x", 1.0);
|
||||||
|
column.put("z", 2.0);
|
||||||
|
List<Object> columns = new ArrayList<>();
|
||||||
|
columns.add(column);
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "columns");
|
||||||
|
msg.put("columns", columns);
|
||||||
|
assertEquals("{\"type\":\"columns\",\"columns\":[{\"x\":1.0,\"z\":2.0}]}", MiniJson.writeObject(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void parsesHelloAck() {
|
||||||
|
Object parsed = MiniJson.parse("{\"type\":\"hello_ack\",\"ok\":true,\"serverId\":\"abc-123\"}");
|
||||||
|
Map<String, Object> obj = (Map<String, Object>) parsed;
|
||||||
|
assertEquals("hello_ack", obj.get("type"));
|
||||||
|
assertEquals(Boolean.TRUE, obj.get("ok"));
|
||||||
|
assertEquals("abc-123", obj.get("serverId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void parsesColumnsMessage() {
|
||||||
|
String json = "{\"type\":\"columns\",\"dimension\":0,\"columns\":["
|
||||||
|
+ "{\"x\":1,\"z\":2,\"height\":64,\"blockId\":2,\"blockMeta\":0},"
|
||||||
|
+ "{\"x\":3,\"z\":4,\"height\":65,\"blockId\":3,\"blockMeta\":0}]}";
|
||||||
|
Map<String, Object> obj = (Map<String, Object>) MiniJson.parse(json);
|
||||||
|
List<Object> columns = (List<Object>) obj.get("columns");
|
||||||
|
assertEquals(2, columns.size());
|
||||||
|
Map<String, Object> first = (Map<String, Object>) columns.get(0);
|
||||||
|
assertEquals(1.0, first.get("x"));
|
||||||
|
assertEquals(64.0, first.get("height"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void parseUnescapesStrings() {
|
||||||
|
Map<String, Object> obj = (Map<String, Object>) MiniJson.parse("{\"error\":\"bad \\\"token\\\"\\nvalue\"}");
|
||||||
|
assertEquals("bad \"token\"\nvalue", obj.get("error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void parsesNumbers() {
|
||||||
|
Map<String, Object> obj = (Map<String, Object>) MiniJson.parse("{\"a\":-5,\"b\":3.5,\"c\":0}");
|
||||||
|
assertEquals(-5.0, obj.get("a"));
|
||||||
|
assertEquals(3.5, obj.get("b"));
|
||||||
|
assertEquals(0.0, obj.get("c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void roundTripsColumnsMessage() {
|
||||||
|
// Mirrors exactly what DefaultBackendConnection.sendDeltas() builds — the real
|
||||||
|
// regression risk isn't MiniJson in isolation, it's this shape drifting silently.
|
||||||
|
Map<String, Object> col = new LinkedHashMap<>();
|
||||||
|
col.put("x", 7.0);
|
||||||
|
col.put("z", -3.0);
|
||||||
|
col.put("height", 70.0);
|
||||||
|
col.put("blockId", 2.0);
|
||||||
|
col.put("blockMeta", 0.0);
|
||||||
|
List<Object> columns = new ArrayList<>();
|
||||||
|
columns.add(col);
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", "columns");
|
||||||
|
msg.put("dimension", 0.0);
|
||||||
|
msg.put("columns", columns);
|
||||||
|
|
||||||
|
String json = MiniJson.writeObject(msg);
|
||||||
|
Map<String, Object> reparsed = (Map<String, Object>) MiniJson.parse(json);
|
||||||
|
assertEquals("columns", reparsed.get("type"));
|
||||||
|
assertEquals(0.0, reparsed.get("dimension"));
|
||||||
|
List<Object> reparsedColumns = (List<Object>) reparsed.get("columns");
|
||||||
|
Map<String, Object> reparsedCol = (Map<String, Object>) reparsedColumns.get(0);
|
||||||
|
assertEquals(7.0, reparsedCol.get("x"));
|
||||||
|
assertEquals(-3.0, reparsedCol.get("z"));
|
||||||
|
assertEquals(2.0, reparsedCol.get("blockId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void test(String name, Runnable body) {
|
||||||
|
try {
|
||||||
|
body.run();
|
||||||
|
passed++;
|
||||||
|
System.out.println("PASS " + name);
|
||||||
|
} catch (AssertionError e) {
|
||||||
|
failed++;
|
||||||
|
System.out.println("FAIL " + name + " — " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertEquals(Object expected, Object actual) {
|
||||||
|
if (expected == null ? actual != null : !expected.equals(actual)) {
|
||||||
|
throw new AssertionError("expected <" + expected + "> but got <" + actual + ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.octoturge.mcmapper.common.protocol;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Hand-rolled test runner (no JUnit) — see common/run-tests.sh. */
|
||||||
|
public class WaypointChatFormatterTest {
|
||||||
|
private static final List<String> failures = new ArrayList<>();
|
||||||
|
private static int passed = 0;
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
journeyMapProducesTheDocumentedBracketSyntax();
|
||||||
|
journeyMapHandlesNegativeCoordinates();
|
||||||
|
xaeroProducesTheDocumentedColonSyntaxForOverworld();
|
||||||
|
xaeroMapsKnownDimensionsToInternalWaypointSets();
|
||||||
|
xaeroDefaultsUnknownDimensionsToEmpty();
|
||||||
|
xaeroTruncatesLongNamesAndDerivesAnUppercaseMarker();
|
||||||
|
xaeroPicksTheNearestOfTheSixteenChatColorCodes();
|
||||||
|
formatDispatchesOnTheShareFormat();
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println((passed) + " passed, " + failures.size() + " failed");
|
||||||
|
if (!failures.isEmpty()) System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void journeyMapProducesTheDocumentedBracketSyntax() {
|
||||||
|
WaypointShare share = new WaypointShare("Base", 105, 72, -723, "0", "#B311CF", WaypointShare.Format.JOURNEYMAP);
|
||||||
|
check(
|
||||||
|
"journeyMap produces the documented bracket syntax",
|
||||||
|
WaypointChatFormatter.journeyMapText(share),
|
||||||
|
"[x:105,y:72,z:-723,dim:0,name:Base,color:#B311CF,delete:false]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void journeyMapHandlesNegativeCoordinates() {
|
||||||
|
WaypointShare share = new WaypointShare("Deep", -10, 5, -20, "-1", "#FFFFFF", WaypointShare.Format.JOURNEYMAP);
|
||||||
|
check(
|
||||||
|
"journeyMap handles negative x/y/z",
|
||||||
|
WaypointChatFormatter.journeyMapText(share),
|
||||||
|
"[x:-10,y:5,z:-20,dim:-1,name:Deep,color:#FFFFFF,delete:false]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void xaeroProducesTheDocumentedColonSyntaxForOverworld() {
|
||||||
|
WaypointShare share = new WaypointShare("Base", 105, 72, -723, "0", "#FF5555", WaypointShare.Format.XAERO);
|
||||||
|
check(
|
||||||
|
"xaero produces the documented colon syntax for the overworld",
|
||||||
|
WaypointChatFormatter.xaeroText(share),
|
||||||
|
"xaero-waypoint:Base:BA:105:72:-723:12:false:0:Internal-overworld-waypoints");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void xaeroMapsKnownDimensionsToInternalWaypointSets() {
|
||||||
|
WaypointShare nether = new WaypointShare("N", 1, 2, 3, "-1", "#FFFFFF", WaypointShare.Format.XAERO);
|
||||||
|
check("xaero maps dimension -1 to the nether waypoint set",
|
||||||
|
WaypointChatFormatter.xaeroText(nether).endsWith(":Internal-the-nether-waypoints"), true);
|
||||||
|
|
||||||
|
WaypointShare end = new WaypointShare("E", 1, 2, 3, "1", "#FFFFFF", WaypointShare.Format.XAERO);
|
||||||
|
check("xaero maps dimension 1 to the end waypoint set",
|
||||||
|
WaypointChatFormatter.xaeroText(end).endsWith(":Internal-the-end-waypoints"), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void xaeroDefaultsUnknownDimensionsToEmpty() {
|
||||||
|
WaypointShare modded = new WaypointShare("M", 1, 2, 3, "42", "#FFFFFF", WaypointShare.Format.XAERO);
|
||||||
|
check("xaero leaves the dimension field empty for an unrecognized dimension id (defaults to the player's current dimension)",
|
||||||
|
WaypointChatFormatter.xaeroText(modded).endsWith(":"), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void xaeroTruncatesLongNamesAndDerivesAnUppercaseMarker() {
|
||||||
|
String longName = "ThisWaypointNameIsDefinitelyLongerThanThirtyTwoCharacters";
|
||||||
|
WaypointShare share = new WaypointShare(longName, 0, 0, 0, "0", "#FFFFFF", WaypointShare.Format.XAERO);
|
||||||
|
String text = WaypointChatFormatter.xaeroText(share);
|
||||||
|
String[] fields = text.split(":");
|
||||||
|
check("xaero truncates the name to 32 characters", fields[1].length() <= 32, true);
|
||||||
|
check("xaero derives a 2-character uppercase marker from the name", fields[2], "TH");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void xaeroPicksTheNearestOfTheSixteenChatColorCodes() {
|
||||||
|
check("pure red maps to chat color code 12 (red)", WaypointChatFormatter.nearestChatColorCode("#FF5555"), 12);
|
||||||
|
check("pure white maps to chat color code 15 (white)", WaypointChatFormatter.nearestChatColorCode("#FFFFFF"), 15);
|
||||||
|
check("pure black maps to chat color code 0 (black)", WaypointChatFormatter.nearestChatColorCode("#000000"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void formatDispatchesOnTheShareFormat() {
|
||||||
|
WaypointShare jm = new WaypointShare("A", 1, 2, 3, "0", "#FFFFFF", WaypointShare.Format.JOURNEYMAP);
|
||||||
|
check("format() dispatches to journeyMapText for JOURNEYMAP",
|
||||||
|
WaypointChatFormatter.format(jm).startsWith("["), true);
|
||||||
|
|
||||||
|
WaypointShare xaero = new WaypointShare("A", 1, 2, 3, "0", "#FFFFFF", WaypointShare.Format.XAERO);
|
||||||
|
check("format() dispatches to xaeroText for XAERO",
|
||||||
|
WaypointChatFormatter.format(xaero).startsWith("xaero-waypoint:"), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void check(String name, Object actual, Object expected) {
|
||||||
|
boolean ok = actual == null ? expected == null : actual.equals(expected);
|
||||||
|
if (ok) {
|
||||||
|
passed++;
|
||||||
|
System.out.println("PASS " + name);
|
||||||
|
} else {
|
||||||
|
failures.add(name);
|
||||||
|
System.out.println("FAIL " + name + " -- expected <" + expected + "> but got <" + actual + ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package com.octoturge.mcmapper.forge1122;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockTexture;
|
||||||
|
import net.minecraft.block.Block;
|
||||||
|
import net.minecraft.util.ResourceLocation;
|
||||||
|
import net.minecraftforge.fml.common.Loader;
|
||||||
|
import net.minecraftforge.fml.common.ModContainer;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 11: best-effort classloader extraction of block textures + the numeric-id -> registry-
|
||||||
|
* name mapping needed to make sense of them on the backend (see {@link BlockRegistryEntry}/
|
||||||
|
* {@link BlockTexture}'s javadocs). Forge doesn't split client/server jars, so every loaded mod's
|
||||||
|
* texture assets are already sitting on this dedicated server's own classpath — {@code
|
||||||
|
* Block.REGISTRY} gives every registered block's numeric id + registry name (same {@code
|
||||||
|
* Block.getIdFromBlock} used for the existing column/section wire encoding, see
|
||||||
|
* Forge1122ChunkAdapter), and the JVM's own merged mod classloader can load
|
||||||
|
* {@code assets/<modid>/textures/blocks/<path>.png} directly with no jar-file bookkeeping.
|
||||||
|
*
|
||||||
|
* This is a *convention* match (the registry name's path segment as the texture filename), not a
|
||||||
|
* real blockstate/model JSON resolution — {@link #extractModels} (Phase 13) is what feeds that,
|
||||||
|
* on the backend side (see MCMapper-Backend's {@code worker/src/models.rs}). A block whose texture
|
||||||
|
* doesn't match this convention (most non-cube blocks) simply isn't found by {@link
|
||||||
|
* #extractTextures} and is skipped there — no worse than pre-Phase-11 behavior for that block.
|
||||||
|
*/
|
||||||
|
final class BlockAssetExtractor {
|
||||||
|
private BlockAssetExtractor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<BlockRegistryEntry> extractRegistry() {
|
||||||
|
List<BlockRegistryEntry> entries = new ArrayList<>();
|
||||||
|
for (Block block : Block.REGISTRY) {
|
||||||
|
ResourceLocation name = block.getRegistryName();
|
||||||
|
if (name == null) continue;
|
||||||
|
entries.add(new BlockRegistryEntry(Block.getIdFromBlock(block), name.toString()));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<BlockTexture> extractTextures(Logger logger) {
|
||||||
|
ClassLoader classLoader = BlockAssetExtractor.class.getClassLoader();
|
||||||
|
List<BlockTexture> textures = new ArrayList<>();
|
||||||
|
int attempted = 0;
|
||||||
|
for (Block block : Block.REGISTRY) {
|
||||||
|
ResourceLocation name = block.getRegistryName();
|
||||||
|
if (name == null) continue;
|
||||||
|
attempted++;
|
||||||
|
// ResourceLocation's domain/path accessor name varies by MCP mapping version (e.g.
|
||||||
|
// getResourceDomain/getResourcePath vs. getNamespace/getPath) — toString()'s
|
||||||
|
// "modid:path" shape is stable across all of them, so split on that instead.
|
||||||
|
String full = name.toString();
|
||||||
|
int colon = full.indexOf(':');
|
||||||
|
String modid = colon >= 0 ? full.substring(0, colon) : full;
|
||||||
|
String path = colon >= 0 ? full.substring(colon + 1) : full;
|
||||||
|
byte[] png = readTexture(classLoader, modid, path);
|
||||||
|
if (png != null) textures.add(new BlockTexture(full, png));
|
||||||
|
}
|
||||||
|
logger.info("MCMapper (1.12.2 leaf) extracted " + textures.size() + "/" + attempted
|
||||||
|
+ " block textures for Phase 11 texture-averaged rendering (best-effort convention match)");
|
||||||
|
return textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readTexture(ClassLoader classLoader, String modid, String path) {
|
||||||
|
// 1.12.2 uses textures/blocks/ (plural) — see the backend's block_names.rs doc comment
|
||||||
|
// for the 1.13+ textures/block/ rename, tried second in case a mod ships modern-style paths.
|
||||||
|
byte[] png = readResource(classLoader, "assets/" + modid + "/textures/blocks/" + path + ".png");
|
||||||
|
if (png != null) return png;
|
||||||
|
return readResource(classLoader, "assets/" + modid + "/textures/block/" + path + ".png");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readResource(ClassLoader classLoader, String resourcePath) {
|
||||||
|
try (InputStream in = classLoader.getResourceAsStream(resourcePath)) {
|
||||||
|
if (in == null) return null;
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
byte[] buf = new byte[4096];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
|
||||||
|
return out.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 13: ships every {@code assets/<modid>/blockstates/*.json} and {@code
|
||||||
|
* assets/<modid>/models/block/**\/*.json} file for every currently-active mod, keyed the same
|
||||||
|
* way {@code worker/src/models.rs}'s {@code ModelRegistry::build} expects — see {@link
|
||||||
|
* BlockModelFile}'s javadoc for why this ships every model file rather than trying to resolve
|
||||||
|
* which ones a blockstate actually needs. Walks each mod's source (a jar in production, a raw
|
||||||
|
* exploded directory when running from an IDE/dev environment — {@code ModContainer#getSource}
|
||||||
|
* covers both) directly rather than the classloader, since listing a directory's contents
|
||||||
|
* (unlike reading one known file by path, as {@link #extractTextures} does) isn't something
|
||||||
|
* {@code ClassLoader#getResourceAsStream} can do.
|
||||||
|
*/
|
||||||
|
static List<BlockModelFile> extractModels(Logger logger) {
|
||||||
|
List<BlockModelFile> files = new ArrayList<>();
|
||||||
|
for (ModContainer mod : Loader.instance().getActiveModList()) {
|
||||||
|
File source = mod.getSource();
|
||||||
|
if (source == null) continue;
|
||||||
|
if (source.isFile()) {
|
||||||
|
extractModelsFromJar(source, mod.getModId(), files);
|
||||||
|
} else if (source.isDirectory()) {
|
||||||
|
extractModelsFromDirectory(source, mod.getModId(), files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info("MCMapper (1.12.2 leaf) extracted " + files.size()
|
||||||
|
+ " blockstate/model JSON files for Phase 13 non-cube model rendering");
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void extractModelsFromJar(File jarFile, String modid, List<BlockModelFile> out) {
|
||||||
|
try (ZipFile zip = new ZipFile(jarFile)) {
|
||||||
|
Enumeration<? extends ZipEntry> entries = zip.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
ZipEntry entry = entries.nextElement();
|
||||||
|
if (entry.isDirectory()) continue;
|
||||||
|
ModelPath parsed = classify(modid, entry.getName());
|
||||||
|
if (parsed == null) continue;
|
||||||
|
byte[] json = readZipEntry(zip, entry);
|
||||||
|
if (json != null) out.add(new BlockModelFile(parsed.kind, parsed.name, new String(json, StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Best-effort, matching readTexture's convention — skip this mod's models entirely.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readZipEntry(ZipFile zip, ZipEntry entry) {
|
||||||
|
try (InputStream in = zip.getInputStream(entry)) {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
byte[] buf = new byte[4096];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
|
||||||
|
return out.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void extractModelsFromDirectory(File modRoot, String modid, List<BlockModelFile> out) {
|
||||||
|
File assetsDir = new File(modRoot, "assets/" + modid);
|
||||||
|
if (assetsDir.isDirectory()) walkDirectory(assetsDir, "assets/" + modid + "/", modid, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void walkDirectory(File dir, String relPrefix, String modid, List<BlockModelFile> out) {
|
||||||
|
File[] children = dir.listFiles();
|
||||||
|
if (children == null) return;
|
||||||
|
for (File child : children) {
|
||||||
|
String rel = relPrefix + child.getName();
|
||||||
|
if (child.isDirectory()) {
|
||||||
|
walkDirectory(child, rel + "/", modid, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ModelPath parsed = classify(modid, rel);
|
||||||
|
if (parsed == null) continue;
|
||||||
|
try {
|
||||||
|
byte[] json = Files.readAllBytes(child.toPath());
|
||||||
|
out.add(new BlockModelFile(parsed.kind, parsed.name, new String(json, StandardCharsets.UTF_8)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Best-effort — skip this one file.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies one archive/directory entry path (e.g. {@code "assets/botania/blockstates/
|
||||||
|
* manapool.json"}) into a wire-ready {@code (kind, name)} pair, or {@code null} if it's not a
|
||||||
|
* blockstate/model JSON file for this mod. Blockstates are matched flat (no subfolders, matching
|
||||||
|
* vanilla's own layout); models are matched recursively under {@code models/block/} since mods
|
||||||
|
* are free to nest them (e.g. a shared {@code models/block/base/} folder).
|
||||||
|
*/
|
||||||
|
private static ModelPath classify(String modid, String entryPath) {
|
||||||
|
String prefix = "assets/" + modid + "/";
|
||||||
|
if (!entryPath.startsWith(prefix) || !entryPath.endsWith(".json")) return null;
|
||||||
|
String rest = entryPath.substring(prefix.length());
|
||||||
|
if (rest.startsWith("blockstates/") && rest.indexOf('/', "blockstates/".length()) < 0) {
|
||||||
|
String path = rest.substring("blockstates/".length(), rest.length() - ".json".length());
|
||||||
|
return new ModelPath("blockstate", modid + ":" + path);
|
||||||
|
}
|
||||||
|
if (rest.startsWith("models/block/")) {
|
||||||
|
String path = rest.substring("models/block/".length(), rest.length() - ".json".length());
|
||||||
|
return new ModelPath("model", modid + ":block/" + path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ModelPath {
|
||||||
|
final String kind;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
ModelPath(String kind, String name) {
|
||||||
|
this.kind = kind;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.octoturge.mcmapper.forge1122;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.ChatBridge;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointChatFormatter;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointShare;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.util.text.TextComponentString;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
public class Forge1122ChatBridge implements ChatBridge {
|
||||||
|
private final MinecraftServer server;
|
||||||
|
private final Logger logger;
|
||||||
|
|
||||||
|
public Forge1122ChatBridge(MinecraftServer server, Logger logger) {
|
||||||
|
this.server = server;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void injectWebChatMessage(String displayName, String message) {
|
||||||
|
server.getPlayerList().sendMessage(new TextComponentString("[Web] " + displayName + ": " + message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void injectWaypointShare(WaypointShare waypoint) {
|
||||||
|
// Neither JourneyMap's nor Xaero's chat-waypoint syntax needs a click-event component —
|
||||||
|
// both client mods auto-detect the right plain-text shape in a normal chat message (see
|
||||||
|
// WaypointChatFormatter's javadoc for sources), so a broadcast TextComponentString is
|
||||||
|
// enough, same as injectWebChatMessage above.
|
||||||
|
server.getPlayerList().sendMessage(new TextComponentString(WaypointChatFormatter.format(waypoint)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -10,6 +10,7 @@ import net.minecraft.world.World;
|
|||||||
import net.minecraft.world.WorldServer;
|
import net.minecraft.world.WorldServer;
|
||||||
import net.minecraft.world.chunk.Chunk;
|
import net.minecraft.world.chunk.Chunk;
|
||||||
import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
|
import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
|
||||||
|
import net.minecraft.world.gen.ChunkProviderServer;
|
||||||
import net.minecraftforge.common.MinecraftForge;
|
import net.minecraftforge.common.MinecraftForge;
|
||||||
import net.minecraftforge.common.util.BlockSnapshot;
|
import net.minecraftforge.common.util.BlockSnapshot;
|
||||||
import net.minecraftforge.event.world.BlockEvent;
|
import net.minecraftforge.event.world.BlockEvent;
|
||||||
@@ -101,6 +102,15 @@ public class Forge1122ChunkAdapter implements ChunkAdapter {
|
|||||||
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
|
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> loadedChunkKeys() {
|
||||||
|
List<Long> keys = new ArrayList<>();
|
||||||
|
for (Chunk chunk : ((ChunkProviderServer) world.getChunkProvider()).getLoadedChunks()) {
|
||||||
|
keys.add((((long) chunk.x) << 32) | (chunk.z & 0xFFFFFFFFL));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code BlockEvent.BreakEvent} fires *before* the block is actually removed (it's
|
* {@code BlockEvent.BreakEvent} fires *before* the block is actually removed (it's
|
||||||
* cancellable), so reading world state synchronously inside that handler would see the
|
* cancellable), so reading world state synchronously inside that handler would see the
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.octoturge.mcmapper.forge1122;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.BackendConnection;
|
||||||
|
import com.octoturge.mcmapper.common.LinkCodeGenerator;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.LinkRequest;
|
||||||
|
import net.minecraft.command.CommandBase;
|
||||||
|
import net.minecraft.command.CommandException;
|
||||||
|
import net.minecraft.command.ICommandSender;
|
||||||
|
import net.minecraft.entity.player.EntityPlayerMP;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.util.text.TextComponentString;
|
||||||
|
|
||||||
|
/** {@code /mcmapper link} — see LinkRequest.java's javadoc and MCMapper-Backend's link.ts. */
|
||||||
|
public class LinkCommand extends CommandBase {
|
||||||
|
private final BackendConnection connection;
|
||||||
|
|
||||||
|
public LinkCommand(BackendConnection connection) {
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "mcmapper";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsage(ICommandSender sender) {
|
||||||
|
return "/mcmapper link";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getRequiredPermissionLevel() {
|
||||||
|
return 0; // any player may link their own account
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(MinecraftServer server, ICommandSender sender, String[] args) throws CommandException {
|
||||||
|
if (args.length == 0 || !"link".equals(args[0])) {
|
||||||
|
sender.sendMessage(new TextComponentString("Usage: /mcmapper link"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(sender instanceof EntityPlayerMP)) {
|
||||||
|
sender.sendMessage(new TextComponentString("Only players can link an account."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityPlayerMP player = (EntityPlayerMP) sender;
|
||||||
|
String code = LinkCodeGenerator.generate();
|
||||||
|
LinkRequest.AuthMode authMode =
|
||||||
|
server.isServerInOnlineMode() ? LinkRequest.AuthMode.ONLINE : LinkRequest.AuthMode.OFFLINE;
|
||||||
|
connection.sendLinkRequest(new LinkRequest(player.getUniqueID().toString(), player.getName(), code, authMode));
|
||||||
|
player.sendMessage(new TextComponentString(
|
||||||
|
"Your MCMapper link code: " + code + " — enter it on the map site within 10 minutes."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,21 @@ package com.octoturge.mcmapper.forge1122;
|
|||||||
|
|
||||||
import com.octoturge.mcmapper.common.ChunkAdapter;
|
import com.octoturge.mcmapper.common.ChunkAdapter;
|
||||||
import com.octoturge.mcmapper.common.DefaultBackendConnection;
|
import com.octoturge.mcmapper.common.DefaultBackendConnection;
|
||||||
|
import com.octoturge.mcmapper.common.ReconciliationScheduler;
|
||||||
import com.octoturge.mcmapper.common.config.MapperConfig;
|
import com.octoturge.mcmapper.common.config.MapperConfig;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.BlockTexture;
|
||||||
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
|
||||||
import com.octoturge.mcmapper.common.protocol.SectionData;
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import net.minecraft.entity.player.EntityPlayerMP;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.util.math.MathHelper;
|
||||||
import net.minecraft.world.WorldServer;
|
import net.minecraft.world.WorldServer;
|
||||||
import net.minecraftforge.common.MinecraftForge;
|
import net.minecraftforge.common.MinecraftForge;
|
||||||
import net.minecraftforge.common.config.Configuration;
|
import net.minecraftforge.common.config.Configuration;
|
||||||
|
import net.minecraftforge.event.ServerChatEvent;
|
||||||
import net.minecraftforge.fml.common.Mod;
|
import net.minecraftforge.fml.common.Mod;
|
||||||
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
|
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
|
||||||
import net.minecraftforge.fml.common.event.FMLServerStartingEvent;
|
import net.minecraftforge.fml.common.event.FMLServerStartingEvent;
|
||||||
@@ -26,9 +35,13 @@ import java.util.Set;
|
|||||||
/**
|
/**
|
||||||
* Entry point for the 1.12.2 leaf — the primary/first-implemented target (Enigmatica 2).
|
* Entry point for the 1.12.2 leaf — the primary/first-implemented target (Enigmatica 2).
|
||||||
* Phase 1: connects to the backend over WS, backfills the overworld's loaded chunks, and
|
* Phase 1: connects to the backend over WS, backfills the overworld's loaded chunks, and
|
||||||
* flushes event-driven column deltas on a timer (no periodic reconciliation sweep yet — see
|
* flushes event-driven column deltas on a timer. Phase 2 adds full-section backfill/flush for
|
||||||
* plan's Phase 7). Phase 2 adds full-section backfill/flush for 3D mesh rendering. Chat bridge
|
* 3D mesh rendering. Phase 3 adds `/mcmapper link` and the two-way chat bridge (see LinkCommand,
|
||||||
* and `/mcmapper link` land in Phase 3.
|
* Forge1122ChatBridge). Phase 7 adds a periodic reconciliation sweep (see {@link
|
||||||
|
* ReconciliationScheduler}) that catches non-event mutations (world-gen, other mods, `/fill`)
|
||||||
|
* the event-driven hooks in {@link Forge1122ChunkAdapter} never see. Phase 7b adds throttled
|
||||||
|
* online-player position tracking (config-gated via {@code playerTrackingEnabled}), overworld
|
||||||
|
* only — see {@link #sendPlayerPositions()}.
|
||||||
*/
|
*/
|
||||||
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
|
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
|
||||||
public class MCMapperMod {
|
public class MCMapperMod {
|
||||||
@@ -43,7 +56,11 @@ public class MCMapperMod {
|
|||||||
private Forge1122ChunkAdapter adapter;
|
private Forge1122ChunkAdapter adapter;
|
||||||
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<>());
|
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<>());
|
||||||
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<>());
|
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<>());
|
||||||
|
private final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
|
||||||
|
private MinecraftServer mcServer;
|
||||||
private int ticksSinceFlush = 0;
|
private int ticksSinceFlush = 0;
|
||||||
|
private int ticksSinceReconciliation = 0;
|
||||||
|
private int ticksSincePlayerPositions = 0;
|
||||||
|
|
||||||
@Mod.EventHandler
|
@Mod.EventHandler
|
||||||
public void preInit(FMLPreInitializationEvent event) {
|
public void preInit(FMLPreInitializationEvent event) {
|
||||||
@@ -60,6 +77,22 @@ public class MCMapperMod {
|
|||||||
config.deltaFlushIntervalTicks = forgeConfig.getInt("deltaFlushIntervalTicks", "network",
|
config.deltaFlushIntervalTicks = forgeConfig.getInt("deltaFlushIntervalTicks", "network",
|
||||||
config.deltaFlushIntervalTicks, 1, 20 * 60,
|
config.deltaFlushIntervalTicks, 1, 20 * 60,
|
||||||
"How often (in ticks) to batch and flush block-change deltas to the backend");
|
"How often (in ticks) to batch and flush block-change deltas to the backend");
|
||||||
|
config.reconciliationIntervalTicks = forgeConfig.getInt("reconciliationIntervalTicks", "network",
|
||||||
|
config.reconciliationIntervalTicks, 20, 20 * 60 * 60,
|
||||||
|
"How often (in ticks) to run a reconciliation sweep, re-reading and resending a " +
|
||||||
|
"rotating slice of loaded chunks to catch changes event hooks miss " +
|
||||||
|
"(world-gen, other mods, /fill, etc.)");
|
||||||
|
config.reconciliationChunksPerSweep = forgeConfig.getInt("reconciliationChunksPerSweep", "network",
|
||||||
|
config.reconciliationChunksPerSweep, 1, 5000,
|
||||||
|
"Max chunks to re-read and resend per reconciliation sweep");
|
||||||
|
config.playerTrackingEnabled = forgeConfig.getBoolean("playerTrackingEnabled", "network",
|
||||||
|
config.playerTrackingEnabled,
|
||||||
|
"Whether to send throttled online-player positions to the backend at all. The " +
|
||||||
|
"backend also has its own per-server admin toggle deciding whether it " +
|
||||||
|
"relays this on to web viewers — this setting only controls the mod side.");
|
||||||
|
config.playerPositionIntervalTicks = forgeConfig.getInt("playerPositionIntervalTicks", "network",
|
||||||
|
config.playerPositionIntervalTicks, 5, 20 * 60,
|
||||||
|
"How often (in ticks) to send the online-player roster to the backend, when playerTrackingEnabled");
|
||||||
if (forgeConfig.hasChanged()) forgeConfig.save();
|
if (forgeConfig.hasChanged()) forgeConfig.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +103,25 @@ public class MCMapperMod {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mcServer = event.getServer();
|
||||||
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
|
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
|
||||||
|
|
||||||
|
// Phase 11: extracted once, synchronously, here — Block.REGISTRY is already fully
|
||||||
|
// populated by server-starting time (all mod block registration happens during loading,
|
||||||
|
// well before this event fires), and it's a pure classpath read (no network), so this
|
||||||
|
// doesn't meaningfully delay startup even on a heavily-modded pack. Sent via the ready
|
||||||
|
// listener below rather than right after connect() (which is async — see
|
||||||
|
// BackendConnection#setReadyListener's javadoc for why a naive immediate send would
|
||||||
|
// silently no-op).
|
||||||
|
List<BlockRegistryEntry> blockRegistry = BlockAssetExtractor.extractRegistry();
|
||||||
|
List<BlockTexture> blockTextures = BlockAssetExtractor.extractTextures(LOGGER);
|
||||||
|
List<BlockModelFile> blockModels = BlockAssetExtractor.extractModels(LOGGER);
|
||||||
|
connection.setReadyListener(() -> {
|
||||||
|
connection.sendBlockRegistry(blockRegistry);
|
||||||
|
connection.sendBlockTextures(blockTextures);
|
||||||
|
connection.sendBlockModels(blockModels);
|
||||||
|
});
|
||||||
|
|
||||||
connection.connect(config.backendUrl, config.serverToken);
|
connection.connect(config.backendUrl, config.serverToken);
|
||||||
LOGGER.info("MCMapper (1.12.2 leaf) connecting to " + config.backendUrl);
|
LOGGER.info("MCMapper (1.12.2 leaf) connecting to " + config.backendUrl);
|
||||||
|
|
||||||
@@ -88,15 +139,64 @@ public class MCMapperMod {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Forge1122ChatBridge chatBridge = new Forge1122ChatBridge(event.getServer(), LOGGER);
|
||||||
|
connection.setChatListener(chatBridge::injectWebChatMessage);
|
||||||
|
connection.setWaypointShareListener(chatBridge::injectWaypointShare);
|
||||||
|
event.registerServerCommand(new LinkCommand(connection));
|
||||||
|
|
||||||
MinecraftForge.EVENT_BUS.register(this);
|
MinecraftForge.EVENT_BUS.register(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onServerChat(ServerChatEvent event) {
|
||||||
|
EntityPlayerMP player = event.getPlayer();
|
||||||
|
connection.sendChatMessage(player.getUniqueID().toString(), player.getName(), event.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public void onServerTick(TickEvent.ServerTickEvent event) {
|
public void onServerTick(TickEvent.ServerTickEvent event) {
|
||||||
if (event.phase != TickEvent.Phase.END) return;
|
if (event.phase != TickEvent.Phase.END) return;
|
||||||
if (++ticksSinceFlush < config.deltaFlushIntervalTicks) return;
|
|
||||||
ticksSinceFlush = 0;
|
if (++ticksSinceFlush >= config.deltaFlushIntervalTicks) {
|
||||||
flush();
|
ticksSinceFlush = 0;
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++ticksSinceReconciliation >= config.reconciliationIntervalTicks) {
|
||||||
|
ticksSinceReconciliation = 0;
|
||||||
|
reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.playerTrackingEnabled && ++ticksSincePlayerPositions >= config.playerPositionIntervalTicks) {
|
||||||
|
ticksSincePlayerPositions = 0;
|
||||||
|
sendPlayerPositions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPlayerPositions() {
|
||||||
|
List<PlayerPosition> players = new ArrayList<>();
|
||||||
|
for (EntityPlayerMP player : mcServer.getPlayerList().getPlayers()) {
|
||||||
|
if (player.dimension != 0) continue;
|
||||||
|
players.add(new PlayerPosition(player.getUniqueID().toString(), player.getName(),
|
||||||
|
MathHelper.floor(player.posX), MathHelper.floor(player.posY), MathHelper.floor(player.posZ)));
|
||||||
|
}
|
||||||
|
connection.sendPlayerPositions(0, players);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconcile() {
|
||||||
|
List<Long> chunkKeys = reconciliationScheduler.next(adapter.loadedChunkKeys(),
|
||||||
|
config.reconciliationChunksPerSweep);
|
||||||
|
if (chunkKeys.isEmpty()) return;
|
||||||
|
|
||||||
|
String dimensionId = adapter.getDimensionId();
|
||||||
|
for (long key : chunkKeys) {
|
||||||
|
int chunkX = (int) (key >> 32);
|
||||||
|
int chunkZ = (int) key;
|
||||||
|
List<DeltaEvent> columns = adapter.readChunk(dimensionId, chunkX, chunkZ);
|
||||||
|
if (!columns.isEmpty()) connection.sendDeltas(columns);
|
||||||
|
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
|
||||||
|
if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void flush() {
|
private void flush() {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.octoturge.mcmapper.forge1710;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.ChatBridge;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointChatFormatter;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointShare;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.util.ChatComponentText;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
public class Forge1710ChatBridge implements ChatBridge {
|
||||||
|
private final MinecraftServer server;
|
||||||
|
private final Logger logger;
|
||||||
|
|
||||||
|
public Forge1710ChatBridge(MinecraftServer server, Logger logger) {
|
||||||
|
this.server = server;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void injectWebChatMessage(String displayName, String message) {
|
||||||
|
server.getConfigurationManager().sendChatMsg(new ChatComponentText("[Web] " + displayName + ": " + message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void injectWaypointShare(WaypointShare waypoint) {
|
||||||
|
// Same rationale as Forge1122ChatBridge: neither JourneyMap's nor Xaero's chat-waypoint
|
||||||
|
// syntax needs a click-event component, so a plain broadcast message is enough.
|
||||||
|
server.getConfigurationManager().sendChatMsg(new ChatComponentText(WaypointChatFormatter.format(waypoint)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
package com.octoturge.mcmapper.forge1710;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.ChunkAdapter;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import net.minecraft.block.Block;
|
||||||
|
import net.minecraft.world.World;
|
||||||
|
import net.minecraft.world.WorldServer;
|
||||||
|
import net.minecraft.world.chunk.Chunk;
|
||||||
|
import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
|
||||||
|
import net.minecraft.world.gen.ChunkProviderServer;
|
||||||
|
import net.minecraftforge.common.MinecraftForge;
|
||||||
|
import net.minecraftforge.event.world.BlockEvent;
|
||||||
|
import net.minecraftforge.event.world.ChunkEvent;
|
||||||
|
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
|
||||||
|
import cpw.mods.fml.common.gameevent.TickEvent;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.7.10's {@link ChunkAdapter} — one Forge/MC generation older than {@code forge-1_12_2}'s (pre
|
||||||
|
* block-state: raw {@code Block} + metadata int, not {@code IBlockState}), and pre-FML-repackage
|
||||||
|
* (event/tick classes live under {@code cpw.mods.fml}, not {@code net.minecraftforge.fml}).
|
||||||
|
* Column top-of-height lookups and section iteration otherwise mirror {@code
|
||||||
|
* Forge1122ChunkAdapter} exactly — see its javadoc for the reconciliation-vs-event framing.
|
||||||
|
*/
|
||||||
|
public class Forge1710ChunkAdapter implements ChunkAdapter {
|
||||||
|
private final WorldServer world;
|
||||||
|
private final String dimensionId;
|
||||||
|
|
||||||
|
public Forge1710ChunkAdapter(WorldServer world) {
|
||||||
|
this.world = world;
|
||||||
|
this.dimensionId = String.valueOf(world.provider.dimensionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDimensionId() {
|
||||||
|
return dimensionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ) {
|
||||||
|
Chunk chunk = world.getChunkFromChunkCoords(chunkX, chunkZ);
|
||||||
|
List<DeltaEvent> events = new ArrayList<DeltaEvent>(256);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (int lx = 0; lx < 16; lx++) {
|
||||||
|
for (int lz = 0; lz < 16; lz++) {
|
||||||
|
events.add(readColumn(chunk, chunkX * 16 + lx, chunkZ * 16 + lz, lx, lz, now,
|
||||||
|
DeltaEvent.Source.RECONCILIATION));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeltaEvent readColumn(Chunk chunk, int worldX, int worldZ, int localX, int localZ,
|
||||||
|
long now, DeltaEvent.Source source) {
|
||||||
|
int height = chunk.getHeightValue(localX, localZ);
|
||||||
|
int topY = Math.max(0, height - 1);
|
||||||
|
Block block = chunk.getBlock(localX, topY, localZ);
|
||||||
|
int meta = chunk.getBlockMetadata(localX, topY, localZ);
|
||||||
|
int id = Block.getIdFromBlock(block);
|
||||||
|
int blockStateId = ((id & 0xFFF) << 4) | (meta & 0xF);
|
||||||
|
return new DeltaEvent(dimensionId, worldX, topY, worldZ, blockStateId, now, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SectionData> readSections(int chunkX, int chunkZ) {
|
||||||
|
Chunk chunk = world.getChunkFromChunkCoords(chunkX, chunkZ);
|
||||||
|
ExtendedBlockStorage[] storage = chunk.getBlockStorageArray();
|
||||||
|
List<SectionData> sections = new ArrayList<SectionData>();
|
||||||
|
for (int sectionY = 0; sectionY < storage.length; sectionY++) {
|
||||||
|
ExtendedBlockStorage ebs = storage[sectionY];
|
||||||
|
// Same empty-section shortcut as forge-1_12_2 — vanilla leaves storage null for an
|
||||||
|
// all-air section, and ExtendedBlockStorage tracks its own non-air count.
|
||||||
|
if (ebs == null || ebs.isEmpty()) continue;
|
||||||
|
|
||||||
|
char[] blocks = new char[4096];
|
||||||
|
for (int ly = 0; ly < 16; ly++) {
|
||||||
|
for (int lz = 0; lz < 16; lz++) {
|
||||||
|
for (int lx = 0; lx < 16; lx++) {
|
||||||
|
Block block = ebs.getBlockByExtId(lx, ly, lz);
|
||||||
|
int meta = ebs.getExtBlockMetadata(lx, ly, lz);
|
||||||
|
int id = Block.getIdFromBlock(block);
|
||||||
|
int blockStateId = ((id & 0xFFF) << 4) | (meta & 0xF);
|
||||||
|
blocks[(ly * 16 + lz) * 16 + lx] = (char) blockStateId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sections.add(new SectionData(sectionY, blocks));
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerEventHooks(DeltaSink sink) {
|
||||||
|
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> loadedChunkKeys() {
|
||||||
|
List<Long> keys = new ArrayList<Long>();
|
||||||
|
ChunkProviderServer provider = (ChunkProviderServer) world.getChunkProvider();
|
||||||
|
for (Object obj : provider.loadedChunks) {
|
||||||
|
Chunk chunk = (Chunk) obj;
|
||||||
|
keys.add((((long) chunk.xPosition) << 32) | (chunk.zPosition & 0xFFFFFFFFL));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same "mark dirty, drain once per tick" strategy as {@code Forge1122ChunkAdapter} (see its
|
||||||
|
* javadoc) — {@code BlockEvent.BreakEvent} still fires before the actual removal here. This
|
||||||
|
* era's {@code BlockEvent} carries {@code world}/{@code x}/{@code y}/{@code z} as plain public
|
||||||
|
* fields rather than a {@code World}/{@code BlockPos} getter pair, and there's no
|
||||||
|
* {@code MultiPlaceEvent} hook (multi-block placements like doors/beds are caught by the
|
||||||
|
* periodic reconciliation sweep instead — an acceptable gap given it's already a documented
|
||||||
|
* fallback for anything event hooks miss).
|
||||||
|
*/
|
||||||
|
private class EventHooks {
|
||||||
|
private final DeltaSink sink;
|
||||||
|
private final Set<Long> dirtyColumns = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
EventHooks(DeltaSink sink) {
|
||||||
|
this.sink = sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onBlockBreak(BlockEvent.BreakEvent event) {
|
||||||
|
markDirty(event.world, event.x, event.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onBlockPlace(BlockEvent.PlaceEvent event) {
|
||||||
|
markDirty(event.world, event.x, event.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onChunkLoad(ChunkEvent.Load event) {
|
||||||
|
if (event.world != world) return;
|
||||||
|
Chunk chunk = event.getChunk();
|
||||||
|
for (DeltaEvent e : readChunk(dimensionId, chunk.xPosition, chunk.zPosition)) {
|
||||||
|
sink.onDelta(e);
|
||||||
|
}
|
||||||
|
sink.onChunkDirty(chunk.xPosition, chunk.zPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onServerTick(TickEvent.ServerTickEvent event) {
|
||||||
|
if (event.phase != TickEvent.Phase.END || dirtyColumns.isEmpty()) return;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Set<Long> dirtyChunks = new LinkedHashSet<Long>();
|
||||||
|
Iterator<Long> it = dirtyColumns.iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
long key = it.next();
|
||||||
|
it.remove();
|
||||||
|
int wx = (int) (key >> 32);
|
||||||
|
int wz = (int) key;
|
||||||
|
Chunk chunk = world.getChunkFromChunkCoords(wx >> 4, wz >> 4);
|
||||||
|
sink.onDelta(readColumn(chunk, wx, wz, wx & 15, wz & 15, now, DeltaEvent.Source.EVENT));
|
||||||
|
dirtyChunks.add((((long) (wx >> 4)) << 32) | ((wz >> 4) & 0xFFFFFFFFL));
|
||||||
|
}
|
||||||
|
for (long chunkKey : dirtyChunks) {
|
||||||
|
sink.onChunkDirty((int) (chunkKey >> 32), (int) chunkKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markDirty(World eventWorld, int x, int z) {
|
||||||
|
if (eventWorld != world) return;
|
||||||
|
long key = ((long) x << 32) | (z & 0xFFFFFFFFL);
|
||||||
|
dirtyColumns.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.octoturge.mcmapper.forge1710;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.BackendConnection;
|
||||||
|
import com.octoturge.mcmapper.common.LinkCodeGenerator;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.LinkRequest;
|
||||||
|
import net.minecraft.command.CommandBase;
|
||||||
|
import net.minecraft.command.ICommandSender;
|
||||||
|
import net.minecraft.entity.player.EntityPlayerMP;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.util.ChatComponentText;
|
||||||
|
|
||||||
|
/** {@code /mcmapper link} — see LinkRequest.java's javadoc and MCMapper-Backend's link.ts. */
|
||||||
|
public class LinkCommand extends CommandBase {
|
||||||
|
private final BackendConnection connection;
|
||||||
|
|
||||||
|
public LinkCommand(BackendConnection connection) {
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCommandName() {
|
||||||
|
return "mcmapper";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCommandUsage(ICommandSender sender) {
|
||||||
|
return "/mcmapper link";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getRequiredPermissionLevel() {
|
||||||
|
return 0; // any player may link their own account
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void processCommand(ICommandSender sender, String[] args) {
|
||||||
|
if (args.length == 0 || !"link".equals(args[0])) {
|
||||||
|
sender.addChatMessage(new ChatComponentText("Usage: /mcmapper link"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(sender instanceof EntityPlayerMP)) {
|
||||||
|
sender.addChatMessage(new ChatComponentText("Only players can link an account."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityPlayerMP player = (EntityPlayerMP) sender;
|
||||||
|
String code = LinkCodeGenerator.generate();
|
||||||
|
LinkRequest.AuthMode authMode = MinecraftServer.getServer().isServerInOnlineMode()
|
||||||
|
? LinkRequest.AuthMode.ONLINE : LinkRequest.AuthMode.OFFLINE;
|
||||||
|
connection.sendLinkRequest(new LinkRequest(player.getUniqueID().toString(),
|
||||||
|
player.getGameProfile().getName(), code, authMode));
|
||||||
|
player.addChatMessage(new ChatComponentText(
|
||||||
|
"Your MCMapper link code: " + code + " — enter it on the map site within 10 minutes."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,216 @@
|
|||||||
package com.octoturge.mcmapper.forge1710;
|
package com.octoturge.mcmapper.forge1710;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.ChunkAdapter;
|
||||||
|
import com.octoturge.mcmapper.common.DefaultBackendConnection;
|
||||||
|
import com.octoturge.mcmapper.common.ReconciliationScheduler;
|
||||||
|
import com.octoturge.mcmapper.common.config.MapperConfig;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import cpw.mods.fml.common.Mod;
|
||||||
|
import cpw.mods.fml.common.event.FMLPreInitializationEvent;
|
||||||
|
import cpw.mods.fml.common.event.FMLServerStartingEvent;
|
||||||
|
import cpw.mods.fml.common.event.FMLServerStoppingEvent;
|
||||||
|
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
|
||||||
|
import cpw.mods.fml.common.gameevent.TickEvent;
|
||||||
|
import net.minecraft.entity.player.EntityPlayerMP;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.util.MathHelper;
|
||||||
|
import net.minecraft.world.WorldServer;
|
||||||
|
import net.minecraftforge.common.DimensionManager;
|
||||||
|
import net.minecraftforge.common.MinecraftForge;
|
||||||
|
import net.minecraftforge.common.config.Configuration;
|
||||||
|
import net.minecraftforge.event.ServerChatEvent;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stub for the 1.7.10 leaf (Phase 9 — higher priority than 26.1.2, ships after 1.12.2 proves
|
* Entry point for the 1.7.10 leaf (Phase 9) — one Forge/MC generation older than {@code
|
||||||
* the architecture). Not wired to the Forge {@code @Mod} annotation yet since this leaf's
|
* forge-1_12_2} (raw block id + metadata, {@code cpw.mods.fml} packages, no {@code IBlockState}).
|
||||||
* ForgeGradle 2.1 toolchain isn't active in the root build — see build.gradle.
|
* Implements the same feature set the 1.12.2 leaf proved (Phases 1-2-3-7-7b: WS connect, column +
|
||||||
|
* section backfill/flush, chat bridge, {@code /mcmapper link}, reconciliation sweep, throttled
|
||||||
|
* player positions) against the {@code common} interfaces — see {@link MCMapperMod}'s 1.12.2
|
||||||
|
* counterpart for the shared design rationale, and {@link Forge1710ChunkAdapter} for what
|
||||||
|
* actually differs API-wise.
|
||||||
*/
|
*/
|
||||||
|
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
|
||||||
public class MCMapperMod {
|
public class MCMapperMod {
|
||||||
public static final String MOD_ID = "mcmapper";
|
public static final String MOD_ID = "mcmapper";
|
||||||
public static final String VERSION = "0.1.0-SNAPSHOT";
|
public static final String VERSION = "0.1.0-SNAPSHOT";
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger(MOD_ID);
|
||||||
|
|
||||||
|
private Configuration forgeConfig;
|
||||||
|
private final MapperConfig config = new MapperConfig();
|
||||||
|
private DefaultBackendConnection connection;
|
||||||
|
private Forge1710ChunkAdapter adapter;
|
||||||
|
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<DeltaEvent>());
|
||||||
|
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<Long>());
|
||||||
|
private final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
|
||||||
|
private MinecraftServer mcServer;
|
||||||
|
private int ticksSinceFlush = 0;
|
||||||
|
private int ticksSinceReconciliation = 0;
|
||||||
|
private int ticksSincePlayerPositions = 0;
|
||||||
|
|
||||||
|
@Mod.EventHandler
|
||||||
|
public void preInit(FMLPreInitializationEvent event) {
|
||||||
|
forgeConfig = new Configuration(event.getSuggestedConfigurationFile());
|
||||||
|
loadConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadConfig() {
|
||||||
|
forgeConfig.load();
|
||||||
|
config.backendUrl = forgeConfig.getString("backendUrl", "network", config.backendUrl,
|
||||||
|
"WS URL of the MCMapper backend api service");
|
||||||
|
config.serverToken = forgeConfig.getString("serverToken", "network", config.serverToken,
|
||||||
|
"Per-server token issued when registering with the backend (see MCMapper-Backend's `bun run seed`)");
|
||||||
|
config.deltaFlushIntervalTicks = forgeConfig.getInt("deltaFlushIntervalTicks", "network",
|
||||||
|
config.deltaFlushIntervalTicks, 1, 20 * 60,
|
||||||
|
"How often (in ticks) to batch and flush block-change deltas to the backend");
|
||||||
|
config.reconciliationIntervalTicks = forgeConfig.getInt("reconciliationIntervalTicks", "network",
|
||||||
|
config.reconciliationIntervalTicks, 20, 20 * 60 * 60,
|
||||||
|
"How often (in ticks) to run a reconciliation sweep, re-reading and resending a " +
|
||||||
|
"rotating slice of loaded chunks to catch changes event hooks miss " +
|
||||||
|
"(world-gen, other mods, /fill, etc.)");
|
||||||
|
config.reconciliationChunksPerSweep = forgeConfig.getInt("reconciliationChunksPerSweep", "network",
|
||||||
|
config.reconciliationChunksPerSweep, 1, 5000,
|
||||||
|
"Max chunks to re-read and resend per reconciliation sweep");
|
||||||
|
config.playerTrackingEnabled = forgeConfig.getBoolean("playerTrackingEnabled", "network",
|
||||||
|
config.playerTrackingEnabled,
|
||||||
|
"Whether to send throttled online-player positions to the backend at all. The " +
|
||||||
|
"backend also has its own per-server admin toggle deciding whether it " +
|
||||||
|
"relays this on to web viewers — this setting only controls the mod side.");
|
||||||
|
config.playerPositionIntervalTicks = forgeConfig.getInt("playerPositionIntervalTicks", "network",
|
||||||
|
config.playerPositionIntervalTicks, 5, 20 * 60,
|
||||||
|
"How often (in ticks) to send the online-player roster to the backend, when playerTrackingEnabled");
|
||||||
|
if (forgeConfig.hasChanged()) forgeConfig.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mod.EventHandler
|
||||||
|
public void serverStarting(FMLServerStartingEvent event) {
|
||||||
|
if (config.serverToken == null || config.serverToken.isEmpty()) {
|
||||||
|
LOGGER.warn("MCMapper serverToken is not configured (see config/mcmapper.cfg) — not connecting to backend");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcServer = event.getServer();
|
||||||
|
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
|
||||||
|
connection.connect(config.backendUrl, config.serverToken);
|
||||||
|
LOGGER.info("MCMapper (1.7.10 leaf) connecting to " + config.backendUrl);
|
||||||
|
|
||||||
|
WorldServer overworld = DimensionManager.getWorld(0);
|
||||||
|
adapter = new Forge1710ChunkAdapter(overworld);
|
||||||
|
adapter.registerEventHooks(new ChunkAdapter.DeltaSink() {
|
||||||
|
@Override
|
||||||
|
public void onDelta(DeltaEvent delta) {
|
||||||
|
pendingDeltas.add(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChunkDirty(int chunkX, int chunkZ) {
|
||||||
|
pendingSectionChunks.add((((long) chunkX) << 32) | (chunkZ & 0xFFFFFFFFL));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Forge1710ChatBridge chatBridge = new Forge1710ChatBridge(event.getServer(), LOGGER);
|
||||||
|
connection.setChatListener(chatBridge::injectWebChatMessage);
|
||||||
|
connection.setWaypointShareListener(chatBridge::injectWaypointShare);
|
||||||
|
event.registerServerCommand(new LinkCommand(connection));
|
||||||
|
|
||||||
|
MinecraftForge.EVENT_BUS.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onServerChat(ServerChatEvent event) {
|
||||||
|
EntityPlayerMP player = (EntityPlayerMP) event.player;
|
||||||
|
connection.sendChatMessage(player.getUniqueID().toString(), player.getGameProfile().getName(), event.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onServerTick(TickEvent.ServerTickEvent event) {
|
||||||
|
if (event.phase != TickEvent.Phase.END) return;
|
||||||
|
|
||||||
|
if (++ticksSinceFlush >= config.deltaFlushIntervalTicks) {
|
||||||
|
ticksSinceFlush = 0;
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++ticksSinceReconciliation >= config.reconciliationIntervalTicks) {
|
||||||
|
ticksSinceReconciliation = 0;
|
||||||
|
reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.playerTrackingEnabled && ++ticksSincePlayerPositions >= config.playerPositionIntervalTicks) {
|
||||||
|
ticksSincePlayerPositions = 0;
|
||||||
|
sendPlayerPositions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPlayerPositions() {
|
||||||
|
List<PlayerPosition> players = new ArrayList<PlayerPosition>();
|
||||||
|
for (Object obj : mcServer.getConfigurationManager().playerEntityList) {
|
||||||
|
EntityPlayerMP player = (EntityPlayerMP) obj;
|
||||||
|
if (player.dimension != 0) continue;
|
||||||
|
players.add(new PlayerPosition(player.getUniqueID().toString(), player.getGameProfile().getName(),
|
||||||
|
MathHelper.floor_double(player.posX), MathHelper.floor_double(player.posY),
|
||||||
|
MathHelper.floor_double(player.posZ)));
|
||||||
|
}
|
||||||
|
connection.sendPlayerPositions(0, players);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconcile() {
|
||||||
|
List<Long> chunkKeys = reconciliationScheduler.next(adapter.loadedChunkKeys(),
|
||||||
|
config.reconciliationChunksPerSweep);
|
||||||
|
if (chunkKeys.isEmpty()) return;
|
||||||
|
|
||||||
|
String dimensionId = adapter.getDimensionId();
|
||||||
|
for (long key : chunkKeys) {
|
||||||
|
int chunkX = (int) (key >> 32);
|
||||||
|
int chunkZ = (int) key;
|
||||||
|
List<DeltaEvent> columns = adapter.readChunk(dimensionId, chunkX, chunkZ);
|
||||||
|
if (!columns.isEmpty()) connection.sendDeltas(columns);
|
||||||
|
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
|
||||||
|
if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flush() {
|
||||||
|
List<DeltaEvent> batch;
|
||||||
|
synchronized (pendingDeltas) {
|
||||||
|
if (pendingDeltas.isEmpty()) {
|
||||||
|
batch = null;
|
||||||
|
} else {
|
||||||
|
batch = new ArrayList<DeltaEvent>(pendingDeltas);
|
||||||
|
pendingDeltas.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batch != null) connection.sendDeltas(batch);
|
||||||
|
|
||||||
|
List<Long> dirtyChunks;
|
||||||
|
synchronized (pendingSectionChunks) {
|
||||||
|
if (pendingSectionChunks.isEmpty()) return;
|
||||||
|
dirtyChunks = new ArrayList<Long>(pendingSectionChunks);
|
||||||
|
pendingSectionChunks.clear();
|
||||||
|
}
|
||||||
|
String dimensionId = adapter.getDimensionId();
|
||||||
|
for (long key : dirtyChunks) {
|
||||||
|
int chunkX = (int) (key >> 32);
|
||||||
|
int chunkZ = (int) key;
|
||||||
|
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
|
||||||
|
if (!sections.isEmpty()) {
|
||||||
|
connection.sendSections(dimensionId, chunkX, chunkZ, sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mod.EventHandler
|
||||||
|
public void serverStopping(FMLServerStoppingEvent event) {
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -20,6 +20,8 @@ minecraft_1_12_2_version=1.12.2
|
|||||||
forge_1_12_2_version=14.23.5.2847
|
forge_1_12_2_version=14.23.5.2847
|
||||||
mcp_1_12_2_mappings=stable_39
|
mcp_1_12_2_mappings=stable_39
|
||||||
|
|
||||||
# neoforge-26_1 (loader assumed NeoForge — confirm before Phase 10, see plan's Open Assumptions)
|
# neoforge-26_1 — loader confirmed NeoForge (Phase 10); this properties file isn't read by that
|
||||||
|
# leaf's build (it's a standalone Gradle project, see neoforge-26_1/settings.gradle), kept here
|
||||||
|
# only as a record of the pinned version alongside the other two leaves'.
|
||||||
minecraft_26_1_version=26.1.2
|
minecraft_26_1_version=26.1.2
|
||||||
neoforge_26_1_version=26.1.2
|
neoforge_26_1_version=26.1.2.94
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
// Loader assumed to be NeoForge (see root plan's Open Assumptions — confirm the actual 26.1.x
|
// Loader confirmed NeoForge (Phase 10) — versions like 26.1.2.94 are published on
|
||||||
// loader ecosystem before Phase 10). Uses the modern ModDevGradle plugin; requires a recent JDK
|
// maven.neoforged.net. Uses the modern ModDevGradle plugin. Minecraft itself now requires Java
|
||||||
// (21+) unlike the two legacy leaves.
|
// 25 (bumped from 21 during the 26.x cycle) — see settings.gradle's comment on how the Java 25
|
||||||
|
// toolchain gets provisioned without needing it pre-installed system-wide.
|
||||||
|
//
|
||||||
|
// Standalone build, not part of the root multi-project build — see settings.gradle.
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'net.neoforged.moddev' version '2.0.+'
|
id 'net.neoforged.moddev' version '2.0.+'
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceCompatibility = targetCompatibility = JavaVersion.VERSION_21
|
group = 'com.octoturge.mcmapper'
|
||||||
|
version = '0.1.0-SNAPSHOT'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceCompatibility = targetCompatibility = JavaVersion.VERSION_25
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main {
|
main {
|
||||||
@@ -17,7 +33,7 @@ sourceSets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
neoForge {
|
neoForge {
|
||||||
version = project.neoforge_26_1_version
|
version = '26.1.2.94'
|
||||||
|
|
||||||
runs {
|
runs {
|
||||||
client {}
|
client {}
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Standalone build — deliberately NOT part of the root multi-project build (see the root
|
||||||
|
// settings.gradle's comment): ModDevGradle needs Gradle 8+ and a Java 25 toolchain, incompatible
|
||||||
|
// with the two legacy leaves' Gradle-7/JDK-8-daemon pin within one Gradle invocation. Build this
|
||||||
|
// leaf from inside this directory, with its own wrapper:
|
||||||
|
//
|
||||||
|
// cd neoforge-26_1 && ./gradlew build
|
||||||
|
//
|
||||||
|
// The Gradle daemon itself can run on any modern JDK on PATH/JAVA_HOME (8.14.x supports up to
|
||||||
|
// JDK 23 as the *daemon* JVM) — it does not need to already be JDK 25. The Foojay resolver below
|
||||||
|
// lets Gradle auto-provision an actual JDK 25 toolchain (into its own GRADLE_USER_HOME cache, not
|
||||||
|
// a system-wide install) for the compile/run tasks that require Minecraft's own Java 25
|
||||||
|
// requirement, without needing one pre-installed.
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven { url = 'https://maven.neoforged.net/releases' }
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'neoforge-26_1'
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.octoturge.mcmapper.neoforge261;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
|
import com.octoturge.mcmapper.common.BackendConnection;
|
||||||
|
import com.octoturge.mcmapper.common.LinkCodeGenerator;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.LinkRequest;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code /mcmapper link} — see LinkRequest.java's javadoc and MCMapper-Backend's link.ts. Modern
|
||||||
|
* Brigadier command registration (this era has no {@code CommandBase} to extend), registered from
|
||||||
|
* {@link MCMapperMod}'s {@code RegisterCommandsEvent} handler.
|
||||||
|
*/
|
||||||
|
final class LinkCommand {
|
||||||
|
private LinkCommand() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static void register(CommandDispatcher<CommandSourceStack> dispatcher, BackendConnection connection) {
|
||||||
|
dispatcher.register(Commands.literal("mcmapper")
|
||||||
|
.then(Commands.literal("link")
|
||||||
|
.executes(ctx -> link(ctx.getSource(), connection))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int link(CommandSourceStack source, BackendConnection connection) throws CommandSyntaxException {
|
||||||
|
ServerPlayer player = source.getPlayerOrException();
|
||||||
|
String code = LinkCodeGenerator.generate();
|
||||||
|
LinkRequest.AuthMode authMode = source.getServer().usesAuthentication()
|
||||||
|
? LinkRequest.AuthMode.ONLINE : LinkRequest.AuthMode.OFFLINE;
|
||||||
|
connection.sendLinkRequest(new LinkRequest(player.getUUID().toString(),
|
||||||
|
player.getGameProfile().name(), code, authMode));
|
||||||
|
source.sendSuccess(() -> Component.literal(
|
||||||
|
"Your MCMapper link code: " + code + " — enter it on the map site within 10 minutes."), false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,43 @@
|
|||||||
package com.octoturge.mcmapper.neoforge261;
|
package com.octoturge.mcmapper.neoforge261;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.ChunkAdapter;
|
||||||
|
import com.octoturge.mcmapper.common.DefaultBackendConnection;
|
||||||
|
import com.octoturge.mcmapper.common.ReconciliationScheduler;
|
||||||
|
import com.octoturge.mcmapper.common.config.MapperConfig;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.neoforged.bus.api.IEventBus;
|
import net.neoforged.bus.api.IEventBus;
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
|
import net.neoforged.fml.ModContainer;
|
||||||
import net.neoforged.fml.common.Mod;
|
import net.neoforged.fml.common.Mod;
|
||||||
|
import net.neoforged.fml.config.ModConfig;
|
||||||
|
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||||
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||||
|
import net.neoforged.neoforge.event.ServerChatEvent;
|
||||||
|
import net.neoforged.neoforge.event.server.ServerStartingEvent;
|
||||||
|
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||||
|
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point for the 26.1.2 leaf — lowest MVP priority (Phase 10), stubbed structurally now.
|
* Entry point for the 26.1.2 leaf (Phase 10, lowest MVP priority) — post-Flattening, mixin-era
|
||||||
* Loader assumed NeoForge; see root plan's Open Assumptions for the verification note.
|
* NeoForge. Implements the same feature set proven by the two legacy leaves against the {@code
|
||||||
|
* common} interfaces; see {@link Neoforge261ChunkAdapter}'s javadoc for what differs API-wise
|
||||||
|
* (block-state reads instead of raw id+meta, {@code net.neoforged.*} packages, Brigadier commands
|
||||||
|
* instead of {@code CommandBase}, {@code ModConfigSpec} instead of legacy Forge's {@code
|
||||||
|
* Configuration}, constructor-injected event buses instead of {@code @Mod.EventHandler} methods).
|
||||||
*/
|
*/
|
||||||
@Mod(MCMapperMod.MOD_ID)
|
@Mod(MCMapperMod.MOD_ID)
|
||||||
public class MCMapperMod {
|
public class MCMapperMod {
|
||||||
@@ -15,7 +45,180 @@ public class MCMapperMod {
|
|||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(MCMapperMod.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(MCMapperMod.class);
|
||||||
|
|
||||||
public MCMapperMod(IEventBus modEventBus) {
|
private final ModConfigSpec.ConfigValue<String> backendUrlSpec;
|
||||||
LOGGER.info("MCMapper (neoforge-26_1 leaf) scaffolding loaded — no-op until Phase 10");
|
private final ModConfigSpec.ConfigValue<String> serverTokenSpec;
|
||||||
|
private final ModConfigSpec.IntValue deltaFlushIntervalTicksSpec;
|
||||||
|
private final ModConfigSpec.IntValue reconciliationIntervalTicksSpec;
|
||||||
|
private final ModConfigSpec.IntValue reconciliationChunksPerSweepSpec;
|
||||||
|
private final ModConfigSpec.BooleanValue playerTrackingEnabledSpec;
|
||||||
|
private final ModConfigSpec.IntValue playerPositionIntervalTicksSpec;
|
||||||
|
|
||||||
|
private final MapperConfig config = new MapperConfig();
|
||||||
|
private DefaultBackendConnection connection;
|
||||||
|
private Neoforge261ChunkAdapter adapter;
|
||||||
|
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<DeltaEvent>());
|
||||||
|
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<Long>());
|
||||||
|
private final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
|
||||||
|
private MinecraftServer mcServer;
|
||||||
|
private int ticksSinceFlush = 0;
|
||||||
|
private int ticksSinceReconciliation = 0;
|
||||||
|
private int ticksSincePlayerPositions = 0;
|
||||||
|
|
||||||
|
public MCMapperMod(IEventBus modEventBus, ModContainer container) {
|
||||||
|
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
|
||||||
|
backendUrlSpec = builder.comment("WS URL of the MCMapper backend api service")
|
||||||
|
.define("backendUrl", config.backendUrl);
|
||||||
|
serverTokenSpec = builder.comment("Per-server token issued when registering with the backend (see MCMapper-Backend's `bun run seed`)")
|
||||||
|
.define("serverToken", config.serverToken);
|
||||||
|
deltaFlushIntervalTicksSpec = builder.comment("How often (in ticks) to batch and flush block-change deltas to the backend")
|
||||||
|
.defineInRange("deltaFlushIntervalTicks", config.deltaFlushIntervalTicks, 1, 20 * 60);
|
||||||
|
reconciliationIntervalTicksSpec = builder.comment("How often (in ticks) to run a reconciliation sweep, re-reading and resending a " +
|
||||||
|
"rotating slice of loaded chunks to catch changes event hooks miss (world-gen, other mods, /fill, etc.)")
|
||||||
|
.defineInRange("reconciliationIntervalTicks", config.reconciliationIntervalTicks, 20, 20 * 60 * 60);
|
||||||
|
reconciliationChunksPerSweepSpec = builder.comment("Max chunks to re-read and resend per reconciliation sweep")
|
||||||
|
.defineInRange("reconciliationChunksPerSweep", config.reconciliationChunksPerSweep, 1, 5000);
|
||||||
|
playerTrackingEnabledSpec = builder.comment("Whether to send throttled online-player positions to the backend at all. The " +
|
||||||
|
"backend also has its own per-server admin toggle deciding whether it relays this on to web " +
|
||||||
|
"viewers — this setting only controls the mod side.")
|
||||||
|
.define("playerTrackingEnabled", config.playerTrackingEnabled);
|
||||||
|
playerPositionIntervalTicksSpec = builder.comment("How often (in ticks) to send the online-player roster to the backend, when playerTrackingEnabled")
|
||||||
|
.defineInRange("playerPositionIntervalTicks", config.playerPositionIntervalTicks, 5, 20 * 60);
|
||||||
|
container.registerConfig(ModConfig.Type.SERVER, builder.build());
|
||||||
|
|
||||||
|
NeoForge.EVENT_BUS.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadConfig() {
|
||||||
|
config.backendUrl = backendUrlSpec.get();
|
||||||
|
config.serverToken = serverTokenSpec.get();
|
||||||
|
config.deltaFlushIntervalTicks = deltaFlushIntervalTicksSpec.get();
|
||||||
|
config.reconciliationIntervalTicks = reconciliationIntervalTicksSpec.get();
|
||||||
|
config.reconciliationChunksPerSweep = reconciliationChunksPerSweepSpec.get();
|
||||||
|
config.playerTrackingEnabled = playerTrackingEnabledSpec.get();
|
||||||
|
config.playerPositionIntervalTicks = playerPositionIntervalTicksSpec.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void serverStarting(ServerStartingEvent event) {
|
||||||
|
loadConfig();
|
||||||
|
if (config.serverToken == null || config.serverToken.isEmpty()) {
|
||||||
|
LOGGER.warn("MCMapper serverToken is not configured (see config/mcmapper-server.toml) — not connecting to backend");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcServer = event.getServer();
|
||||||
|
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
|
||||||
|
connection.connect(config.backendUrl, config.serverToken);
|
||||||
|
LOGGER.info("MCMapper (neoforge-26_1 leaf) connecting to " + config.backendUrl);
|
||||||
|
|
||||||
|
ServerLevel overworld = event.getServer().overworld();
|
||||||
|
adapter = new Neoforge261ChunkAdapter(overworld);
|
||||||
|
adapter.registerEventHooks(new ChunkAdapter.DeltaSink() {
|
||||||
|
@Override
|
||||||
|
public void onDelta(DeltaEvent delta) {
|
||||||
|
pendingDeltas.add(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChunkDirty(int chunkX, int chunkZ) {
|
||||||
|
pendingSectionChunks.add((((long) chunkX) << 32) | (chunkZ & 0xFFFFFFFFL));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Neoforge261ChatBridge chatBridge = new Neoforge261ChatBridge(event.getServer());
|
||||||
|
connection.setChatListener(chatBridge::injectWebChatMessage);
|
||||||
|
connection.setWaypointShareListener(chatBridge::injectWaypointShare);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onRegisterCommands(RegisterCommandsEvent event) {
|
||||||
|
LinkCommand.register(event.getDispatcher(), connection != null ? connection : new com.octoturge.mcmapper.common.BackendConnection.NoOp());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onServerChat(ServerChatEvent event) {
|
||||||
|
if (connection == null) return;
|
||||||
|
ServerPlayer player = event.getPlayer();
|
||||||
|
connection.sendChatMessage(player.getUUID().toString(), event.getUsername(), event.getRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onServerTick(ServerTickEvent.Post event) {
|
||||||
|
if (connection == null) return;
|
||||||
|
|
||||||
|
if (++ticksSinceFlush >= config.deltaFlushIntervalTicks) {
|
||||||
|
ticksSinceFlush = 0;
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++ticksSinceReconciliation >= config.reconciliationIntervalTicks) {
|
||||||
|
ticksSinceReconciliation = 0;
|
||||||
|
reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.playerTrackingEnabled && ++ticksSincePlayerPositions >= config.playerPositionIntervalTicks) {
|
||||||
|
ticksSincePlayerPositions = 0;
|
||||||
|
sendPlayerPositions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPlayerPositions() {
|
||||||
|
List<PlayerPosition> players = new ArrayList<PlayerPosition>();
|
||||||
|
for (ServerPlayer player : mcServer.getPlayerList().getPlayers()) {
|
||||||
|
if (player.level().dimension() != net.minecraft.world.level.Level.OVERWORLD) continue;
|
||||||
|
players.add(new PlayerPosition(player.getUUID().toString(), player.getGameProfile().name(),
|
||||||
|
player.getBlockX(), player.getBlockY(), player.getBlockZ()));
|
||||||
|
}
|
||||||
|
connection.sendPlayerPositions(0, players);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconcile() {
|
||||||
|
List<Long> chunkKeys = reconciliationScheduler.next(adapter.loadedChunkKeys(),
|
||||||
|
config.reconciliationChunksPerSweep);
|
||||||
|
if (chunkKeys.isEmpty()) return;
|
||||||
|
|
||||||
|
String dimensionId = adapter.getDimensionId();
|
||||||
|
for (long key : chunkKeys) {
|
||||||
|
int chunkX = (int) (key >> 32);
|
||||||
|
int chunkZ = (int) key;
|
||||||
|
List<DeltaEvent> columns = adapter.readChunk(dimensionId, chunkX, chunkZ);
|
||||||
|
if (!columns.isEmpty()) connection.sendDeltas(columns);
|
||||||
|
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
|
||||||
|
if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flush() {
|
||||||
|
List<DeltaEvent> batch;
|
||||||
|
synchronized (pendingDeltas) {
|
||||||
|
if (pendingDeltas.isEmpty()) {
|
||||||
|
batch = null;
|
||||||
|
} else {
|
||||||
|
batch = new ArrayList<DeltaEvent>(pendingDeltas);
|
||||||
|
pendingDeltas.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batch != null) connection.sendDeltas(batch);
|
||||||
|
|
||||||
|
List<Long> dirtyChunks;
|
||||||
|
synchronized (pendingSectionChunks) {
|
||||||
|
if (pendingSectionChunks.isEmpty()) return;
|
||||||
|
dirtyChunks = new ArrayList<Long>(pendingSectionChunks);
|
||||||
|
pendingSectionChunks.clear();
|
||||||
|
}
|
||||||
|
String dimensionId = adapter.getDimensionId();
|
||||||
|
for (long key : dirtyChunks) {
|
||||||
|
int chunkX = (int) (key >> 32);
|
||||||
|
int chunkZ = (int) key;
|
||||||
|
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
|
||||||
|
if (!sections.isEmpty()) {
|
||||||
|
connection.sendSections(dimensionId, chunkX, chunkZ, sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void serverStopping(ServerStoppingEvent event) {
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.octoturge.mcmapper.neoforge261;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.ChatBridge;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointChatFormatter;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.WaypointShare;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
|
||||||
|
public class Neoforge261ChatBridge implements ChatBridge {
|
||||||
|
private final MinecraftServer server;
|
||||||
|
|
||||||
|
public Neoforge261ChatBridge(MinecraftServer server) {
|
||||||
|
this.server = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void injectWebChatMessage(String displayName, String message) {
|
||||||
|
server.getPlayerList().broadcastSystemMessage(Component.literal("[Web] " + displayName + ": " + message), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void injectWaypointShare(WaypointShare waypoint) {
|
||||||
|
// Same rationale as the legacy leaves' ChatBridge: neither JourneyMap's nor Xaero's
|
||||||
|
// chat-waypoint syntax needs a click-event component, plain text is enough.
|
||||||
|
server.getPlayerList().broadcastSystemMessage(Component.literal(WaypointChatFormatter.format(waypoint)), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package com.octoturge.mcmapper.neoforge261;
|
||||||
|
|
||||||
|
import com.octoturge.mcmapper.common.ChunkAdapter;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.LevelAccessor;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.chunk.LevelChunk;
|
||||||
|
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||||
|
import net.minecraft.world.level.levelgen.Heightmap;
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
|
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||||
|
import net.neoforged.neoforge.event.level.ChunkEvent;
|
||||||
|
import net.neoforged.neoforge.event.level.block.BreakBlockEvent;
|
||||||
|
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 26.1.2's {@link ChunkAdapter} (Phase 10) — post-Flattening: blocks are read as {@link
|
||||||
|
* BlockState}, not raw id+metadata, and this leaf's block identity ({@code Block.getId(state)},
|
||||||
|
* a registry-wide packed int) no longer reliably fits the pre-Flattening leaves' {@code (blockId
|
||||||
|
* << 4) | meta} 16-bit encoding once a heavily-modded registry is in play (see {@link
|
||||||
|
* DeltaEvent}'s javadoc). {@link DeltaEvent#blockStateId} is a plain {@code int}, so 2D
|
||||||
|
* column/reconciliation data (the Phase 1/9/10 parity bar) carries the full packed id losslessly;
|
||||||
|
* {@link SectionData#blocks} is a {@code char[]} for wire-size reasons inherited from the
|
||||||
|
* pre-Flattening leaves, so 3D section backfill truncates to the low 16 bits — a known,
|
||||||
|
* documented collision risk for very large (heavily modded) registries, deferred rather than
|
||||||
|
* redesigning the wire protocol for a Phase whose own verification bar is 2D-tile parity, not 3D.
|
||||||
|
*
|
||||||
|
* <p>Also unlike the two legacy leaves, this adapter tracks its own loaded-chunk set from {@link
|
||||||
|
* ChunkEvent.Load}/{@code Unload} rather than querying a chunk provider's internals directly —
|
||||||
|
* modern {@code ServerChunkCache} doesn't expose a simple public "currently loaded chunks"
|
||||||
|
* iterable, and an event-driven set is arguably more robust anyway.
|
||||||
|
*/
|
||||||
|
public class Neoforge261ChunkAdapter implements ChunkAdapter {
|
||||||
|
private final ServerLevel level;
|
||||||
|
private final String dimensionId;
|
||||||
|
private final Set<Long> loadedChunks = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
public Neoforge261ChunkAdapter(ServerLevel level) {
|
||||||
|
this.level = level;
|
||||||
|
this.dimensionId = level.dimension().identifier().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDimensionId() {
|
||||||
|
return dimensionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ) {
|
||||||
|
LevelChunk chunk = level.getChunk(chunkX, chunkZ);
|
||||||
|
List<DeltaEvent> events = new ArrayList<DeltaEvent>(256);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (int lx = 0; lx < 16; lx++) {
|
||||||
|
for (int lz = 0; lz < 16; lz++) {
|
||||||
|
events.add(readColumn(chunkX * 16 + lx, chunkZ * 16 + lz, now, DeltaEvent.Source.RECONCILIATION));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeltaEvent readColumn(int worldX, int worldZ, long now, DeltaEvent.Source source) {
|
||||||
|
int height = level.getHeight(Heightmap.Types.WORLD_SURFACE, worldX, worldZ);
|
||||||
|
int topY = height - 1;
|
||||||
|
BlockState state = level.getBlockState(new BlockPos(worldX, topY, worldZ));
|
||||||
|
int blockStateId = Block.getId(state);
|
||||||
|
return new DeltaEvent(dimensionId, worldX, topY, worldZ, blockStateId, now, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SectionData> readSections(int chunkX, int chunkZ) {
|
||||||
|
LevelChunk chunk = level.getChunk(chunkX, chunkZ);
|
||||||
|
LevelChunkSection[] storage = chunk.getSections();
|
||||||
|
List<SectionData> sections = new ArrayList<SectionData>();
|
||||||
|
for (int sectionY = 0; sectionY < storage.length; sectionY++) {
|
||||||
|
LevelChunkSection section = storage[sectionY];
|
||||||
|
// hasOnlyAir() is the modern equivalent of the legacy leaves' null/isEmpty()
|
||||||
|
// shortcut — skip fully-air sections without a 4096-position scan.
|
||||||
|
if (section == null || section.hasOnlyAir()) continue;
|
||||||
|
|
||||||
|
char[] blocks = new char[4096];
|
||||||
|
for (int ly = 0; ly < 16; ly++) {
|
||||||
|
for (int lz = 0; lz < 16; lz++) {
|
||||||
|
for (int lx = 0; lx < 16; lx++) {
|
||||||
|
BlockState state = section.getBlockState(lx, ly, lz);
|
||||||
|
int blockStateId = Block.getId(state) & 0xFFFF;
|
||||||
|
blocks[(ly * 16 + lz) * 16 + lx] = (char) blockStateId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sections.add(new SectionData(sectionY, blocks));
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerEventHooks(DeltaSink sink) {
|
||||||
|
NeoForge.EVENT_BUS.register(new EventHooks(sink));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> loadedChunkKeys() {
|
||||||
|
return new ArrayList<Long>(loadedChunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same "mark dirty, drain once per tick" strategy as the legacy leaves (see {@code
|
||||||
|
* Forge1122ChunkAdapter}'s javadoc) — {@code BreakBlockEvent} still fires before the actual
|
||||||
|
* removal. This era's block events expose {@code getLevel()}/{@code getPos()} methods (not
|
||||||
|
* the legacy leaves' direct-field or {@code x}/{@code y}/{@code z} conventions), and block
|
||||||
|
* break/place are two differently-shaped classes ({@code BreakBlockEvent} top-level,
|
||||||
|
* {@code BlockEvent.EntityPlaceEvent} nested) rather than one shared {@code BlockEvent}
|
||||||
|
* subclass pair.
|
||||||
|
*/
|
||||||
|
private class EventHooks {
|
||||||
|
private final DeltaSink sink;
|
||||||
|
private final Set<Long> dirtyColumns = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
EventHooks(DeltaSink sink) {
|
||||||
|
this.sink = sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onBlockBreak(BreakBlockEvent event) {
|
||||||
|
markDirty(event.getLevel(), event.getPos());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onBlockPlace(BlockEvent.EntityPlaceEvent event) {
|
||||||
|
markDirty(event.getLevel(), event.getPos());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onChunkLoad(ChunkEvent.Load event) {
|
||||||
|
if (event.getLevel() != level) return;
|
||||||
|
LevelChunk chunk = event.getChunk();
|
||||||
|
int chunkX = chunk.getPos().x();
|
||||||
|
int chunkZ = chunk.getPos().z();
|
||||||
|
loadedChunks.add(key(chunkX, chunkZ));
|
||||||
|
for (DeltaEvent e : readChunk(dimensionId, chunkX, chunkZ)) {
|
||||||
|
sink.onDelta(e);
|
||||||
|
}
|
||||||
|
sink.onChunkDirty(chunkX, chunkZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onChunkUnload(ChunkEvent.Unload event) {
|
||||||
|
if (event.getLevel() != level) return;
|
||||||
|
LevelChunk chunk = event.getChunk();
|
||||||
|
loadedChunks.remove(key(chunk.getPos().x(), chunk.getPos().z()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public void onServerTick(ServerTickEvent.Post event) {
|
||||||
|
if (dirtyColumns.isEmpty()) return;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Set<Long> dirtyChunks = new LinkedHashSet<Long>();
|
||||||
|
Iterator<Long> it = dirtyColumns.iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
long key = it.next();
|
||||||
|
it.remove();
|
||||||
|
int wx = (int) (key >> 32);
|
||||||
|
int wz = (int) key;
|
||||||
|
sink.onDelta(readColumn(wx, wz, now, DeltaEvent.Source.EVENT));
|
||||||
|
dirtyChunks.add(key(wx >> 4, wz >> 4));
|
||||||
|
}
|
||||||
|
for (long chunkKey : dirtyChunks) {
|
||||||
|
sink.onChunkDirty((int) (chunkKey >> 32), (int) chunkKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markDirty(LevelAccessor eventLevel, BlockPos pos) {
|
||||||
|
if (eventLevel != level) return;
|
||||||
|
dirtyColumns.add(key(pos.getX(), pos.getZ()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long key(int a, int b) {
|
||||||
|
return (((long) a) << 32) | (b & 0xFFFFFFFFL);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-5
@@ -17,8 +17,12 @@ include 'forge-1_12_2'
|
|||||||
// neoforge-26_1 is intentionally NOT included in the default build: both legacy leaves
|
// neoforge-26_1 is intentionally NOT included in the default build: both legacy leaves
|
||||||
// (forge-1_7_10, forge-1_12_2) use anatawa12's ForgeGradle 1.2/2.3 forks, which need Gradle 7.6
|
// (forge-1_7_10, forge-1_12_2) use anatawa12's ForgeGradle 1.2/2.3 forks, which need Gradle 7.6
|
||||||
// and JDK 8 (see gradle.properties' org.gradle.java.home pin and this project's
|
// and JDK 8 (see gradle.properties' org.gradle.java.home pin and this project's
|
||||||
// gradle-wrapper.properties). ModDevGradle (used by neoforge-26_1) needs Gradle 8+ and JDK 17+
|
// gradle-wrapper.properties). ModDevGradle (used by neoforge-26_1) needs Gradle 8+ and a Java 25
|
||||||
// to even apply the plugin — incompatible with that daemon within one invocation. Until Phase 10
|
// toolchain (Minecraft itself now requires Java 25 as of the 26.x cycle) — incompatible with
|
||||||
// gives it a proper isolated build setup, it can only be built by pointing org.gradle.java.home
|
// that daemon within one invocation. Phase 10 gave it its own standalone build instead: it has
|
||||||
// at a JDK 21+ install and invoking Gradle (8+) from inside the neoforge-26_1/ directory
|
// its own wrapper + settings.gradle (with the Foojay toolchain resolver, so Gradle
|
||||||
// directly, with its own wrapper. The module itself is fully scaffolded and ready.
|
// auto-provisions the JDK 25 toolchain rather than needing one pre-installed) — build it from
|
||||||
|
// inside neoforge-26_1/ directly (`cd neoforge-26_1 && ./gradlew build`), pointing
|
||||||
|
// org.gradle.java.home at a JDK 21+ install for the Gradle daemon itself via a separate
|
||||||
|
// $GRADLE_USER_HOME (same mechanism as the legacy leaves' JDK 8 pin) — see neoforge-26_1's
|
||||||
|
// README section for the full command.
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Copy to .env (untracked) and adjust if the defaults in docker-compose.yml don't fit — none of
|
||||||
|
# these need to be set for the default `mc-1_12_2` service to work as-is.
|
||||||
|
|
||||||
|
# MC_1_12_2_PORT=25565
|
||||||
|
# MC_1_12_2_MEMORY=3G
|
||||||
|
# MC_1_12_2_ONLINE_MODE=FALSE
|
||||||
|
|
||||||
|
# MC_1_7_10_PORT=25566
|
||||||
|
# MC_1_7_10_MEMORY=2G
|
||||||
|
# MC_1_7_10_ONLINE_MODE=FALSE
|
||||||
|
|
||||||
|
# MC_NEOFORGE_26_1_PORT=25567
|
||||||
|
# MC_NEOFORGE_26_1_MEMORY=3G
|
||||||
|
# MC_NEOFORGE_26_1_ONLINE_MODE=FALSE
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Real jars/configs/world data dropped in here for local live-testing — never committed. The
|
||||||
|
# per-loader-per-version directories themselves are tracked via .gitkeep so `docker compose up`
|
||||||
|
# has somewhere to bind-mount without a manual `mkdir` first.
|
||||||
|
mods/*/*/*
|
||||||
|
config/*/*/*
|
||||||
|
!mods/*/*/.gitkeep
|
||||||
|
!config/*/*/.gitkeep
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
# Live test servers
|
||||||
|
|
||||||
|
Real Minecraft servers (via [itzg/docker-minecraft-server](https://github.com/itzg/docker-minecraft-server),
|
||||||
|
MIT — handles EULA acceptance, Forge/NeoForge installation, memory flags, etc. so this compose
|
||||||
|
file doesn't reimplement any of that) for exercising the mod end-to-end against a running
|
||||||
|
MCMapper-Backend stack — the thing the plan's own per-phase verification steps keep asking for
|
||||||
|
("place/break blocks, confirm they appear on the map") but that no session has actually done yet.
|
||||||
|
|
||||||
|
Only `mc-1_12_2` runs by default — it's the actual driving use case (Enigmatica 2, see the plan's
|
||||||
|
version priority). `mc-1_7_10` and `mc-neoforge-26_1` are opt-in via Compose profiles:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up # 1.12.2 only (default)
|
||||||
|
docker compose --profile legacy-1_7_10 up # + 1.7.10
|
||||||
|
docker compose --profile neoforge-26_1 up # + 26.1.2 NeoForge (least tested, lowest priority)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Start a backend stack
|
||||||
|
|
||||||
|
From `MCMapper-Backend`, either:
|
||||||
|
- `docker compose up` (full stack — api/frontend/worker/postgres/redis/caddy; Caddy publishes
|
||||||
|
`:80` on the host, routing `/ws*` to `api` per the Caddyfile), or
|
||||||
|
- run `api` directly (`cd api && bun run dev`) for a lighter loop — it listens on `:3000` with no
|
||||||
|
proxy in front.
|
||||||
|
|
||||||
|
Either way, register a server row so the mod has a token to authenticate with:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd MCMapper-Backend/api
|
||||||
|
MCMAPPER_SEED_SERVER_NAME=test-1_12_2 MCMAPPER_SEED_SERVER_TOKEN=<pick-any-string> \
|
||||||
|
MCMAPPER_SEED_SERVER_AUTH_MODE=offline \
|
||||||
|
DATABASE_URL=postgres://mcmapper:mcmapper@localhost:<pg-port>/mcmapper bun run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
(`authMode=offline` matches this compose's default `ONLINE_MODE=FALSE` — see docker-compose.yml's
|
||||||
|
comment on that variable. Use `online` + `ONLINE_MODE=TRUE` instead to test the real UUID-merge
|
||||||
|
identity path.) The admin panel (`/admin` on the frontend, Phase 6) is the other way to register a
|
||||||
|
server, if the backend is already up with `MCMAPPER_ADMIN_TOKEN` set.
|
||||||
|
|
||||||
|
## 2. Build the mod and drop the jar in
|
||||||
|
|
||||||
|
```
|
||||||
|
cd MCMapper-Mod
|
||||||
|
./gradlew :forge-1_12_2:build
|
||||||
|
cp forge-1_12_2/build/libs/forge-1_12_2-*.jar test/mods/forge/1.12.2/
|
||||||
|
```
|
||||||
|
|
||||||
|
(Swap the leaf/version for `forge-1_7_10`/`mods/forge/1.7.10` or `neoforge-26_1`/`mods/neoforge/26.1.2`
|
||||||
|
— note `neoforge-26_1` has its own standalone Gradle wrapper, see the root README's "Building". Mods
|
||||||
|
are split by loader first, then version — Forge and NeoForge mod jars aren't interchangeable even
|
||||||
|
for adjacent MC versions. Each `mods/<loader>/<version>/` directory maps straight to that server's
|
||||||
|
`/data/mods` — drop other mod jars in there too to test alongside MCMapper, and remove/swap them any
|
||||||
|
time, then `docker compose restart` to pick up the change; nothing in `mods/`/`config/` is ever
|
||||||
|
committed, see .gitignore.)
|
||||||
|
|
||||||
|
## 3. First boot, then configure
|
||||||
|
|
||||||
|
```
|
||||||
|
cd test
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f mc-1_12_2 # wait for "Done" / world generation to finish
|
||||||
|
```
|
||||||
|
|
||||||
|
The mod writes `config/mcmapper.cfg` with empty defaults on this first boot (it needs a
|
||||||
|
`backendUrl`/`serverToken` before it'll actually connect — see the root README's "Configuration"
|
||||||
|
section). Edit `test/config/forge/1.12.2/mcmapper.cfg`:
|
||||||
|
|
||||||
|
```
|
||||||
|
backendUrl=ws://host.docker.internal:80/ws
|
||||||
|
serverToken=<the token you seeded above>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `ws://host.docker.internal:80/ws` if the backend is running via its own `docker compose up`
|
||||||
|
(Caddy on host `:80`), or `ws://host.docker.internal:3000/ws` if you ran `bun run dev` for `api`
|
||||||
|
directly instead (no Caddy in front). `extra_hosts: host.docker.internal:host-gateway` in
|
||||||
|
docker-compose.yml is what makes that hostname resolve to the host machine from inside the MC
|
||||||
|
container — works the same whether the host's Docker is Docker Desktop or a bare Linux daemon
|
||||||
|
(Engine ≥20.10, which this project's own WSL2 dockerd is).
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose restart mc-1_12_2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Verify
|
||||||
|
|
||||||
|
Connect a real Minecraft 1.12.2 client to `localhost:${MC_1_12_2_PORT:-25565}` (offline/cracked
|
||||||
|
login works fine with the default `ONLINE_MODE=FALSE`), walk around to load some chunks, place/
|
||||||
|
break a few blocks, then check the map frontend (`http://localhost` if using the backend's own
|
||||||
|
`docker compose`, or `http://localhost:3001` if running `frontend` directly) — this is the actual
|
||||||
|
Phase 1 verification step from the plan, finally exercised against a real server rather than only
|
||||||
|
unit/integration tests. Also useful for later phases' own live-test callouts that were flagged but
|
||||||
|
never actually run: the atlas shader's `invertY` assumption (Phase 12) and real non-cube block
|
||||||
|
rendering (Phase 13, place/find a vanilla non-cube block like a torch or stairs — or attach a mod
|
||||||
|
that ships one, e.g. Thaumcraft, by dropping its jar in the same `mods/forge/1.12.2/` directory).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- World/server data persists in named Docker volumes (`mc-1_12_2-data` etc.) across
|
||||||
|
`docker compose restart`/`down` — use `docker compose down -v` to fully wipe a server and start
|
||||||
|
over (e.g. after bumping the mod jar in a way that needs a clean world).
|
||||||
|
- `mods/<loader>/<version>/` and `config/<loader>/<version>/` contents are gitignored (except the
|
||||||
|
`.gitkeep` placeholders that keep the mount points present in a fresh checkout) — nothing dropped
|
||||||
|
in them is committed.
|
||||||
|
- To add other mods for stress-testing (Thaumcraft was the plan's named non-cube-model stress
|
||||||
|
test for Phase 13) drop their jars in the same `mods/forge/1.12.2/` directory itzg's image reads
|
||||||
|
from — no compose changes needed. itzg's image also supports `CURSEFORGE_FILES`/`MODRINTH_PROJECTS`
|
||||||
|
for auto-downloading specific mods (CurseForge's downloads generally need a `CF_API_KEY` now);
|
||||||
|
not wired up here since that needs per-mod IDs/an API key only you can supply — see itzg's own
|
||||||
|
docs (https://docker-minecraft-server.readthedocs.io/) if you want that instead of manual jars.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Real Minecraft servers for live-testing the mod against a running MCMapper-Backend stack —
|
||||||
|
# see README.md in this directory for the full walkthrough (build the mod, seed a backend server
|
||||||
|
# token, drop the jar in mods/<loader>/<version>/, docker compose up). Mods are split by loader
|
||||||
|
# first, then version — Forge and NeoForge mod jars aren't interchangeable even for adjacent MC
|
||||||
|
# versions, and a future Fabric leaf (Phase 14) would add its own mods/fabric/<version> alongside
|
||||||
|
# these rather than needing a reshuffle.
|
||||||
|
#
|
||||||
|
# Uses itzg/docker-minecraft-server (https://github.com/itzg/docker-minecraft-server, MIT) — the
|
||||||
|
# de facto standard MC server image, handles EULA/Forge/NeoForge installation, memory flags, etc.
|
||||||
|
# so this compose file doesn't have to reimplement any of that.
|
||||||
|
#
|
||||||
|
# Only `mc-1_12_2` (the actual driving use case — Enigmatica 2, see the plan's version priority)
|
||||||
|
# runs by default. The other two leaves are opt-in via Compose profiles, since running all three
|
||||||
|
# at once is a lot of RAM for a local dev box:
|
||||||
|
# docker compose up # 1.12.2 only (default)
|
||||||
|
# docker compose --profile legacy-1_7_10 up # + 1.7.10
|
||||||
|
# docker compose --profile neoforge-26_1 up # + 26.1.2 (NeoForge) — least tested, lowest MVP priority
|
||||||
|
|
||||||
|
services:
|
||||||
|
mc-1_12_2:
|
||||||
|
# itzg's `:latest` tag now defaults to a Java 25 base — legacy Forge's LaunchWrapper hard-
|
||||||
|
# crashes on anything past Java 8 (`ClassCastException: AppClassLoader cannot be cast to
|
||||||
|
# URLClassLoader`, confirmed by actually booting this service against `:latest` first). Same
|
||||||
|
# JDK 8 constraint as this repo's own build toolchain (see ../gradle.properties), just for the
|
||||||
|
# server *runtime* instead of the compiler this time.
|
||||||
|
image: itzg/minecraft-server:java8
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
ports:
|
||||||
|
- "${MC_1_12_2_PORT:-25565}:25565"
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
TYPE: "FORGE"
|
||||||
|
VERSION: "1.12.2"
|
||||||
|
# Same build pinned in ../gradle.properties (forge_1_12_2_version) — keep these in sync.
|
||||||
|
FORGE_VERSION: "14.23.5.2847"
|
||||||
|
MEMORY: "${MC_1_12_2_MEMORY:-3G}"
|
||||||
|
# Offline/cracked mode by default so any MC client can connect with no Microsoft account
|
||||||
|
# needed for a quick local smoke test — matches the plan's authMode="offline" identity
|
||||||
|
# scoping (see MCMapper-Backend's README). Flip to TRUE (and register the backend server
|
||||||
|
# row with authMode=online) to test the real online-mode UUID-merge path instead.
|
||||||
|
ONLINE_MODE: "${MC_1_12_2_ONLINE_MODE:-FALSE}"
|
||||||
|
volumes:
|
||||||
|
- mc-1_12_2-data:/data
|
||||||
|
# Drop the built forge-1_12_2 jar (see README.md — `./gradlew :forge-1_12_2:build`,
|
||||||
|
# output in ../forge-1_12_2/build/libs/) — and any other mod jars to stress-test against
|
||||||
|
# (e.g. Thaumcraft, the plan's named Phase 13 non-cube-model target) — straight into this
|
||||||
|
# directory; itzg's image loads anything here as-is, no repackaging needed. Add/remove jars
|
||||||
|
# any time and `docker compose restart mc-1_12_2` to pick up changes.
|
||||||
|
- ./mods/forge/1.12.2:/data/mods
|
||||||
|
# The mod writes config/mcmapper.cfg here on first boot (see the mod repo README's
|
||||||
|
# "Configuration" section) — edit backendUrl/serverToken in this mounted file, then
|
||||||
|
# `docker compose restart mc-1_12_2`.
|
||||||
|
- ./config/forge/1.12.2:/data/config
|
||||||
|
# Lets `backendUrl=ws://host.docker.internal:<port>/ws` in the mounted config reach a backend
|
||||||
|
# stack running on the host (either MCMapper-Backend's own `docker compose up`, published on
|
||||||
|
# host port 80 via its Caddy, or a bare `bun run dev` api on host port 3000) — see README.md.
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mc-1_7_10:
|
||||||
|
# Same Java-8 requirement as mc-1_12_2 above — 1.7.10's Forge/LaunchWrapper generation is
|
||||||
|
# even older, so it needs this at least as much.
|
||||||
|
image: itzg/minecraft-server:java8
|
||||||
|
profiles: ["legacy-1_7_10"]
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
ports:
|
||||||
|
- "${MC_1_7_10_PORT:-25566}:25565"
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
TYPE: "FORGE"
|
||||||
|
VERSION: "1.7.10"
|
||||||
|
# Same build pinned in ../gradle.properties (forge_1_7_10_version=10.13.4.1614-1.7.10), but
|
||||||
|
# itzg's installer rejects that full string ("Invalid value for --forge-version") — its
|
||||||
|
# --forge-version validation only accepts the plain dot-separated build number, not the
|
||||||
|
# "-1.7.10" suffix Forge's own artifact naming appends for this era. Confirmed by actually
|
||||||
|
# booting the container: the suffixed value fails immediately with that validation error;
|
||||||
|
# the trimmed value passes validation and proceeds to actually download the installer
|
||||||
|
# (any further failures at that point are files.minecraftforge.net connectivity, unrelated
|
||||||
|
# to this value).
|
||||||
|
FORGE_VERSION: "10.13.4.1614"
|
||||||
|
MEMORY: "${MC_1_7_10_MEMORY:-2G}"
|
||||||
|
ONLINE_MODE: "${MC_1_7_10_ONLINE_MODE:-FALSE}"
|
||||||
|
volumes:
|
||||||
|
- mc-1_7_10-data:/data
|
||||||
|
- ./mods/forge/1.7.10:/data/mods
|
||||||
|
- ./config/forge/1.7.10:/data/config
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mc-neoforge-26_1:
|
||||||
|
# Unlike the two legacy leaves above, this one actually *wants* a modern JRE (Minecraft
|
||||||
|
# itself requires Java 25 as of the 26.x cycle, see ../neoforge-26_1/settings.gradle) — plain
|
||||||
|
# `:latest` already resolves to a java25 base as of this writing (confirmed by actually
|
||||||
|
# pulling it), so no separate tag pin needed here.
|
||||||
|
image: itzg/minecraft-server:latest
|
||||||
|
profiles: ["neoforge-26_1"]
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
ports:
|
||||||
|
- "${MC_NEOFORGE_26_1_PORT:-25567}:25565"
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
TYPE: "NEOFORGE"
|
||||||
|
VERSION: "26.1.2"
|
||||||
|
# Same build pinned in ../gradle.properties (neoforge_26_1_version). This is the least
|
||||||
|
# exercised leaf (lowest MVP priority, see the plan) — if itzg's installer doesn't yet
|
||||||
|
# recognize this exact MC/NeoForge version pairing, check for an itzg image update first.
|
||||||
|
NEOFORGE_VERSION: "26.1.2.94"
|
||||||
|
MEMORY: "${MC_NEOFORGE_26_1_MEMORY:-3G}"
|
||||||
|
ONLINE_MODE: "${MC_NEOFORGE_26_1_ONLINE_MODE:-FALSE}"
|
||||||
|
volumes:
|
||||||
|
- mc-neoforge-26_1-data:/data
|
||||||
|
- ./mods/neoforge/26.1.2:/data/mods
|
||||||
|
# NeoForge writes config/mcmapper-server.toml (TOML, not the legacy leaves' .cfg) — see
|
||||||
|
# the mod repo README's "Configuration" section.
|
||||||
|
- ./config/neoforge/26.1.2:/data/config
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mc-1_12_2-data:
|
||||||
|
mc-1_7_10-data:
|
||||||
|
mc-neoforge-26_1-data:
|
||||||
Reference in New Issue
Block a user