Phase 1: forge-1_12_2 WS connection and column delta capture
Adds a hand-rolled RFC 6455 WS client and minimal JSON codec to common/ (no third-party deps, keeping legacy ForgeGradle's classpath untouched), a DefaultBackendConnection implementing hello/hello_ack auth and reconnect with backoff, and Forge1122ChunkAdapter deriving top-of-column state from the vanilla heightmap for both initial chunk backfill and event-driven deltas (block break/place, deferred one tick to read post-mutation state). MCMapperMod wires it up with config-driven backendUrl/serverToken and a tick-based flush batch. Verified against a live MCMapper-Backend api instance (real WS handshake, hello_ack, and 256-column batch landing correctly in Postgres).
This commit is contained in:
+132
@@ -0,0 +1,132 @@
|
||||
package com.octoturge.mcmapper.forge1122;
|
||||
|
||||
import com.octoturge.mcmapper.common.ChunkAdapter;
|
||||
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||
import net.minecraft.block.Block;
|
||||
import net.minecraft.block.state.IBlockState;
|
||||
import net.minecraft.util.math.BlockPos;
|
||||
import net.minecraft.world.World;
|
||||
import net.minecraft.world.WorldServer;
|
||||
import net.minecraft.world.chunk.Chunk;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.common.util.BlockSnapshot;
|
||||
import net.minecraftforge.event.world.BlockEvent;
|
||||
import net.minecraftforge.event.world.ChunkEvent;
|
||||
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.gameevent.TickEvent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 1.12.2's {@link ChunkAdapter}: derives each column's "top of column" state (height + block)
|
||||
* from the chunk's vanilla precipitation heightmap ({@link Chunk#getHeightValue}) — the same
|
||||
* O(1) lookup vanilla itself uses, rather than scanning down from the world height limit.
|
||||
*/
|
||||
public class Forge1122ChunkAdapter implements ChunkAdapter {
|
||||
private final WorldServer world;
|
||||
private final String dimensionId;
|
||||
|
||||
public Forge1122ChunkAdapter(WorldServer world) {
|
||||
this.world = world;
|
||||
this.dimensionId = String.valueOf(world.provider.getDimension());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ) {
|
||||
Chunk chunk = world.getChunk(chunkX, chunkZ);
|
||||
List<DeltaEvent> events = new ArrayList<>(256);
|
||||
long now = System.currentTimeMillis();
|
||||
for (int lx = 0; lx < 16; lx++) {
|
||||
for (int lz = 0; lz < 16; lz++) {
|
||||
events.add(readColumn(chunk, chunkX * 16 + lx, chunkZ * 16 + lz, lx, lz, now,
|
||||
DeltaEvent.Source.RECONCILIATION));
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private DeltaEvent readColumn(Chunk chunk, int worldX, int worldZ, int localX, int localZ,
|
||||
long now, DeltaEvent.Source source) {
|
||||
int height = chunk.getHeightValue(localX, localZ);
|
||||
int topY = Math.max(0, height - 1);
|
||||
IBlockState state = chunk.getBlockState(new BlockPos(localX, topY, localZ));
|
||||
int id = Block.getIdFromBlock(state.getBlock());
|
||||
int meta = state.getBlock().getMetaFromState(state);
|
||||
int blockStateId = ((id & 0xFFF) << 4) | (meta & 0xF);
|
||||
return new DeltaEvent(dimensionId, worldX, topY, worldZ, blockStateId, now, source);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerEventHooks(DeltaSink sink) {
|
||||
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code BlockEvent.BreakEvent} fires *before* the block is actually removed (it's
|
||||
* cancellable), so reading world state synchronously inside that handler would see the
|
||||
* pre-break block, not the resulting top-of-column state. Rather than special-case each
|
||||
* event's timing, every hook just marks the column dirty; a server-tick handler drains the
|
||||
* dirty set once per tick (well after any same-tick mutation completes) and reads the
|
||||
* genuinely-current state then. This also naturally coalesces multiple changes to the same
|
||||
* column within one tick into a single read.
|
||||
*/
|
||||
private class EventHooks {
|
||||
private final DeltaSink sink;
|
||||
private final Set<Long> dirtyColumns = ConcurrentHashMap.newKeySet();
|
||||
|
||||
EventHooks(DeltaSink sink) {
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onBlockBreak(BlockEvent.BreakEvent event) {
|
||||
markDirty(event.getWorld(), event.getPos());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onBlockPlace(BlockEvent.PlaceEvent event) {
|
||||
markDirty(event.getWorld(), event.getPos());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onMultiPlace(BlockEvent.MultiPlaceEvent event) {
|
||||
for (BlockSnapshot snapshot : event.getReplacedBlockSnapshots()) {
|
||||
markDirty(event.getWorld(), snapshot.getPos());
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onChunkLoad(ChunkEvent.Load event) {
|
||||
if (event.getWorld() != world) return;
|
||||
Chunk chunk = event.getChunk();
|
||||
for (DeltaEvent e : readChunk(dimensionId, chunk.x, chunk.z)) {
|
||||
sink.onDelta(e);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onServerTick(TickEvent.ServerTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END || dirtyColumns.isEmpty()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
Iterator<Long> it = dirtyColumns.iterator();
|
||||
while (it.hasNext()) {
|
||||
long key = it.next();
|
||||
it.remove();
|
||||
int wx = (int) (key >> 32);
|
||||
int wz = (int) key;
|
||||
Chunk chunk = world.getChunk(wx >> 4, wz >> 4);
|
||||
sink.onDelta(readColumn(chunk, wx, wz, wx & 15, wz & 15, now, DeltaEvent.Source.EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
private void markDirty(World eventWorld, BlockPos pos) {
|
||||
if (eventWorld != world) return;
|
||||
long key = ((long) pos.getX() << 32) | (pos.getZ() & 0xFFFFFFFFL);
|
||||
dirtyColumns.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,29 @@
|
||||
package com.octoturge.mcmapper.forge1122;
|
||||
|
||||
import com.octoturge.mcmapper.common.DefaultBackendConnection;
|
||||
import com.octoturge.mcmapper.common.config.MapperConfig;
|
||||
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||
import net.minecraft.world.WorldServer;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.common.config.Configuration;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
|
||||
import net.minecraftforge.fml.common.event.FMLServerStartingEvent;
|
||||
import net.minecraftforge.fml.common.event.FMLServerStoppingEvent;
|
||||
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.gameevent.TickEvent;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entry point for the 1.12.2 leaf — the primary/first-implemented target (Enigmatica 2).
|
||||
* Phase 0 scaffolding only: connection, delta capture, chat bridge and link-command wiring
|
||||
* land in Phase 1, built on the {@code common} interfaces via a 1.12.2 {@code ChunkAdapter}
|
||||
* and {@code ChatBridge} implementation (not yet present in this package).
|
||||
* Phase 1: connects to the backend over WS, backfills the overworld's loaded chunks, and
|
||||
* flushes event-driven column deltas on a timer (no periodic reconciliation sweep yet — see
|
||||
* plan's Phase 7). Chat bridge and `/mcmapper link` land in Phase 3.
|
||||
*/
|
||||
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
|
||||
public class MCMapperMod {
|
||||
@@ -18,8 +32,68 @@ public class MCMapperMod {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(MOD_ID);
|
||||
|
||||
private Configuration forgeConfig;
|
||||
private final MapperConfig config = new MapperConfig();
|
||||
private DefaultBackendConnection connection;
|
||||
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<>());
|
||||
private int ticksSinceFlush = 0;
|
||||
|
||||
@Mod.EventHandler
|
||||
public void preInit(FMLPreInitializationEvent event) {
|
||||
LOGGER.info("MCMapper (1.12.2 leaf) scaffolding loaded — no-op until Phase 1");
|
||||
forgeConfig = new Configuration(event.getSuggestedConfigurationFile());
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
private void loadConfig() {
|
||||
forgeConfig.load();
|
||||
config.backendUrl = forgeConfig.getString("backendUrl", "network", config.backendUrl,
|
||||
"WS URL of the MCMapper backend api service");
|
||||
config.serverToken = forgeConfig.getString("serverToken", "network", config.serverToken,
|
||||
"Per-server token issued when registering with the backend (see MCMapper-Backend's `bun run seed`)");
|
||||
config.deltaFlushIntervalTicks = forgeConfig.getInt("deltaFlushIntervalTicks", "network",
|
||||
config.deltaFlushIntervalTicks, 1, 20 * 60,
|
||||
"How often (in ticks) to batch and flush block-change deltas to the backend");
|
||||
if (forgeConfig.hasChanged()) forgeConfig.save();
|
||||
}
|
||||
|
||||
@Mod.EventHandler
|
||||
public void serverStarting(FMLServerStartingEvent event) {
|
||||
if (config.serverToken == null || config.serverToken.isEmpty()) {
|
||||
LOGGER.warn("MCMapper serverToken is not configured (see config/mcmapper.cfg) — not connecting to backend");
|
||||
return;
|
||||
}
|
||||
|
||||
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
|
||||
connection.connect(config.backendUrl, config.serverToken);
|
||||
LOGGER.info("MCMapper (1.12.2 leaf) connecting to " + config.backendUrl);
|
||||
|
||||
WorldServer overworld = event.getServer().getWorld(0);
|
||||
Forge1122ChunkAdapter adapter = new Forge1122ChunkAdapter(overworld);
|
||||
adapter.registerEventHooks(pendingDeltas::add);
|
||||
|
||||
MinecraftForge.EVENT_BUS.register(this);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onServerTick(TickEvent.ServerTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) return;
|
||||
if (++ticksSinceFlush < config.deltaFlushIntervalTicks) return;
|
||||
ticksSinceFlush = 0;
|
||||
flush();
|
||||
}
|
||||
|
||||
private void flush() {
|
||||
List<DeltaEvent> batch;
|
||||
synchronized (pendingDeltas) {
|
||||
if (pendingDeltas.isEmpty()) return;
|
||||
batch = new ArrayList<>(pendingDeltas);
|
||||
pendingDeltas.clear();
|
||||
}
|
||||
connection.sendDeltas(batch);
|
||||
}
|
||||
|
||||
@Mod.EventHandler
|
||||
public void serverStopping(FMLServerStoppingEvent event) {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user