From e041b0af3bcad479922ebbb6cf65ee974d0dd6c5 Mon Sep 17 00:00:00 2001 From: A-BenLi06 Date: Mon, 26 Oct 2026 10:38:15 +0800 Subject: [PATCH 1/3] feat: port station PIDS and navigation view API to Minecraft 1.20 --- AGENTS.md | 4 +- README.md | 3 + .../teamcreating/mtrmap/api/MtrMapApi.java | 130 ++++++++ build.gradle | 7 +- .../JourneyMapForegroundRenderer.java | 48 +-- .../mtrmap/extension/OverlayUiEvents.java | 18 ++ .../mtrmap/network/MTRNetwork.java | 9 + .../mtrmap/network/MTRNetworkClient.java | 4 +- docs/agents/MESSAGES.md | 6 + docs/agents/STATE.md | 5 +- docs/agents/TASKS.md | 2 + docs/api/overlay-api-v1.md | 53 ++++ docs/validation/pids-and-dimmed-1.5.3.md | 41 +++ fabric/build.gradle | 5 + .../mtrmap/CommandRegistration.java | 8 + .../io/github/teamcreating/mtrmap/MTRMap.java | 7 +- .../teamcreating/mtrmap/MTRMapClient.java | 9 + .../mtrmap/config/MTRMapConfig.java | 2 + .../client/xaero/XaeroWorldMapMixin.java | 8 - .../mtrmap/network/MTRNetwork.java | 9 +- .../mtrmap/network/MTRNetworkClient.java | 1 + .../main/resources/mtrmap.fabric.mixins.json | 3 +- gradle.properties | 2 +- gradle/overlay-api.gradle | 21 ++ .../mtrmap/CommandRegistration.java | 10 + .../mtrmap/config/MTRMapConfig.java | 5 + .../teamcreating/mtrmap/config/PidsStyle.java | 15 + .../extension/ClientOverlayRuntime.java | 180 +++++++++++ .../mtrmap/extension/CoreOverlayAdapter.java | 112 +++++++ .../mtrmap/extension/OverlayAssembly.java | 25 ++ .../mtrmap/extension/OverlayCodec.java | 88 ++++++ .../mtrmap/extension/OverlayUi.java | 293 ++++++++++++++++++ .../mtrmap/extension/OverlayUiEvents.java | 18 ++ .../mtrmap/extension/PidsBoardModel.java | 39 +++ .../mtrmap/extension/SectionResolver.java | 23 ++ .../extension/ServerOverlayService.java | 141 +++++++++ .../mtrmap/extension/ViewRegistry.java | 57 ++++ .../mtrmap/extension/ViewRenderer.java | 103 ++++++ .../JourneyMapForegroundRenderer.java | 49 +-- .../journeymap/JourneyMapLandmarkManager.java | 10 +- .../journeymap/JourneyMapPathManager.java | 1 + .../integration/xaero/XaeroRouteRenderer.java | 37 ++- .../mtrmap/mapdata/MapDataCache.java | 9 +- .../teamcreating/mtrmap/mapdata/MapTrack.java | 4 +- .../mtrmap/mapdata/PlanarMapCache.java | 2 +- .../mtrmap/mapdata/RouteRibbonGeometry.java | 16 +- .../mtrmap/mapdata/RouteRibbonProfile.java | 4 +- .../mtrmap/mapdata/SpanMapTrack.java | 38 +++ .../mtrmap/mapdata/TrackRoutePalette.java | 4 +- .../mixin/client/OverlayPointerMixin.java | 17 + .../client/xaero/TrainMapPauseMixin.java | 2 +- .../client/xaero/XaeroWorldMapMixin.java | 11 - .../mtrmap/network/ClientNetworkSync.java | 3 + .../mtrmap/network/MTRNetwork.java | 9 + .../mtrmap/network/MTRNetworkClient.java | 4 +- .../mtrmap/network/OverlayChunk.java | 7 + .../mtrmap/network/OverlayRequest.java | 6 + .../network/ServerNetworkCollector.java | 2 +- .../mtrmap/train/TrainServerEvents.java | 6 +- .../resources/assets/mtrmap/lang/en_us.json | 58 +++- .../resources/assets/mtrmap/lang/zh_cn.json | 58 +++- .../resources/assets/mtrmap/lang/zh_tw.json | 58 +++- src/main/resources/mtrmap.mixins.json | 3 +- .../extension/CoreOverlayAdapterTest.java | 56 ++++ .../extension/OverlayContractsTest.java | 63 ++++ .../mtrmap/extension/OverlayGeometryTest.java | 78 +++++ .../mtrmap/extension/PidsBoardModelTest.java | 38 +++ tools/ci/core.py | 17 +- tools/ci/test_pipeline.py | 19 ++ 69 files changed, 2055 insertions(+), 148 deletions(-) create mode 100644 api/src/main/java/io/github/teamcreating/mtrmap/api/MtrMapApi.java create mode 100644 compat/mc1204/forge/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java create mode 100644 docs/api/overlay-api-v1.md create mode 100644 docs/validation/pids-and-dimmed-1.5.3.md create mode 100644 gradle/overlay-api.gradle create mode 100644 src/main/java/io/github/teamcreating/mtrmap/config/PidsStyle.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/ClientOverlayRuntime.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapter.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/OverlayAssembly.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/OverlayCodec.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUi.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/PidsBoardModel.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/SectionResolver.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/ServerOverlayService.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/ViewRegistry.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/extension/ViewRenderer.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/mapdata/SpanMapTrack.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/mixin/client/OverlayPointerMixin.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/network/OverlayChunk.java create mode 100644 src/main/java/io/github/teamcreating/mtrmap/network/OverlayRequest.java create mode 100644 src/test/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapterTest.java create mode 100644 src/test/java/io/github/teamcreating/mtrmap/extension/OverlayContractsTest.java create mode 100644 src/test/java/io/github/teamcreating/mtrmap/extension/OverlayGeometryTest.java create mode 100644 src/test/java/io/github/teamcreating/mtrmap/extension/PidsBoardModelTest.java diff --git a/AGENTS.md b/AGENTS.md index b51a43a..01bc45c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## 当前兼容分支例外(2026-09-30,Codex) -本 worktree 为用户明确要求的 `compat/mtr-4.0-mc-1.20` 分支:**Forge/Fabric MC 1.20.1 / 1.20.4 + MTR 4.0.5,mod/pack/metadata 均保持 v1.5.2**。用户于2026-10-22明确要求移植最新功能后测试所有平台。禁止将这里的 Forge 代码覆盖回 main。下文的 NeoForge 1.21.1 平台约束适用于 main;本分支遵循上面的兼容目标。构建 Gradle 用 JDK 21,产物为 Java 17;`gradlew build` 与 `fabric/gradlew -p fabric build` 均运行共用测试;加 `-Pminecraft_version=1.20.4` 构建独立 1.20.4 目标。权威状态见 STATE.md,构建/功能说明以本分支 README 为准。 +本 worktree 为用户明确要求的 `compat/mtr-4.0-mc-1.20` 分支:**Forge/Fabric MC 1.20.1 / 1.20.4 + MTR 4.0.5,当前开发 mod/pack/metadata 为用户指定 v1.5.3(已发布稳定版仍1.5.2)**。用户于2026-10-22明确要求移植最新功能后测试所有平台。禁止将这里的 Forge 代码覆盖回 main。下文的 NeoForge 1.21.1 平台约束适用于 main;本分支遵循上面的兼容目标。构建 Gradle 用 JDK 21,产物为 Java 17;`gradlew build` 与 `fabric/gradlew -p fabric build` 均运行共用测试;加 `-Pminecraft_version=1.20.4` 构建独立 1.20.4 目标。权威状态见 STATE.md,构建/功能说明以本分支 README 为准。 ## 0. 三条铁律(违反 = 事故重演) @@ -14,7 +14,7 @@ main 分支上不存在 Forge 1.20.1 代码(旧代码只存在于 git 历史 `77121f5` 之前)。 如果你发现工作区/代码"看起来像 Forge 1.20.1"或与你记忆中的项目不符——**是你的认知过时了**, 请先读 `docs/agents/STATE.md` 和 `walkthrough.md`,而不是"修复"它。 -2. **版本号由用户决定**:当前固定 `mod_version=1.5.2`,仅用户明确要求时才更改;禁止自动递增。 +2. **版本号由用户决定**:用户指定1.5.3并授权实施,本兼容移植的开发版本为 `mod_version=1.5.3`;禁止自动递增。 版本号会自动展开进 `META-INF/neoforge.mods.toml` 和 `pack.mcmeta` 的 description (`pack.mcmeta` 用 `${version}` 占位符,不要手写死版本号)。 3. **禁止破坏性 git 操作**:不得执行 `git reset --hard`、`git checkout -- .`、`git push --force`、 diff --git a/README.md b/README.md index 4cb00b1..094e4d7 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ The server component is optional. This branch provides separate JARs for **MC 1. ## Commands and configuration +**1.5.3 development:** station arrival boards, two PIDS themes, owned view/dimmed spans and the optional navigation entry are documented in the [public API](docs/api/overlay-api-v1.md). The published stable release remains 1.5.2. + Commands are registered on the **client**, so they are available even when the server does not run this mod. | Command | Purpose | @@ -63,6 +65,7 @@ Commands are registered on the **client**, so they are available even when the s | `/mtrmap config routeLines ` | Show or hide route ribbons on both maps. | | `/mtrmap config trackLines ` | Show or hide physical rails on both maps. | | `/mtrmap config trainCars ` | Show or hide carriages on both maps. | +| `/mtrmap config pidsStyle british\|european` | 1.5.3 development: station PIDS style; British is the default. Query with no argument. | Forge stores settings in `config/mtrmap.toml` and copies an existing `mtrsurveyor.toml` on first launch when the new file is absent. Fabric uses `config/mtrmap.properties` with its own defaults. The two formats are not automatically interchangeable. Important options include `networkSync.enabled` (default `true`), `networkSync.refreshIntervalSeconds` (default `300`), and the station/platform/depot visibility switches. diff --git a/api/src/main/java/io/github/teamcreating/mtrmap/api/MtrMapApi.java b/api/src/main/java/io/github/teamcreating/mtrmap/api/MtrMapApi.java new file mode 100644 index 0000000..2c59af1 --- /dev/null +++ b/api/src/main/java/io/github/teamcreating/mtrmap/api/MtrMapApi.java @@ -0,0 +1,130 @@ +package io.github.teamcreating.mtrmap.api; + +import java.util.*; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; + +/** Optional, client-thread API. Compile against the API JAR; do not shade it. */ +public final class MtrMapApi { + private static Client provider; + private MtrMapApi() {} + public static Optional findClient() { return Optional.ofNullable(provider); } + /** Runtime bootstrap; consumers must not replace the provider. */ + public static void install(Client client) { + if (provider != null && provider != client) throw new IllegalStateException("Provider already installed"); + provider = Objects.requireNonNull(client); + } + public enum Status { LOADING, READY, LIMITED, STALE, UNSUPPORTED, NOT_FOUND, ERROR, INVALID, AMBIGUOUS, UNAVAILABLE, CLOSED } + public record WorldRef(UUID serverEpoch, String dimensionId) { + public WorldRef { Objects.requireNonNull(serverEpoch); Objects.requireNonNull(dimensionId); } + } + public record Point(double x, double y, double z) {} + public record Station(String id, String name, Point position, List platformIds) { + public Station { platformIds = List.copyOf(platformIds); } + } + public record Platform(String id, String stationId, String name, Point position) {} + public record Stop(int ordinal, String platformId, String stationId, String destination) {} + /** Fractions use canonical rail direction and native Core distance, including slopes. */ + public record RailSpan(String railId, double from, double to) { + public RailSpan { + Objects.requireNonNull(railId); + if (!Double.isFinite(from) || !Double.isFinite(to) || from < 0 || from > 1 || to < 0 || to > 1 || from == to) + throw new IllegalArgumentException("Rail fractions must be distinct and within [0,1]"); + } + } + public record Leg(int fromStop, int toStop, List rails) { + public Leg { rails = List.copyOf(rails); } + } + public record PathVariant(String id, List legs) { + public PathVariant { legs = List.copyOf(legs); } + } + public record Route(String id, String name, String number, int color, String type, List stops, List paths) { + public Route { stops = List.copyOf(stops); paths = List.copyOf(paths); } + } + public record Catalog(WorldRef world, long revision, long baseStaticRevision, Status status, + List stations, List platforms, List routes) { + public Catalog { stations = List.copyOf(stations); platforms = List.copyOf(platforms); routes = List.copyOf(routes); } + } + public record Track(String id, List points, double nativeLength) { + public Track { points = List.copyOf(points); } + } + public record RouteSection(String routeId, int fromStop, int toStop, Optional pathVariant) { + public RouteSection { Objects.requireNonNull(routeId); pathVariant = Objects.requireNonNull(pathVariant); } + } + /** routeId can be empty for a physical-rail selection. */ + public record SelectedSpan(String routeId, RailSpan rail) { + public SelectedSpan { Objects.requireNonNull(routeId); Objects.requireNonNull(rail); } + } + public record Resolution(Status status, long catalogRevision, List spans, List candidates) { + public Resolution { spans = List.copyOf(spans); candidates = List.copyOf(candidates); } + } + public enum Purpose { FOCUS, DECORATION } + public enum Unmatched { KEEP, DIM, HIDE } + public enum Pattern { INHERIT, SOLID, COLOUR_WHITE } + public record Style(Optional color, double opacity, double widthScale, Pattern pattern) { + public Style { + Objects.requireNonNull(color); Objects.requireNonNull(pattern); + if (!Double.isFinite(opacity) || opacity < 0 || opacity > 1 || !Double.isFinite(widthScale) || widthScale < 0.25 || widthScale > 4) + throw new IllegalArgumentException("Invalid style"); + } + public static Style inherit() { return new Style(Optional.empty(), 1, 1, Pattern.INHERIT); } + public static Style dimmed(double opacity) { return new Style(Optional.empty(), opacity, 1, Pattern.INHERIT); } + } + /** A local override in native rail distance; later overrides win at overlapping points. */ + public record SpanStyle(SelectedSpan span, Style style) { + public SpanStyle { Objects.requireNonNull(span); Objects.requireNonNull(style); } + } + public record ViewRequest(WorldRef world, long catalogRevision, List spans, + Set wholeRoutes, Unmatched unmatched, Map routeStyles, + boolean showTrains, Set stationIds, List spanStyles, double unmatchedOpacity) { + public ViewRequest(WorldRef world, long catalogRevision, List spans, Set wholeRoutes, + Unmatched unmatched, Map routeStyles, boolean showTrains, Set stationIds) { + this(world, catalogRevision, spans, wholeRoutes, unmatched, routeStyles, showTrains, stationIds, List.of(), .15); + } + public ViewRequest { + Objects.requireNonNull(world); Objects.requireNonNull(unmatched); + spans = List.copyOf(spans); wholeRoutes = Set.copyOf(wholeRoutes); routeStyles = Map.copyOf(routeStyles); stationIds = Set.copyOf(stationIds); + spanStyles = List.copyOf(spanStyles); + if (spans.size() > 20000 || spanStyles.size() > 20000 || stationIds.size() > 20000 || wholeRoutes.size() > 4096 || routeStyles.size() > 4096) throw new IllegalArgumentException("View exceeds capacity"); + if (!Double.isFinite(unmatchedOpacity) || unmatchedOpacity < 0 || unmatchedOpacity > 1) throw new IllegalArgumentException("Invalid unmatched opacity"); + } + /** The caller supplies navigation progress. Overlay never infers movement or computes a route. */ + public static ViewRequest navigation(WorldRef world, long revision, List remaining, + List completed, double completedOpacity, double unusedOpacity) { + var all = new ArrayList(completed); all.addAll(remaining); + var overrides = new ArrayList(); + for (var span : completed) overrides.add(new SpanStyle(span, Style.dimmed(completedOpacity))); + // Remaining wins when a repeated/circular journey traverses the same physical interval again. + for (var span : remaining) overrides.add(new SpanStyle(span, Style.inherit())); + return new ViewRequest(world, revision, all, Set.of(), Unmatched.DIM, Map.of(), true, Set.of(), overrides, unusedOpacity); + } + } + public interface ViewSession extends AutoCloseable { + CompletionStage replace(ViewRequest request); + void clear(); + @Override void close(); + } + public record Arrival(String routeId, String routeName, int color, String platformId, String platformName, + String destination, long arrivalAt, long departureAt, long deviation, boolean realtime, boolean terminating) {} + public record Service(String routeId, String platformId, String routeName, int color, String platformName) {} + public record Board(WorldRef world, String stationId, long generation, Status status, long sampledAt, + long serverNow, List services, List arrivals) { + public Board { services = List.copyOf(services); arrivals = List.copyOf(arrivals); } + } + public interface BoardSubscription extends AutoCloseable { + Board latest(); + @Override void close(); + } + public record NavigationTarget(WorldRef world, String stationId) {} + public interface Registration extends AutoCloseable { @Override void close(); } + public interface Client { + default String apiVersion() { return "1.0.0"; } + Optional world(String dimensionId); + Catalog catalog(WorldRef world); + List tracks(WorldRef world, Set railIds); + CompletionStage resolve(WorldRef world, long catalogRevision, List sections); + ViewSession openView(String owner, String displayName, Purpose purpose, int priority); + BoardSubscription watchStation(WorldRef world, String stationId, Consumer listener); + Registration registerNavigation(String owner, String displayName, Consumer handler); + } +} diff --git a/build.gradle b/build.gradle index b405489..d85a4a2 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,8 @@ version = project.mod_version group = project.maven_group base.archivesName = project.archives_base_name + '-forge-mc' + project.minecraft_version java.toolchain.languageVersion = JavaLanguageVersion.of(17) +ext.overlayRoot = projectDir +apply from: 'gradle/overlay-api.gradle' minecraft { mappings channel: 'official', version: project.minecraft_version runs { @@ -15,12 +17,12 @@ minecraft { workingDirectory project.file(project.findProperty('mtrmapRunDir') ?: 'run') if (project.hasProperty('quickplayServer')) args '--quickPlayMultiplayer', project.property('quickplayServer') else if (project.hasProperty('quickplay')) args '--quickPlaySingleplayer', project.property('quickplay') - mods { mtrmap { source sourceSets.main } } + mods { mtrmap { source sourceSets.main; source sourceSets.overlayApi } } } server { workingDirectory project.file(project.findProperty('mtrmapRunDir') ?: 'run-server') args 'nogui' - mods { mtrmap { source sourceSets.main } } + mods { mtrmap { source sourceSets.main; source sourceSets.overlayApi } } } } } @@ -82,6 +84,7 @@ if (project.hasProperty('mtrmapRuntimeTest')) { def mergedSmokeMod = tasks.register('mergeSmokeMod', Sync) { dependsOn tasks.named('classes') from sourceSets.main.output + from sourceSets.overlayApi.output into layout.buildDirectory.dir('runtime-mod') } tasks.withType(JavaExec).configureEach { diff --git a/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java b/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java index b694a40..097dc79 100644 --- a/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java +++ b/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java @@ -55,50 +55,24 @@ public static void render(GuiGraphics graphics, Object screen) { graphics.pose().translate(0, 0, 400); RenderSystem.setShaderColor(1, 1, 1, 1); scene = JourneyMapPathManager.render(graphics, MapDataCache.get(dimensionId), projection, - MTRMapConfig.INSTANCE.trainCarsEnabled.get() && bounds != null && MTRMapConfig.INSTANCE.enabled.get() + MTRMapConfig.INSTANCE.trainCarsEnabled.get() && io.github.teamcreating.mtrmap.extension.ViewRenderer.trains(dimensionId) && bounds != null && MTRMapConfig.INSTANCE.enabled.get() ? ClientTrainStream.cars(dimensionId, bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ(), projection.pixelsPerBlock()) : java.util.List.of()); graphics.flush(); - ResourceLocation currentTexture = null; - int currentColor = 0; - float currentOpacity = Float.NaN; - for (MarkerOverlay marker : JourneyMapLandmarkManager.displayedMarkers()) { - final MapImage icon = marker.getIcon(); - if (!viewedDimension.equals(marker.getDimension()) || icon.getImageLocation() == null) { - continue; - } - final int width = (int) Math.round(icon.getDisplayWidth()); - final int height = (int) Math.round(icon.getDisplayHeight()); - final int x = (int) Math.round(projection.x(marker.getPoint().getX(), marker.getPoint().getZ()) - - icon.getAnchorX()); - final int y = (int) Math.round(projection.y(marker.getPoint().getX(), marker.getPoint().getZ()) - - icon.getAnchorY()); - if (x + width < 0 || y + height < 0 || x >= graphics.guiWidth() || y >= graphics.guiHeight()) { - continue; - } - final int color = icon.getColor(); - final float opacity = icon.getOpacity(); - final ResourceLocation texture = icon.getImageLocation(); - if (!texture.equals(currentTexture) || color != currentColor || opacity != currentOpacity) { - // Buffered blits use the shader tint at flush time. Keep - // adjacent icons with the same state in one batch. - graphics.flush(); - RenderSystem.setShaderColor(((color >> 16) & 255) / 255f, - ((color >> 8) & 255) / 255f, (color & 255) / 255f, opacity); - currentTexture = texture; - currentColor = color; - currentOpacity = opacity; - } - graphics.blit(icon.getImageLocation(), x, y, width, height, - (float) icon.getTextureX(), (float) icon.getTextureY(), - icon.getTextureWidth(), icon.getTextureHeight(), - icon.getTextureWidth(), icon.getTextureHeight()); - } } finally { graphics.flush(); graphics.pose().popPose(); RenderSystem.setShaderColor(1, 1, 1, 1); } - if (scene != null) { + final double uiMouseX=minecraft.mouseHandler.xpos()/guiScale, uiMouseY=minecraft.mouseHandler.ypos()/guiScale; + graphics.pose().pushPose(); + try { + graphics.pose().translate(0,0,600); + io.github.teamcreating.mtrmap.extension.OverlayUi.render(graphics,dimensionId, + io.github.teamcreating.mtrmap.extension.ViewRenderer.data(MapDataCache.get(dimensionId)),projection.pixelsPerBlock(), + projection::x,projection::y,(int)uiMouseX,(int)uiMouseY,true); + graphics.flush(); + } finally { graphics.pose().popPose(); } + if (scene != null && !io.github.teamcreating.mtrmap.extension.OverlayUi.blocks(uiMouseX,uiMouseY) && !io.github.teamcreating.mtrmap.extension.OverlayUi.hasPoint(uiMouseX,uiMouseY)) { ClientTrainStream.drawStatus(graphics, dimensionId); final double mouseX = minecraft.mouseHandler.xpos() / guiScale; final double mouseY = minecraft.mouseHandler.ypos() / guiScale; diff --git a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java new file mode 100644 index 0000000..68ad826 --- /dev/null +++ b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java @@ -0,0 +1,18 @@ +package io.github.teamcreating.mtrmap.extension; +import io.github.teamcreating.mtrmap.MTRMap; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.client.event.ScreenEvent; + +@Mod.EventBusSubscriber(modid = MTRMap.MOD_ID, value = Dist.CLIENT) +public final class OverlayUiEvents { + private OverlayUiEvents() {} + @SubscribeEvent(priority = net.minecraftforge.eventbus.api.EventPriority.LOWEST) public static void render(ScreenEvent.Render.Post e) { + if (e.getScreen().getClass().getName().equals("xaero.map.gui.GuiMap")) io.github.teamcreating.mtrmap.integration.xaero.XaeroRouteRenderer.renderUi(); + } + @SubscribeEvent public static void down(ScreenEvent.MouseButtonPressed.Pre e) { if (OverlayUi.down(e.getMouseX(), e.getMouseY(), e.getButton())) e.setCanceled(true); } + @SubscribeEvent public static void up(ScreenEvent.MouseButtonReleased.Pre e) { if (OverlayUi.up(e.getMouseX(), e.getMouseY(), e.getButton())) e.setCanceled(true); } + @SubscribeEvent public static void wheel(ScreenEvent.MouseScrolled.Pre e) { if (OverlayUi.wheel(e.getMouseX(), e.getMouseY(), e.getDeltaY())) e.setCanceled(true); } + @SubscribeEvent public static void key(ScreenEvent.KeyPressed.Pre e) { if (OverlayUi.key(e.getKeyCode(), e.getModifiers())) e.setCanceled(true); } +} diff --git a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java index c3ce30c..25f3436 100644 --- a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java +++ b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java @@ -13,7 +13,14 @@ public final class MTRNetwork { .networkProtocolVersion(8).optional().simpleChannel(); public static final SimpleChannel TRAINS = ChannelBuilder.named(new ResourceLocation(MTRMap.MOD_ID, "trains_v1")) .networkProtocolVersion(1).optional().simpleChannel(); + public static final SimpleChannel OVERLAY = ChannelBuilder.named(new ResourceLocation(MTRMap.MOD_ID, "overlay_v1")).networkProtocolVersion(1).optional().simpleChannel(); public static void register() { + OVERLAY.messageBuilder(OverlayRequest.class, 0, NetworkDirection.PLAY_TO_SERVER) + .encoder((m,b)->OverlayRequest.write(b,m)).decoder(OverlayRequest::read) + .consumerMainThread((m,ctx)->{ var player = ctx.getSender(); if(player!=null) io.github.teamcreating.mtrmap.extension.ServerOverlayService.request(player,m); }).add(); + OVERLAY.messageBuilder(OverlayChunk.class, 1, NetworkDirection.PLAY_TO_CLIENT) + .encoder((m,b)->OverlayChunk.write(b,m)).decoder(OverlayChunk::read) + .consumerMainThread((m,ctx)->MTRNetworkClient.onOverlay(m)).add(); TRAINS.messageBuilder(TrainStreamRequest.class, 0, NetworkDirection.PLAY_TO_SERVER) .encoder((msg, buf) -> TrainStreamRequest.write(buf, msg)).decoder(TrainStreamRequest::read) .consumerMainThread((msg, ctx) -> { ServerPlayer sender = ctx.getSender(); if(sender != null) io.github.teamcreating.mtrmap.train.ServerTrainStream.request(sender, msg); }).add(); @@ -43,8 +50,10 @@ public static void register() { .encoder((msg, buf) -> NetworkProbeResponse.write(buf, msg)).decoder(NetworkProbeResponse::read) .consumerMainThread((msg, ctx) -> MTRNetworkClient.onProbe(msg)).add(); } + public static boolean canSendOverlay(ServerPlayer player) { return OVERLAY.isRemotePresent(player.connection.getConnection()); } public static boolean canSendTrains(ServerPlayer player) { return TRAINS.isRemotePresent(player.connection.getConnection()); } public static void sendToPlayer(ServerPlayer player, MapPacket packet) { + if(packet instanceof OverlayChunk) { if(canSendOverlay(player)) { OVERLAY.send(packet, player.connection.getConnection()); } return; } if (packet instanceof TrainStreamChunk || packet instanceof TrainStreamStatus) { if (canSendTrains(player)) { TRAINS.send(packet, player.connection.getConnection()); } return; diff --git a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java index 5c176a2..5e68a1f 100644 --- a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java +++ b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java @@ -2,6 +2,8 @@ import net.minecraft.client.Minecraft; /** Physical-client callbacks. Never executed on a dedicated server. */ public final class MTRNetworkClient { + static void onOverlay(OverlayChunk packet) { io.github.teamcreating.mtrmap.extension.ClientOverlayRuntime.INSTANCE.chunk(packet); } + public static boolean canSendOverlay() { var c=Minecraft.getInstance().getConnection(); return c!=null && MTRNetwork.OVERLAY.isRemotePresent(c.getConnection()); } static void onChunk(NetworkSyncChunk packet) { ClientNetworkSync.onChunkReceived(packet); } static void onProbe(NetworkProbeResponse packet) { ClientNetworkSync.onProbeReceived(packet.hashes()); } static void onTrainChunk(TrainStreamChunk packet) { io.github.teamcreating.mtrmap.train.ClientTrainStream.chunk(packet); } @@ -13,6 +15,6 @@ public static boolean canSendToServer() { } public static void sendToServer(MapPacket packet) { var listener = Minecraft.getInstance().getConnection(); - if (listener != null) { if (packet instanceof TrainStreamRequest) MTRNetwork.TRAINS.send(packet, listener.getConnection()); else MTRNetwork.CHANNEL.send(packet, listener.getConnection()); } + if (listener != null) { if (packet instanceof OverlayRequest) MTRNetwork.OVERLAY.send(packet, listener.getConnection()); else if (packet instanceof TrainStreamRequest) MTRNetwork.TRAINS.send(packet, listener.getConnection()); else MTRNetwork.CHANNEL.send(packet, listener.getConnection()); } } } diff --git a/docs/agents/MESSAGES.md b/docs/agents/MESSAGES.md index 77c37c8..1c53d6b 100644 --- a/docs/agents/MESSAGES.md +++ b/docs/agents/MESSAGES.md @@ -1,5 +1,11 @@ # MESSAGES.md — Agent 交流墙(只追加,不改不删) +## [2026-10-26 10:36] Codex — 1.5.3原生PIDS与dimmed移植完成 + +- 四目标支持主线新增站点原生API、独立overlay-1通道、旧式折叠信息与双主题PIDS、持久命令、精确区段dimmed和统一导航provider入口;版本1.5.3,不覆盖主线NF平台。不增加模拟或第三方专用适配。 +- 每目标104测试/构建、Java17 API ABI/消费者和生产JAR校验通过,另有20项CI工具回归。MTR4.0原生Simulator夹具直接验证路径/停站/到站接口。旧四目标本轮没有新PIDS实机;NeoForge两地图已检查,验证限制明确记录。源分支feat/1.5.3-station-board-mc120,准备draft PR;未发布。 + + ## [2026-10-22 22:42] Codex-CI — CI/CD六平台与发布演练验收 diff --git a/docs/agents/STATE.md b/docs/agents/STATE.md index 238a9b2..4812cbb 100644 --- a/docs/agents/STATE.md +++ b/docs/agents/STATE.md @@ -1,8 +1,9 @@ # STATE.md — MC 1.20 兼容分支权威状态 -- 最后更新:2026-10-22 18:21,操作者:Codex。 +- 最后更新:2026-10-26 10:36,操作者:Codex。 - 分支:`compat/mtr-4.0-mc-1.20`;原基于 main `6afde36`(v1.5.1),现移植 main `df9df96` 最新功能。 -- 版本:**v1.5.2**(用户指定,禁止自动递增);mod ID `mtrmap`;包 `io.github.teamcreating.mtrmap`;客户端命令 `/mtrmap`。 +- 开发版本:**v1.5.3**(用户指定,禁止自动递增);mod ID `mtrmap`;包 `io.github.teamcreating.mtrmap`;客户端命令 `/mtrmap`。 +- 2026-10-26 10:36,Codex:站点原生快照、Java17 API、英式/欧式PIDS/持久命令、默认折叠旧信息和导航dimmed已移植至feat/1.5.3-station-board-mc120;Forge47/49网络和滚轮API及JM5桥独立适配。四目标各104测试、构建、产物及API消费者通过;本轮旧目标新PIDS未实机,NeoForge主线双地图已测。见[验证](../validation/pids-and-dimmed-1.5.3.md);未发布。 - 主线继续保留 NeoForge/Fabric MC 1.21.1,独立 worktree `../mtrsurveyor` 保留 1.21.1;本轮主线另修复 Fabric JourneyMap 入口,不以兼容代码覆盖主线。 - 2026-10-22 18:11,Codex:用户指定命名空间迁移至 `io.github.teamcreating.mtrmap`,maven_group=`io.github.teamcreating`;四目标各89项测试及产物检查通过,无旧包类/入口,原始LICENSE已打包。Java文件仅包名差异,mod ID/配置/网络协议保持。Fabric1.20.4独立服/JourneyMap5实际加载、绘制、悬停通过;迁移验收时未发布;现已按用户要求替换GitHub v1.5.2附件,tag保留。 diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 2dd271e..8b34f57 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -19,6 +19,8 @@ ## 完成 +- [完成] 1.5.3站点API、双样式PIDS和导航dimmed移植 — Codex,2026-10-26 10:36。四目标104测试/构建/产物及Java17消费者通过,Forge47/49独立可选overlay通道、滚轮API和JM5桥适配;主线NeoForge两地图已测。旧目标新UI实机/真实provider联动仍待发布前QA,详见docs/validation/pids-and-dimmed-1.5.3.md。未发布。 + - [完成] GitHub Actions CI/CD — Codex-CI,2026-10-22 22:42。双分支六目标Runner构建与CI Gate、18项工具回归、远端产物复核、六包Candidate、Nightly完整验证/无变更跳过、Snapshot六包与Publish只读演练通过;Environment已配置,规则在本验收提交推送后启用。RELEASE_TOKEN按用户要求稍后配置,不触发正式发布。实现提交main 9ea00e9、compat 7f63367,证据见docs/validation/ci-cd.md。 diff --git a/docs/api/overlay-api-v1.md b/docs/api/overlay-api-v1.md new file mode 100644 index 0000000..cf1638f --- /dev/null +++ b/docs/api/overlay-api-v1.md @@ -0,0 +1,53 @@ +# MTR Map Overlay client API 1.0.0 (1.5.3) + +The implementation is `io.github.teamcreating.mtrmap.api.MtrMapApi`. Use the separately built `build/api/mtrmap-api-1.0.0.jar` as **compileOnly**; do not shade or duplicate its classes. All API types are pure Java 17. Check that Overlay is installed before loading your integration class, then use `MtrMapApi.findClient()`. No Navigation/howtogo-specific dependency is required. + +## Thread and lifecycle + +Call all client operations and close handles on the Minecraft client thread. Listeners run on that thread. Values are immutable snapshots. `world("minecraft:overworld")` starts an optional catalog request and returns empty until a server-backed world identity is available. Require `catalog(world).status() == READY` before submitting a view. World identity includes a server epoch: do not reuse handles, IDs or revisions after disconnect/reconnect. View and station subscription handles are invalidated on disconnect; navigation provider registrations remain registered until explicitly closed, so a mod registers its entry once after findClient() becomes available (for example, on a client tick). + +The static v8 and train v1 transports remain unchanged. Station/catalog requests use a separate optional overlay-1 transport. Unsupported servers keep ordinary map functionality. A station board is LOADING, READY, LIMITED, STALE, NOT_FOUND, UNSUPPORTED or ERROR; LIMITED is not proof that no train exists. Native `arrivalAt`, `departureAt`, `deviation`, `realtime` and `terminating` values are preserved. Do not add deviation to timestamps a second time. + +## Native sections and dimming + +Use native route IDs and stop ordinals from Catalog. IDs must not be inferred from names/colours. `resolve(world, revision, sections)` clips already-existing Core Depot paths; it never runs another route finder. Multiple path variants produce AMBIGUOUS and candidate IDs. Explicitly select the intended variant. Rail fractions use canonical rail direction and native distance (including slopes), not X/Z chord length. Rail IDs retain Core's exact hex spelling; station/platform/route IDs use unsigned lowercase unpadded hex. + +```java +// Called by your planner on the client thread, after its navigation/progress changed. +var request = MtrMapApi.ViewRequest.navigation( + world, catalog.revision(), remainingSpans, completedSpans, + 0.35, // completed intervals retain colour at 35% opacity + 0.15 // unused routes/railways at 15% opacity +); +var result = focus.replace(request); // CompletionStage +``` + +Open `focus` with `client.openView("yourmod:journey", "Your planner", Purpose.FOCUS, 0)`. `remainingSpans` and `completedSpans` are lists of SelectedSpan supplied by the caller. The factory selects both and attaches completed-span overrides, with `unmatched=DIM`. Upcoming intervals win if a later journey traverses a completed physical interval again. Overlay does not infer progress or simulate a train. Cancel the journey by closing **your** focus handle; the next valid view or normal map is restored. Replacing a view is atomic: an invalid revision/selection leaves the previous valid view installed. The user can temporarily show the full map without destroying the caller's session. + +For finer control, construct a ViewRequest with `spanStyles` and `unmatchedOpacity`. Each `SpanStyle(SelectedSpan, Style)` applies to only that native interval; empty routeId means physical rail/all its bands. Last overlapping override wins. A local colour/pattern may inherit the route-wide override; opacity and width multiply the route-wide style, with final width clamped to [.25, 4]. Unmatched DIM additionally multiplies by unmatchedOpacity. Route bands, white blocks, feathering and the physical rail backing dim together; shared backing uses the most visible band. Train symbols retain their original appearance. Static source data is never mutated. Selections/overrides are bounded to 20000 entries each, with at most 256 combined intervals per physical rail across active sessions. Geometry is cached until a view/source change, so submit progress changes rather than an identical request every frame. + +`Unmatched.KEEP`, `DIM`, `HIDE`, explicit `wholeRoutes`, `stationIds`, colour/opacity/width and `INHERIT`, `SOLID`, `COLOUR_WHITE` patterns are implemented. An empty stationIds set retains all markers. No arbitrary clipping rectangle, custom dash/outline renderer, arbitrary UI injection or Maven repository publication is promised by this version. The earlier draft remains a design history, not a callable contract. + +## Reserved navigation entry + +```java +// Register once. Keep and close the returned registration when unloading the integration. +var registration = client.registerNavigation( + "yourmod:planner", "Your planner", + target -> openYourPlanner(target.world(), target.stationId()) +); +``` + +A user's Navigate click passes only the selected server-backed station identity to the handler. One provider is invoked directly; multiple providers show a chooser. Without a provider the reserved action is disabled. Registration never performs pathfinding, starts navigation automatically, or inspects another mod. Up to eight unique owner IDs may register. Closing a registration removes only that owner. Keep your own planner UI and use the view/session API after the user confirms a journey. + +## Station board and in-game preferences + +`client.watchStation(world, stationId, listener)` returns a BoardSubscription. Read latest() for its initial state; close it when no longer needed. Up to six different client station interests share bounded, renewable server snapshots. The built-in UI subscribes only while its board is expanded and the map is open; collapse/close releases its interest. + +Both map hosts use the same classic station tooltip, original route metadata, default collapsed state, Expand, and optional Navigate action. Expanded tables put destination first, then line, platform and right-aligned countdown. Long labels remain accessible in a row tooltip. Small windows compact the duplicated metadata and scroll rows without zooming the map. Prediction warnings are separate from a missing service row. No prediction is displayed as a dash, not zero minutes. + +- `/mtrmap config pidsStyle` queries the setting. +- `/mtrmap config pidsStyle british` selects black/amber, the default. +- `/mtrmap config pidsStyle european` selects white text, a blue-gray header, subtle alternating rows and amber countdowns. + +The preference persists in the loader's existing local config. It changes presentation only, with identical native arrival data. diff --git a/docs/validation/pids-and-dimmed-1.5.3.md b/docs/validation/pids-and-dimmed-1.5.3.md new file mode 100644 index 0000000..49df7a0 --- /dev/null +++ b/docs/validation/pids-and-dimmed-1.5.3.md @@ -0,0 +1,41 @@ +# 1.5.3 站点PIDS与导航dimmed验证 + +Codex,2026-10-26 10:34(Asia/Taipei)。开发版本1.5.3;没有发布release/tag。旧1.5.2正式附件保持。 + +## 交付 + +- 两地图共用旧式紫边站点信息框,原站点/站台与线路描述保留。点击固定后默认折叠;Expand才订阅原生站点预测;Collapse、关闭地图或断线释放兴趣。小窗口压缩重复线路摘要,长文本可悬停查看完整内容,面板滚动不缩放地图。 +- 英式black/amber为默认;欧式/机场方向为白字、蓝灰表头、轻交替行和黄色ETA。固定Destination/Line/Platform/ETA列与行距,ETA右对齐。`/mtrmap config pidsStyle british|european`设置并持久化,无参数查询。 +- 可选Java17 API JAR、原生站点/线路/停站ordinal与Depot路径目录、显式路径歧义、可关闭的独立区段视图。`ViewRequest.navigation`同时选择待乘和已走区段;已走默认建议.35、未用.15,调用方可调整;重叠的后续乘车区段优先保持明亮。`spanStyles`精确到原生半轨,线路色、白块、羽化和物理底衬一同淡化,源缓存不修改。 +- Navigate预留入口通过owner注册回调接入;零provider禁用,多个provider可选择。没有Navigation/howtogo专用适配,没有本地寻路、导航进度推断或车辆模拟。 +- 静态v8和车辆v1不变;站点/目录使用独立可选overlay-1。服务器共享Simulator线程原生ArrivalsRequest读取及后台编码,客户端后台解码和展示;租约/容量/分片/错代/限额/玩家与兴趣轮转,长目录传输不独占所有站点发送机会。 + +## 构建与自动验证 + +| MC / loader | MTR | 构建 | JUnit | +| --- | --- | --- | --- | +| 1.21.1 NeoForge | 4.1.0-beta.2 | 通过 | 101通过、1生成用例跳过 | +| 1.21.1 Fabric | 4.1.0-beta.2 | 通过 | 共享主线101项;本目标不单独重复运行 | +| 1.20.1 Forge | 4.0.5 | 通过 | 104通过、1生成用例跳过 | +| 1.20.1 Fabric | 4.0.5 | 通过 | 104通过、1生成用例跳过 | +| 1.20.4 Forge | 4.0.5 | 通过 | 104通过、1生成用例跳过 | +| 1.20.4 Fabric | 4.0.5 | 通过 | 104通过、1生成用例跳过 | + +- 实际依赖编译验证Core4.0/4.1公开接口;原生Simulator夹具保留重复停站、Depot半轨及平台/站点映射。PIDS模型验证native arrivalAt,不重复加deviation;已到站、过期班次、无预测和同站台不同目的地保持区分。 +- 几何用例覆盖同一物理轨道的已走/待乘/未用三段、下一次复用同区段的优先级、被v8绘制缓存去重的原生线路身份、真实XYZ/LOD/长度、白块及底衬alpha和源快照不可变。 +- `python tools/verify_platform_matrix.py`六产物namespace、loader/MC/MTR metadata、LICENSE、字节码、Mixin和共享测试检查通过。新API始终Java17,其余主线Java21/兼容Java17;校验器仅对精确api/子包放行Java17,并禁止泄漏MC/Core/地图类型。 +- CI工具20项回归通过(包含新API/新输入桥六目标正例、API错误字节码与MC类型泄漏拒绝)。 +- 六目标独立API JAR与内嵌API公开签名对照及Java17消费者编译检查;Fabric remap可能重排常量池,不以逐字节相同代替ABI验证。 + +## computer-use实机范围 + +NeoForge1.21.1,独立存档副本`run/saves/Overlay153Test`,未修改用户原存档。已直接操作Xaero与JourneyMap6: + +- 默认旧信息框与折叠、点击Expand显示原生Local/Local Return倒计时及Express缺预测横杠;悬停能读完整平台名;保持固定、不因移出关闭。 +- 英式琥珀表、指令切换european的客户端反馈、欧式白字/底色/右对齐ETA;正常退出重启后仍保留european,证明持久化。 +- 小窗口压缩模式与最大化后的完整线路列表;旧ROUTES/TRACKS控件恢复;初版面板内部滚动不改变3.9x缩放。 +- JourneyMap轨道不再盖住卡片;Xaero初版玩家箭头穿过背景,最终改为host post-render/独立UI深度,重启复查箭头已被站点面板覆盖。 + +本轮未重跑其他五目标的新PIDS实机矩阵、第三方真实provider点击/进度联动、大网络/多玩家性能、跨机器联机或production整合包启动。dimmed自动化覆盖完整几何与公开API,未声称完成真实导航模组适配/联动实测。测试夹具的缺预测服务显示LIMITED,不能解释为无车或零ETA。候选数上限不构成Core内部扫描耗时上限;不提供FPS/带宽实测成绩。 + +API实现与可编译示例见[公共API v1](../api/overlay-api-v1.md)。本地测试包统一放在`../test-builds/v1.5.3-pids-dimmed`,六文件名含MC/MTR/loader,并附API JAR、哈希与源码提交记录。 diff --git a/fabric/build.gradle b/fabric/build.gradle index 45f89a6..ae83f49 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -9,6 +9,9 @@ version = sharedProperties.getProperty('mod_version') group = sharedProperties.getProperty('maven_group') base.archivesName = sharedProperties.getProperty('archives_base_name') + '-fabric-mc' + mcVersion java.toolchain.languageVersion = JavaLanguageVersion.of(17) +ext.overlayRoot = file('..') +apply from: '../gradle/overlay-api.gradle' +loom.mods { mtrmap { sourceSet sourceSets.main; sourceSet sourceSets.overlayApi } } repositories { exclusiveContent { forRepository { mavenCentral() } @@ -48,6 +51,7 @@ sourceSets.main.resources.srcDir('../src/main/resources') sourceSets.main.resources.exclude('META-INF/mods.toml', 'mtrmap.mixins.json') compileJava.source(fileTree('../src/main/java') { exclude 'io/github/teamcreating/mtrmap/train/TrainServerEvents.java' + exclude 'io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java' exclude 'io/github/teamcreating/mtrmap/MTRMap.java' exclude 'io/github/teamcreating/mtrmap/MTRMapClient.java' exclude 'io/github/teamcreating/mtrmap/config/MTRMapConfig.java' @@ -132,6 +136,7 @@ if (legacyJourneyMap) { fileTree('../src/main/java') { exclude overrides exclude 'io/github/teamcreating/mtrmap/train/TrainServerEvents.java', 'io/github/teamcreating/mtrmap/MTRMap.java', 'io/github/teamcreating/mtrmap/MTRMapClient.java', + 'io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java', 'io/github/teamcreating/mtrmap/config/MTRMapConfig.java', 'io/github/teamcreating/mtrmap/CommandRegistration.java', 'io/github/teamcreating/mtrmap/network/MTRNetwork.java', 'io/github/teamcreating/mtrmap/network/MTRNetworkClient.java', 'io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java' diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java index 6ddd3f1..be62ce2 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java @@ -50,6 +50,14 @@ public static void register(CommandDispatcher dispatc MTRMapConfig.INSTANCE.trainCarsEnabled::get, MTRMapConfig.INSTANCE.trainCarsEnabled::set)); + var pids = literal("pidsStyle").executes(ctx -> { + ctx.getSource().sendFeedback(Component.translatable("mtrmap.ui.pids_style", MTRMapConfig.INSTANCE.pidsStyle.get())); return 1; + }); + for (var style : io.github.teamcreating.mtrmap.config.PidsStyle.values()) pids.then(literal(style.key).executes(ctx -> { + MTRMapConfig.setPidsStyle(style.key); + ctx.getSource().sendFeedback(Component.translatable("mtrmap.ui.pids_style", style.key)); return 1; + })); + config.then(pids); dispatcher.register(literal("mtrmap") .then(mode) .then(config) diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java index 87c5f82..e9d1f8c 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java @@ -16,9 +16,10 @@ public final class MTRMap implements ModInitializer { public void onInitialize() { MTRMapConfig.load(); MTRNetwork.registerServer(); - net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(io.github.teamcreating.mtrmap.train.ServerTrainStream::tick); - net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.SERVER_STOPPING.register(server -> io.github.teamcreating.mtrmap.train.ServerTrainStream.stop()); - net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> io.github.teamcreating.mtrmap.train.ServerTrainStream.disconnect(handler.player.getUUID())); + net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(server -> { io.github.teamcreating.mtrmap.train.ServerTrainStream.tick(server); io.github.teamcreating.mtrmap.extension.ServerOverlayService.tick(server); }); + net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.SERVER_STOPPING.register(server -> { io.github.teamcreating.mtrmap.train.ServerTrainStream.stop(); io.github.teamcreating.mtrmap.extension.ServerOverlayService.stop(); }); + net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> + { io.github.teamcreating.mtrmap.train.ServerTrainStream.disconnect(handler.player.getUUID()); io.github.teamcreating.mtrmap.extension.ServerOverlayService.disconnect(handler.player.getUUID()); }); LOGGER.info("[{}] Fabric loaded; JourneyMap {}, Xaero World Map {}", MOD_NAME, isModLoaded("journeymap") ? "detected" : "not found", isModLoaded("xaeroworldmap") ? "detected" : "not found"); diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java index b8e9480..bd06a2e 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java @@ -12,6 +12,15 @@ public final class MTRMapClient implements ClientModInitializer { @Override public void onInitializeClient() { + net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.AFTER_INIT.register((client, screen, width, height) -> { + if(screen.getClass().getName().equals("xaero.map.gui.GuiMap")) + net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.afterRender(screen).register((s,g,x,y,delta)->io.github.teamcreating.mtrmap.integration.xaero.XaeroRouteRenderer.renderUi()); + net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents.allowMouseClick(screen).register((s, x, y, button) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.down(x, y, button)); + net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents.allowMouseRelease(screen).register((s, x, y, button) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.up(x, y, button)); + net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents.allowMouseScroll(screen).register((s, x, y, horizontal, vertical) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.wheel(x, y, vertical)); + net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.allowKeyPress(screen).register((s, key, scan, modifiers) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.key(key, modifiers)); + }); + MTRNetwork.registerClient(); ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> CommandRegistration.register(dispatcher)); diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java index f0e70e7..c147adb 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java @@ -23,6 +23,7 @@ public final class MTRMapConfig { public final Value waypointMode = value("waypointMode", "both", s -> s); public final Value routeLinesEnabled = bool("routeLinesEnabled", true); public final Value trainCarsEnabled = bool("trainCarsEnabled", true); + public final Value pidsStyle = value("pidsStyle", "british", s -> PidsStyle.parse(s).key); public final Value trackLinesEnabled = bool("trackLinesEnabled", true); public final Value networkSyncEnabled = bool("networkSync.enabled", true); public final Value networkSyncIntervalSeconds = value("networkSync.refreshIntervalSeconds", 300, @@ -35,6 +36,7 @@ public final class MTRMapConfig { private MTRMapConfig() { } + public static void setPidsStyle(String style) { INSTANCE.pidsStyle.set(PidsStyle.parse(style).key); } private static Value bool(String key, boolean fallback) { return value(key, fallback, Boolean::parseBoolean); diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java index 8861cd3..edb1ca3 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java @@ -31,12 +31,4 @@ public abstract class XaeroWorldMapMixin { } } - @Inject(method = {"mouseClicked", "method_25402"}, at = @At("HEAD"), - remap = false, cancellable = true, require = 0) - private void mtrmap$onClick(double mouseX, double mouseY, int button, - CallbackInfoReturnable cir) { - if (XaeroRouteRenderer.onMouseClicked(mouseX, mouseY, button)) { - cir.setReturnValue(true); - } - } } diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java index 1531793..df7febd 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java @@ -9,6 +9,9 @@ /** Fabric 1.20 transport for the shared v8 messages. */ public final class MTRNetwork { + private static final ResourceLocation OVERLAY_REQUEST=id("overlay_request_v1"), OVERLAY_CHUNK=id("overlay_response_v1"); + public static boolean canSendOverlay(ServerPlayer p) { return ServerPlayNetworking.canSend(p,OVERLAY_CHUNK); } + public static boolean canSendOverlayToServer() { return ClientPlayNetworking.canSend(OVERLAY_REQUEST); } private static final ResourceLocation REQUEST = id("request_network_sync_v8"); private static final ResourceLocation PROBE = id("network_sync_probe_v8"); private static final ResourceLocation CHUNK = id("network_sync_chunk_v8"); @@ -19,6 +22,7 @@ public final class MTRNetwork { public static boolean canSendTrains(ServerPlayer player) { return ServerPlayNetworking.canSend(player, TRAIN_CHUNK) && ServerPlayNetworking.canSend(player, TRAIN_STATUS); } public static boolean canSendTrainsToServer() { return ClientPlayNetworking.canSend(TRAIN_REQUEST); } public static void registerServer() { + ServerPlayNetworking.registerGlobalReceiver(OVERLAY_REQUEST,(server,player,handler,buf,sender)->{ var message=OverlayRequest.read(buf);server.execute(()->io.github.teamcreating.mtrmap.extension.ServerOverlayService.request(player,message)); }); ServerPlayNetworking.registerGlobalReceiver(TRAIN_REQUEST, (server, player, handler, buf, sender) -> { TrainStreamRequest msg = TrainStreamRequest.read(buf); server.execute(() -> io.github.teamcreating.mtrmap.train.ServerTrainStream.request(player, msg)); @@ -31,6 +35,7 @@ public static void registerServer() { server.execute(() -> ServerNetworkCollector.sendProbeResponse(player))); } public static void registerClient() { + ClientPlayNetworking.registerGlobalReceiver(OVERLAY_CHUNK,(client,handler,buf,sender)->{ var message=OverlayChunk.read(buf);client.execute(()->io.github.teamcreating.mtrmap.extension.ClientOverlayRuntime.INSTANCE.chunk(message)); }); ClientPlayNetworking.registerGlobalReceiver(TRAIN_CHUNK, (client, handler, buf, sender) -> { TrainStreamChunk msg = TrainStreamChunk.read(buf); client.execute(() -> io.github.teamcreating.mtrmap.train.ClientTrainStream.chunk(msg)); @@ -53,7 +58,7 @@ public static boolean canSendToServer() { } public static void sendToServer(MapPacket packet) { FriendlyByteBuf buf = PacketByteBufs.create(); - if (packet instanceof TrainStreamRequest msg) { + if(packet instanceof OverlayRequest msg) { OverlayRequest.write(buf,msg);ClientPlayNetworking.send(OVERLAY_REQUEST,buf); } else if (packet instanceof TrainStreamRequest msg) { TrainStreamRequest.write(buf, msg); ClientPlayNetworking.send(TRAIN_REQUEST, buf); } else if (packet instanceof RequestNetworkSync msg) { RequestNetworkSync.write(buf, msg); ClientPlayNetworking.send(REQUEST, buf); @@ -64,7 +69,7 @@ public static void sendToServer(MapPacket packet) { public static void sendToPlayer(ServerPlayer player, MapPacket packet) { FriendlyByteBuf buf = PacketByteBufs.create(); ResourceLocation channel; - if (packet instanceof TrainStreamChunk msg) { + if(packet instanceof OverlayChunk msg) { OverlayChunk.write(buf,msg);channel=OVERLAY_CHUNK; } else if (packet instanceof TrainStreamChunk msg) { TrainStreamChunk.write(buf, msg); channel = TRAIN_CHUNK; } else if (packet instanceof TrainStreamStatus msg) { TrainStreamStatus.write(buf, msg); channel = TRAIN_STATUS; diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java index 74f8abc..5aea4f3 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java @@ -8,6 +8,7 @@ public final class MTRNetworkClient { private MTRNetworkClient() { } + public static boolean canSendOverlay() { return MTRNetwork.canSendOverlayToServer(); } public static boolean canSendTrains() { return MTRNetwork.canSendTrainsToServer(); } public static boolean canSendToServer() { return MTRNetwork.canSendToServer(); diff --git a/fabric/src/main/resources/mtrmap.fabric.mixins.json b/fabric/src/main/resources/mtrmap.fabric.mixins.json index c3f75c2..6c3c03e 100644 --- a/fabric/src/main/resources/mtrmap.fabric.mixins.json +++ b/fabric/src/main/resources/mtrmap.fabric.mixins.json @@ -8,7 +8,8 @@ "client.MinecraftClientDataMixin", "client.xaero.XaeroWorldMapAccessor", "client.xaero.XaeroWorldMapMixin", - "client.xaero.TrainMapPauseMixin" + "client.xaero.TrainMapPauseMixin", + "client.OverlayPointerMixin" ], "mixins": [ "MainAccessorMixin", diff --git a/gradle.properties b/gradle.properties index b196f6b..4050bea 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx3G -Dfile.encoding=UTF-8 org.gradle.daemon=false minecraft_version=1.20.1 forge_version=47.4.0 -mod_version=1.5.2 +mod_version=1.5.3 maven_group=io.github.teamcreating archives_base_name=CRTools-MTR-Map-Overlay mtr_version=4.0.5 diff --git a/gradle/overlay-api.gradle b/gradle/overlay-api.gradle new file mode 100644 index 0000000..a749c90 --- /dev/null +++ b/gradle/overlay-api.gradle @@ -0,0 +1,21 @@ +// Pure Java 17 contract, shared by all loaders; implementation never leaks into this JAR. +sourceSets { + overlayApi { java.srcDir new File(project.ext.overlayRoot, 'api/src/main/java') } +} +tasks.named('compileOverlayApiJava', JavaCompile) { options.release = 17 } +sourceSets.main.compileClasspath += sourceSets.overlayApi.output +sourceSets.main.runtimeClasspath += sourceSets.overlayApi.output +sourceSets.test.compileClasspath += sourceSets.overlayApi.output +sourceSets.test.runtimeClasspath += sourceSets.overlayApi.output +tasks.named('compileJava') { dependsOn tasks.named('compileOverlayApiJava') } +tasks.named('jar') { from sourceSets.overlayApi.output } +tasks.register('apiJar', Jar) { + dependsOn tasks.named('compileOverlayApiJava') + archiveBaseName = 'mtrmap-api' + archiveVersion = '1.0.0' + archiveClassifier = '' + destinationDirectory = layout.buildDirectory.dir('api') + from sourceSets.overlayApi.output + from new File(project.ext.overlayRoot, 'LICENSE') +} +tasks.named('assemble') { dependsOn tasks.named('apiJar') } diff --git a/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java b/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java index b9d1de4..b3e64a0 100644 --- a/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java +++ b/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java @@ -121,6 +121,16 @@ public static void register(CommandDispatcher dispatcher) { // Config sub-commands LiteralArgumentBuilder configNode = Commands.literal("config"); + var pids = Commands.literal("pidsStyle").executes(ctx -> { + ctx.getSource().sendSuccess(() -> Component.translatable("mtrmap.ui.pids_style", MTRMapConfig.INSTANCE.pidsStyle.get()), false); + return 1; + }); + for (var style : io.github.teamcreating.mtrmap.config.PidsStyle.values()) pids.then(Commands.literal(style.key).executes(ctx -> { + MTRMapConfig.setPidsStyle(style.key); + ctx.getSource().sendSuccess(() -> Component.translatable("mtrmap.ui.pids_style", style.key), false); + return 1; + })); + configNode.then(pids); configNode.then(createBoolConfigNode("enabled", "MTR map overlays", () -> MTRMapConfig.INSTANCE.enabled.get(), v -> MTRMapConfig.INSTANCE.enabled.set(v))); diff --git a/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java b/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java index 7c15b94..795c058 100644 --- a/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java +++ b/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java @@ -29,6 +29,7 @@ public class MTRMapConfig { public final ForgeConfigSpec.BooleanValue routeLinesEnabled; public final ForgeConfigSpec.BooleanValue trackLinesEnabled; public final ForgeConfigSpec.BooleanValue trainCarsEnabled; + public final ForgeConfigSpec.ConfigValue pidsStyle; // Full-network sync (requires the mod on the server) public final ForgeConfigSpec.BooleanValue networkSyncEnabled; @@ -76,6 +77,8 @@ private MTRMapConfig(ForgeConfigSpec.Builder builder) { trainCarsEnabled = builder.comment("Draw TSC global train symbols on the map").define("trainCarsEnabled", true); + pidsStyle = builder.define("pidsStyle", "british", value -> value instanceof String str && (str.equals("british") || str.equals("european"))); + networkSyncEnabled = builder .comment("Request full-network snapshots from servers that also run this mod (Create-train-map-style whole-network view). Client-only servers fall back to radius-limited MTR data automatically") .define("networkSync.enabled", true); @@ -109,6 +112,8 @@ private MTRMapConfig(ForgeConfigSpec.Builder builder) { builder.pop(); } + public static void setPidsStyle(String style) { INSTANCE.pidsStyle.set(PidsStyle.parse(style).key); SPEC.save(); } + public static void register(ModContainer modContainer) { migrateLegacyConfig(); ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, SPEC, "mtrmap.toml"); diff --git a/src/main/java/io/github/teamcreating/mtrmap/config/PidsStyle.java b/src/main/java/io/github/teamcreating/mtrmap/config/PidsStyle.java new file mode 100644 index 0000000..2bbf63b --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/config/PidsStyle.java @@ -0,0 +1,15 @@ +package io.github.teamcreating.mtrmap.config; + +/** Stable configuration values shared by loaders. */ +public enum PidsStyle { + BRITISH("british", 0xFFD34E, 0xD9CFAD, 0x181510, false), + EUROPEAN("european", 0xF2F3F5, 0xBFC6D1, 0x252E3C, true); + + public final String key; + public final int destination, secondary, header; + public final boolean stripes; + PidsStyle(String key, int destination, int secondary, int header, boolean stripes) { + this.key = key; this.destination = destination; this.secondary = secondary; this.header = header; this.stripes = stripes; + } + public static PidsStyle parse(String key) { return "european".equals(key) ? EUROPEAN : BRITISH; } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/ClientOverlayRuntime.java b/src/main/java/io/github/teamcreating/mtrmap/extension/ClientOverlayRuntime.java new file mode 100644 index 0000000..c57b945 --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/ClientOverlayRuntime.java @@ -0,0 +1,180 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.MTRMap; +import io.github.teamcreating.mtrmap.api.MtrMapApi; +import io.github.teamcreating.mtrmap.api.MtrMapApi.*; +import io.github.teamcreating.mtrmap.mapdata.MapDataCache; +import io.github.teamcreating.mtrmap.network.*; +import net.minecraft.client.Minecraft; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +/** Client-thread facade. Receiving snapshots never creates a local simulation. */ +public final class ClientOverlayRuntime implements MtrMapApi.Client { + public static final ClientOverlayRuntime INSTANCE = new ClientOverlayRuntime(); + private record Key(String dimension, String station) {} + private static final class Query { + final long generation; final long started = System.nanoTime(); + long requestAt, sequence, transferSequence; + OverlayAssembly transfer; + Query(long generation) { this.generation = generation; } + } + private final Map queries = new LinkedHashMap<>(); + private final Map catalogs = new HashMap<>(); + private final Map> watches = new LinkedHashMap<>(); + private long generation; + private final ViewRegistry views = new ViewRegistry(); + public record Navigation(String owner, String name, Consumer handler) {} + private final Map navigation = new LinkedHashMap<>(); + public List navigation() { thread(); return List.copyOf(navigation.values()); } + public Registration registerNavigation(String owner, String displayName, Consumer handler) { + thread(); + if (owner == null || !owner.matches("[a-z0-9_.-]+:[a-z0-9_./-]+") || displayName == null || displayName.isBlank() || displayName.length() > 80 || navigation.size() >= 8 || navigation.containsKey(owner)) + throw new IllegalArgumentException("Invalid or duplicate navigation provider / capacity reached"); + var value = new Navigation(owner, displayName, Objects.requireNonNull(handler)); navigation.put(owner, value); + // Providers live for the client/mod lifetime; world/session data is supplied only on an explicit user click. + return () -> { thread(); navigation.remove(owner, value); }; + } + public void navigate(Navigation provider, WorldRef world, String station) { + thread(); if (navigation.get(provider.owner()) != provider || catalog(world).status() != Status.READY) return; + try { provider.handler().accept(new NavigationTarget(world, station)); } + catch (RuntimeException e) { MTRMap.LOGGER.warn("[MTRMap] Navigation provider failed: {}", provider.owner(), e); } + } + private boolean clearing; + private final ThreadPoolExecutor decoder = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(2), r -> { var t = new Thread(r, "MTRMap-overlay-decoder"); t.setDaemon(true); return t; }); + private ClientOverlayRuntime() { MtrMapApi.install(this); } + private static void thread() { if (!Minecraft.getInstance().isSameThread()) throw new IllegalStateException("MTR Map API requires client thread"); } + public ViewRegistry views() { return views; } + private static String key(String dimension) { return dimension.replaceFirst(":", "/"); } + public Optional world(String dimensionId) { + thread(); if (Minecraft.getInstance().getConnection() == null) return Optional.empty(); + String dimension = key(dimensionId); + if (!dimension.matches("[a-z0-9_.-]+/[a-z0-9_./-]+")) throw new IllegalArgumentException("Invalid dimension"); + Catalog catalog = catalogs.get(dimension); + if (catalog == null && MTRNetworkClient.canSendOverlay() && queries.size() < 8) queries.computeIfAbsent(new Key(dimension, ""), k -> new Query(++generation)); + return catalog == null ? Optional.empty() : Optional.of(catalog.world()); + } + public Catalog catalog(WorldRef world) { + thread(); Catalog value = catalogs.get(key(world.dimensionId())); + if (value == null || !value.world().equals(world)) return new Catalog(world, 0, 0, Status.STALE, List.of(), List.of(), List.of()); + if (MapDataCache.hasServerData(key(world.dimensionId())) && MapDataCache.get(key(world.dimensionId())).version != value.baseStaticRevision()) + return new Catalog(value.world(), value.revision(), value.baseStaticRevision(), Status.STALE, value.stations(), value.platforms(), value.routes()); + return value; + } + public List tracks(WorldRef world, Set railIds) { + thread(); if (railIds.size() > 4096) throw new IllegalArgumentException("Too many rails"); + if (catalog(world).status() != Status.READY) return List.of(); + return MapDataCache.get(key(world.dimensionId())).tracks.stream().filter(t -> railIds.contains(t.id)) + .map(t -> new Track(t.id, t.points.stream().map(p -> new Point(p[0], p.length >= 3 ? p[2] : Double.NaN, p[1])).toList(), t.curve == null ? Double.NaN : t.curve.length())).toList(); + } + public CompletionStage resolve(WorldRef world, long revision, List sections) { + thread(); var catalog = catalog(world); + return CompletableFuture.completedFuture(catalog.status() != Status.READY || catalog.revision() != revision + ? new Resolution(Status.STALE, catalog.revision(), List.of(), List.of()) : SectionResolver.resolve(catalog, List.copyOf(sections))); + } + public ViewSession openView(String owner, String displayName, Purpose purpose, int priority) { + thread(); var delegate = views.open(owner, displayName, purpose, priority, this::catalog); + return new ViewSession() { + public CompletionStage replace(ViewRequest request) { + thread(); var tracks = new HashMap(); + MapDataCache.get(key(request.world().dimensionId())).tracks.forEach(t -> tracks.put(t.id, t)); + var perRail = new HashMap(); + java.util.stream.Stream.concat(request.spans().stream(), request.spanStyles().stream().map(SpanStyle::span)) + .forEach(s -> perRail.merge(s.rail().railId(), 1, Integer::sum)); + if (perRail.values().stream().anyMatch(n -> n > 256)) return CompletableFuture.completedFuture(Status.INVALID); + if (java.util.stream.Stream.concat(request.spans().stream(), request.spanStyles().stream().map(SpanStyle::span)) + .anyMatch(s -> !tracks.containsKey(s.rail().railId()) || tracks.get(s.rail().railId()).curve == null)) + return CompletableFuture.completedFuture(Status.UNAVAILABLE); + return delegate.replace(request); + } + public void clear() { thread(); delegate.clear(); } + public void close() { thread(); delegate.close(); } + }; + } + public BoardSubscription watchStation(WorldRef world, String stationId, Consumer listener) { + thread(); stationId = Long.toUnsignedString(Long.parseUnsignedLong(stationId, 16), 16); Objects.requireNonNull(listener); + var key = new Key(key(world.dimensionId()), stationId); + if (!watches.containsKey(key) && watches.size() >= 6) throw new IllegalStateException("Station interest capacity reached"); + var watch = new Watch(world, key, listener); watches.computeIfAbsent(key, ignored -> new ArrayList<>()).add(watch); + if (catalog(world).status() == Status.STALE) watch.value = empty(world, key, 0, Status.STALE); + else if (!MTRNetworkClient.canSendOverlay()) watch.value = empty(world, key, 0, Status.UNSUPPORTED); + else queries.computeIfAbsent(key, ignored -> new Query(++generation)); + return watch; + } + private static Board empty(WorldRef world, Key key, long gen, Status status) { return new Board(world, key.station, gen, status, 0, 0, List.of(), List.of()); } + private final class Watch implements BoardSubscription { + final WorldRef world; final Key key; final Consumer listener; Board value; long received; boolean closed; + Watch(WorldRef world, Key key, Consumer listener) { this.world = world; this.key = key; this.listener = listener; value = empty(world, key, 0, Status.LOADING); } + public Board latest() { thread(); return value; } + void accept(Board board) { if (closed) return; value = board; received = System.nanoTime(); try { listener.accept(board); } catch (RuntimeException e) { MTRMap.LOGGER.warn("[MTRMap] Station API listener failed", e); } } + public void close() { + thread(); if (closed) return; closed = true; + var list = watches.get(key); if (list != null) { list.remove(this); if (list.isEmpty()) { watches.remove(key); cancel(key); } } + } + } + private void cancel(Key key) { + Query query = queries.remove(key); + if (!clearing && query != null && MTRNetworkClient.canSendOverlay()) MTRNetworkClient.sendToServer(new OverlayRequest(query.generation, key.dimension, key.station, false)); + } + public void tick() { + thread(); OverlayUi.tick(); + if (Minecraft.getInstance().getConnection() == null) return; + long now = System.nanoTime(); int sent = 0; + for (var entry : List.copyOf(queries.entrySet())) { + var query = entry.getValue(); var key = entry.getKey(); + if (query.transfer != null && now - query.transfer.createdNanos > 30_000_000_000L) query.transfer = null; + if (!key.station.isEmpty()) for (var watch : List.copyOf(watches.getOrDefault(key, List.of()))) { + if (watch.value.status() == Status.LOADING && now - query.started > 15_000_000_000L) watch.accept(empty(watch.world, key, query.generation, Status.ERROR)); + else if (watch.received > 0 && now - watch.received > 5_000_000_000L && watch.value.status() != Status.STALE) + watch.accept(new Board(watch.world, key.station, query.generation, Status.STALE, watch.value.sampledAt(), watch.value.serverNow(), watch.value.services(), watch.value.arrivals())); + } + if (queries.get(key) == query && sent < 2 && MTRNetworkClient.canSendOverlay() && now - query.requestAt > 1_000_000_000L) { + sent++; query.requestAt = now; MTRNetworkClient.sendToServer(new OverlayRequest(query.generation, key.dimension, key.station, true)); + } + } + for (var c : List.copyOf(catalogs.values())) if (catalog(c.world()).status() == Status.STALE) + queries.computeIfAbsent(new Key(key(c.world().dimensionId()), ""), ignored -> new Query(++generation)); + views.invalidate(catalogs.values().stream().map(c -> catalog(c.world())).toList()); + } + public void chunk(OverlayChunk message) { + thread(); var key = new Key(message.dimension(), message.station()); var query = queries.get(key); + if (query == null || query.generation != message.generation() || message.sequence() <= query.sequence) return; + try { + if (query.transfer == null || query.transferSequence != message.sequence()) { + if (message.sequence() < query.transferSequence) return; + if (message.total() > OverlayCodec.MAX_BYTES) throw new IllegalArgumentException("Overlay response too large"); + query.transferSequence = message.sequence(); query.transfer = new OverlayAssembly(message.count(), message.total()); + } + if (!query.transfer.add(message.index(), message.count(), message.total(), message.bytes())) return; + byte[] bytes = query.transfer.assemble(); query.transfer = null; + query.sequence = message.sequence(); + try { decoder.execute(() -> { + try { + Record decoded = key.station.isEmpty() ? OverlayCodec.decode(bytes, Catalog.class) : OverlayCodec.decode(bytes, Board.class); + Minecraft.getInstance().execute(() -> install(key, query, message.sequence(), decoded)); + } catch (Exception e) { MTRMap.LOGGER.warn("[MTRMap] Invalid overlay response", e); } + }); } catch (RejectedExecutionException busy) { queries.put(key, new Query(++generation)); } + } catch (Exception e) { query.transfer = null; MTRMap.LOGGER.warn("[MTRMap] Invalid overlay response", e); } + } + private void install(Key key, Query query, long sequence, Record decoded) { + thread(); + if (queries.get(key) != query || query.sequence != sequence) return; + if (decoded instanceof Catalog catalog) { + if (!key(catalog.world().dimensionId()).equals(key.dimension)) return; + catalogs.put(key.dimension, catalog); queries.remove(key); + } else { + var board = (Board) decoded; + if (!key(board.world().dimensionId()).equals(key.dimension) || !board.stationId().equals(key.station)) return; + for (var watch : List.copyOf(watches.getOrDefault(key, List.of()))) if (watch.world.equals(board.world())) + watch.accept(new Board(board.world(), board.stationId(), query.generation, board.status(), board.sampledAt(), board.serverNow(), board.services(), board.arrivals())); + } + } + public void clear() { + thread(); clearing = true; + try { OverlayUi.clear(); } finally { clearing = false; } + queries.clear(); decoder.getQueue().clear(); catalogs.clear(); generation++; views.clear(); ViewRenderer.clear(); + watches.values().forEach(list -> list.forEach(w -> { w.closed = true; w.value = empty(w.world, w.key, 0, Status.CLOSED); })); watches.clear(); + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapter.java b/src/main/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapter.java new file mode 100644 index 0000000..eca8420 --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapter.java @@ -0,0 +1,112 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.api.MtrMapApi.*; +import org.mtr.core.data.Position; +import org.mtr.core.data.TransportMode; +import org.mtr.core.operation.ArrivalsRequest; +import org.mtr.core.simulation.Simulator; +import org.mtr.libraries.it.unimi.dsi.fastutil.longs.LongImmutableList; +import java.util.*; + +/** Simulator-thread reads only. No independent timetable, routing or motion simulation. */ +public final class CoreOverlayAdapter { + private CoreOverlayAdapter() {} + private static String id(long id) { return Long.toUnsignedString(id, 16); } + private static Point point(Position p) { return new Point(p.getX(), p.getY(), p.getZ()); } + public static Catalog catalog(Simulator simulator, WorldRef world, long base) { + var stations = new ArrayList(); + var platforms = new ArrayList(); + var platformStations = new HashMap(); + for (var station : simulator.stations) { + var ids = new ArrayList(); + for (var platform : station.savedRails) { + ids.add(id(platform.getId())); platformStations.put(platform.getId(), id(station.getId())); + platforms.add(new Platform(id(platform.getId()), id(station.getId()), platform.getName(), point(platform.getMidPosition()))); + } + stations.add(new Station(id(station.getId()), station.getName(), point(station.getCenter()), ids)); + } + var routes = new ArrayList(); + for (var route : simulator.routes) { + if (route.getTransportMode() != TransportMode.TRAIN) continue; + var stops = new ArrayList(); + for (var stop : route.getRoutePlatforms()) { + var platform = stop.getPlatform(); + stops.add(new Stop(stops.size(), platform == null ? "" : id(platform.getId()), + platform == null ? "" : platformStations.getOrDefault(platform.getId(), ""), stop.getCustomDestination())); + } + routes.add(new io.github.teamcreating.mtrmap.api.MtrMapApi.Route(id(route.getId()), route.getName(), route.getRouteNumber(), + route.getColor(), route.getRouteType().name(), stops, paths(route, stops))); + } + long revision = base; + for (Object value : List.of(stations, platforms, routes)) revision = (revision ^ value.hashCode()) * 0x100000001b3L; + return new Catalog(world, revision, base, Status.READY, stations, platforms, routes); + } + /** Match the complete native stop sequence, never an intersection of route/platform memberships. */ + private static List paths(org.mtr.core.data.Route route, List stops) { + if (stops.size() < 2 || stops.stream().anyMatch(s -> s.platformId().isEmpty())) return List.of(); + var variants = new ArrayList(); + for (var depot : route.depots) { + var path = depot.getPath(); + if (path.size() > 20000 || variants.size() >= 128) break; + var indices = new ArrayList(); + for (int i = 0; i < path.size(); i++) { + var p = path.get(i); + if (p.getSavedRailBaseId() != 0 && (indices.isEmpty() + || path.get(indices.get(indices.size() - 1)).getSavedRailBaseId() != p.getSavedRailBaseId() + || path.get(indices.get(indices.size() - 1)).getStopIndex() != p.getStopIndex())) indices.add(i); + } + for (int start = 0; start + stops.size() <= indices.size() && variants.size() < 128; start++) { + boolean matches = true; + for (int j = 0; j < stops.size(); j++) + if (!id(path.get(indices.get(start + j)).getSavedRailBaseId()).equals(stops.get(j).platformId())) { matches = false; break; } + if (!matches) continue; + var legs = new ArrayList(); + for (int j = 0; j + 1 < stops.size(); j++) { + var rails = new ArrayList(); + int first = indices.get(start + j), last = indices.get(start + j + 1); + for (int k = first; k <= last; k++) { + var p = path.get(k); var rail = p.getRail(); + if (rail == null || !rail.isValid() || rail.railMath == null) { matches = false; break; } + double from = k == first ? 0.5 : 0, to = k == last ? 0.5 : 1; + rails.add(new RailSpan(rail.getHexId(), p.reversePositions ? 1 - from : from, p.reversePositions ? 1 - to : to)); + } + if (!matches) break; + legs.add(new Leg(j, j + 1, rails)); + } + if (matches) variants.add(new PathVariant(id(depot.getId()) + ":" + start, legs)); + } + } + return List.copyOf(variants); + } + public static Board board(Simulator simulator, WorldRef world, String stationId, long generation) { + long now = simulator.getCurrentMillis(); + var station = simulator.stationIdMap.get(Long.parseUnsignedLong(stationId, 16)); + if (station == null) return new Board(world, stationId, generation, Status.NOT_FOUND, now, now, List.of(), List.of()); + if (station.savedRails.size() > 128) return new Board(world, stationId, generation, Status.LIMITED, now, now, List.of(), List.of()); + long[] ids = station.savedRails.stream().mapToLong(p -> p.getId()).toArray(); + var services = new LinkedHashMap(); + for (var platform : station.savedRails) for (var route : platform.routes) { + if (route.getTransportMode() == TransportMode.TRAIN) services.put(id(route.getId()) + ":" + id(platform.getId()), + new Service(id(route.getId()), id(platform.getId()), route.getName(), route.getColor(), platform.getName())); + } + var entries = new ArrayList(); + boolean complete = false; + for (int count : new int[]{10, 40, 160}) { + var response = new ArrivalsRequest(new LongImmutableList(ids), count, 4097).getArrivals(simulator); + now = response.getCurrentTime(); entries.clear(); + var seen = new HashSet(); + var groups = new HashSet(); + for (var a : response.getArrivals()) { + String key = id(a.getRouteId()) + ":" + id(a.getPlatformId()) + ":" + a.getDestination() + ":" + a.getCircularState(); + if (a.getDeparture() < now || !groups.add(key)) continue; + seen.add(id(a.getRouteId()) + ":" + id(a.getPlatformId())); + entries.add(new Arrival(id(a.getRouteId()), a.getRouteName(), a.getRouteColor(), id(a.getPlatformId()), a.getPlatformName(), + a.getDestination(), a.getArrival(), a.getDeparture(), a.getDeviation(), a.getRealtime(), a.getIsTerminating())); + } + complete = seen.containsAll(services.keySet()) && response.getArrivals().size() < 4097; + if (complete || entries.size() >= 4096) break; + } + return new Board(world, stationId, generation, complete ? Status.READY : Status.LIMITED, now, now, + new ArrayList<>(services.values()), entries); + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayAssembly.java b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayAssembly.java new file mode 100644 index 0000000..e0e29dc --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayAssembly.java @@ -0,0 +1,25 @@ +package io.github.teamcreating.mtrmap.extension; +import io.github.teamcreating.mtrmap.network.OverlayChunk; +import java.util.Arrays; +public final class OverlayAssembly { + public final long createdNanos = System.nanoTime(); + private final byte[][] parts; private final int total; private int received; + public OverlayAssembly(int count, int total) { + if (total <= 0 || total > OverlayCodec.MAX_BYTES || count != (total + OverlayChunk.CHUNK - 1) / OverlayChunk.CHUNK) + throw new IllegalArgumentException("Invalid overlay transfer size"); + this.total = total; parts = new byte[count][]; + } + public boolean add(int index, int count, int total, byte[] bytes) { + if (count != parts.length || total != this.total || index < 0 || index >= count || bytes.length != Math.min(OverlayChunk.CHUNK, total - index * OverlayChunk.CHUNK)) + throw new IllegalArgumentException("Invalid overlay part"); + if (parts[index] == null) { parts[index] = bytes.clone(); received++; } + else if (!Arrays.equals(parts[index], bytes)) throw new IllegalArgumentException("Conflicting overlay part"); + return received == parts.length; + } + public byte[] assemble() { + if (received != parts.length) throw new IllegalStateException("Incomplete overlay transfer"); + byte[] result = new byte[total]; int offset = 0; + for (var part : parts) { System.arraycopy(part, 0, result, offset, part.length); offset += part.length; } + return result; + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayCodec.java b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayCodec.java new file mode 100644 index 0000000..d6d3bb5 --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayCodec.java @@ -0,0 +1,88 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.api.MtrMapApi; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; + +/** Schema-directed record codec: wire data never chooses a Java class. Bounded at every level. */ +public final class OverlayCodec { + public static final int MAX_BYTES = 8 * 1024 * 1024; + private OverlayCodec() {} + public static byte[] encode(Record value) throws IOException { + var bytes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(new OutputStream() { + private void capacity(int n) throws IOException { if (n > MAX_BYTES - bytes.size()) throw new IOException("Overlay response too large"); } + @Override public void write(int value) throws IOException { capacity(1); bytes.write(value); } + @Override public void write(byte[] data, int offset, int length) throws IOException { capacity(length); bytes.write(data, offset, length); } + })) { write(out, value, value.getClass(), 0); } + if (bytes.size() > MAX_BYTES) throw new IOException("Overlay response too large"); + return bytes.toByteArray(); + } + public static T decode(byte[] bytes, Class type) throws IOException { + if (bytes.length > MAX_BYTES) throw new IOException("Overlay response too large"); + try (var in = new DataInputStream(new ByteArrayInputStream(bytes))) { + Object value = read(in, type, 0, new int[]{200000}); + if (in.available() != 0) throw new IOException("Trailing overlay bytes"); + return type.cast(value); + } + } + private static void check(int depth) throws IOException { if (depth > 20) throw new IOException("Overlay nesting too deep"); } + private static void write(DataOutputStream out, Object value, Type type, int depth) throws IOException { + check(depth); + if (type instanceof ParameterizedType p) { + Type[] args = p.getActualTypeArguments(); + if (p.getRawType() == Optional.class) { var v = (Optional) value; out.writeBoolean(v.isPresent()); if (v.isPresent()) write(out, v.get(), args[0], depth + 1); } + else if (p.getRawType() == Map.class) { + var map = (Map) value; count(out, map.size()); + for (var entry : map.entrySet()) { write(out, entry.getKey(), args[0], depth + 1); write(out, entry.getValue(), args[1], depth + 1); } + } else { + var list = (Collection) value; count(out, list.size()); for (Object item : list) write(out, item, args[0], depth + 1); + } + return; + } + Class c = (Class) type; + if (c == String.class) { if (((String) value).length() > 8192) throw new IOException("Text exceeds capacity"); out.writeUTF((String) value); } + else if (c == int.class || c == Integer.class) out.writeInt((Integer) value); + else if (c == long.class) out.writeLong((Long) value); + else if (c == double.class) out.writeDouble((Double) value); + else if (c == boolean.class) out.writeBoolean((Boolean) value); + else if (c == UUID.class) { out.writeLong(((UUID) value).getMostSignificantBits()); out.writeLong(((UUID) value).getLeastSignificantBits()); } + else if (c.isEnum()) out.writeUTF(((Enum) value).name()); + else if (c.isRecord() && c.getEnclosingClass() == MtrMapApi.class) { + try { for (var component : c.getRecordComponents()) write(out, component.getAccessor().invoke(value), component.getGenericType(), depth + 1); } + catch (ReflectiveOperationException e) { throw new IOException(e); } + } else throw new IOException("Unsupported schema " + c); + } + private static void count(DataOutputStream out, int count) throws IOException { if (count > 20000) throw new IOException("Collection exceeds capacity"); out.writeInt(count); } + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object read(DataInputStream in, Type type, int depth, int[] remaining) throws IOException { + check(depth); if (--remaining[0] < 0) throw new IOException("Overlay object budget exceeded"); + if (type instanceof ParameterizedType p) { + Type[] args = p.getActualTypeArguments(); + if (p.getRawType() == Optional.class) return in.readBoolean() ? Optional.of(read(in, args[0], depth + 1, remaining)) : Optional.empty(); + int n = in.readInt(); if (n < 0 || n > 20000 || n > remaining[0]) throw new IOException("Invalid collection count"); + if (p.getRawType() == Map.class) { + var map = new LinkedHashMap<>(); + for (int i = 0; i < n; i++) { Object key = read(in, args[0], depth + 1, remaining); if (map.put(key, read(in, args[1], depth + 1, remaining)) != null) throw new IOException("Duplicate map key"); } + return Map.copyOf(map); + } + var list = new ArrayList<>(); for (int i = 0; i < n; i++) list.add(read(in, args[0], depth + 1, remaining)); + return p.getRawType() == Set.class ? Set.copyOf(list) : List.copyOf(list); + } + Class c = (Class) type; + if (c == String.class) { String text = in.readUTF(); if (text.length() > 8192) throw new IOException("Text exceeds capacity"); return text; } + if (c == int.class || c == Integer.class) return in.readInt(); + if (c == long.class) return in.readLong(); + if (c == double.class) return in.readDouble(); + if (c == boolean.class) return in.readBoolean(); + if (c == UUID.class) return new UUID(in.readLong(), in.readLong()); + try { + if (c.isEnum()) return Enum.valueOf((Class) c, in.readUTF()); + if (!c.isRecord() || c.getEnclosingClass() != MtrMapApi.class) throw new IOException("Unsupported schema"); + var components = c.getRecordComponents(); var values = new Object[components.length]; var types = new Class[values.length]; + for (int i = 0; i < values.length; i++) { values[i] = read(in, components[i].getGenericType(), depth + 1, remaining); types[i] = components[i].getType(); } + return c.getConstructor(types).newInstance(values); + } catch (ReflectiveOperationException | IllegalArgumentException e) { throw new IOException("Invalid overlay data", e); } + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUi.java b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUi.java new file mode 100644 index 0000000..fed894e --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUi.java @@ -0,0 +1,293 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.api.MtrMapApi.*; +import io.github.teamcreating.mtrmap.config.MTRMapConfig; +import io.github.teamcreating.mtrmap.config.PidsStyle; +import io.github.teamcreating.mtrmap.mapdata.*; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import org.lwjgl.glfw.GLFW; +import java.util.*; +import java.util.function.DoubleBinaryOperator; + +/** Classic station tooltip with an optional native arrival board. Host retains the camera. */ +public final class OverlayUi { + private record Button(int x, int y, int w, int h, Component label, boolean enabled, Runnable action) { + boolean contains(double mx, double my) { return mx >= x && my >= y && mx < x + w && my < y + h; } + } + private record Target(double x, double y, String station, MapLandmark marker) {} + private static final List