Add throttled player-position tracking, mod side (Phase 7b)

Adds PlayerPosition (common/protocol) and BackendConnection.sendPlayerPositions(),
wired into MCMapperMod.java: a new playerPositionIntervalTicks-throttled tick timer
(gated by playerTrackingEnabled) sends the full current overworld online-player
roster to the backend each interval, mirroring the "current state, not a diff"
philosophy of columns/sections — a player logging out just stops appearing next
send, no separate leave message needed.

Wire message: {"type":"player_positions","dimension":0,"players":[...]}.
Backend-side receive/relay + admin visibility toggle land in a follow-up commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-09 19:38:16 +02:00
parent 52583f2056
commit ef47d4481a
5 changed files with 102 additions and 3 deletions
@@ -2,6 +2,7 @@ package com.octoturge.mcmapper.common;
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.protocol.WaypointShare;
@@ -28,6 +29,14 @@ public interface BackendConnection {
/** Forwards one in-game chat message to the backend — see ChatBridge.OutboundSink's javadoc. */ /** Forwards one in-game chat message to the backend — see ChatBridge.OutboundSink's javadoc. */
void sendChatMessage(String uuid, String username, String message); 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);
/** Registers the callback for web-originated chat messages the backend relays back to us. */ /** Registers the callback for web-originated chat messages the backend relays back to us. */
void setChatListener(ChatListener listener); void setChatListener(ChatListener listener);
@@ -65,6 +74,10 @@ public interface BackendConnection {
public void sendChatMessage(String uuid, String username, String message) { public void sendChatMessage(String uuid, String username, String message) {
} }
@Override
public void sendPlayerPositions(int dimension, List<PlayerPosition> players) {
}
@Override @Override
public void setChatListener(ChatListener listener) { public void setChatListener(ChatListener listener) {
} }
@@ -3,6 +3,7 @@ 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.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.protocol.WaypointShare;
import com.octoturge.mcmapper.common.ws.SimpleWebSocketClient; import com.octoturge.mcmapper.common.ws.SimpleWebSocketClient;
@@ -32,6 +33,8 @@ import java.util.function.Consumer;
* mod -&gt; api {"type":"chat","uuid":"...","username":"...","message":"..."} * mod -&gt; api {"type":"chat","uuid":"...","username":"...","message":"..."}
* api -&gt; mod {"type":"chat","username":"...","message":"..."} * api -&gt; mod {"type":"chat","username":"...","message":"..."}
* api -&gt; mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"} * api -&gt; mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"}
*
* mod -&gt; api {"type":"player_positions","dimension":0,"players":[{"uuid":"...","username":"...","x":..,"y":..,"z":..}]}
* </pre> * </pre>
* *
* Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions * Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions
@@ -58,8 +61,16 @@ 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.
* *
* 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
@@ -270,6 +281,26 @@ public class DefaultBackendConnection implements BackendConnection {
sendRaw("chat", msg); 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);
}
@Override @Override
public void setChatListener(ChatListener listener) { public void setChatListener(ChatListener listener) {
this.chatListener = listener; this.chatListener = listener;
@@ -13,4 +13,5 @@ public class MapperConfig {
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 reconciliationChunksPerSweep = 50;
public int playerPositionIntervalTicks = 20 * 2;
} }
@@ -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;
}
}
@@ -5,8 +5,11 @@ import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.ReconciliationScheduler; 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.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.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;
@@ -33,7 +36,9 @@ import java.util.Set;
* 3D mesh rendering. Phase 3 adds `/mcmapper link` and the two-way chat bridge (see LinkCommand, * 3D mesh rendering. Phase 3 adds `/mcmapper link` and the two-way chat bridge (see LinkCommand,
* Forge1122ChatBridge). Phase 7 adds a periodic reconciliation sweep (see {@link * Forge1122ChatBridge). Phase 7 adds a periodic reconciliation sweep (see {@link
* ReconciliationScheduler}) that catches non-event mutations (world-gen, other mods, `/fill`) * ReconciliationScheduler}) that catches non-event mutations (world-gen, other mods, `/fill`)
* the event-driven hooks in {@link Forge1122ChunkAdapter} never see. * 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 {
@@ -49,8 +54,10 @@ public class MCMapperMod {
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 final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
private MinecraftServer mcServer;
private int ticksSinceFlush = 0; private int ticksSinceFlush = 0;
private int ticksSinceReconciliation = 0; private int ticksSinceReconciliation = 0;
private int ticksSincePlayerPositions = 0;
@Mod.EventHandler @Mod.EventHandler
public void preInit(FMLPreInitializationEvent event) { public void preInit(FMLPreInitializationEvent event) {
@@ -75,6 +82,14 @@ public class MCMapperMod {
config.reconciliationChunksPerSweep = forgeConfig.getInt("reconciliationChunksPerSweep", "network", config.reconciliationChunksPerSweep = forgeConfig.getInt("reconciliationChunksPerSweep", "network",
config.reconciliationChunksPerSweep, 1, 5000, config.reconciliationChunksPerSweep, 1, 5000,
"Max chunks to re-read and resend per reconciliation sweep"); "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();
} }
@@ -85,6 +100,7 @@ public class MCMapperMod {
return; return;
} }
mcServer = event.getServer();
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn); connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
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);
@@ -130,6 +146,21 @@ public class MCMapperMod {
ticksSinceReconciliation = 0; ticksSinceReconciliation = 0;
reconcile(); 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() { private void reconcile() {