Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 311 additions & 0 deletions docs/multiplayer/worknotes/MP-12.md

Large diffs are not rendered by default.

262 changes: 262 additions & 0 deletions docs/multiplayer/worknotes/MP-H.md

Large diffs are not rendered by default.

203 changes: 183 additions & 20 deletions mod/src/client/java/dev/agentcraft/client/dev/DevCommands.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import com.google.gson.JsonObject;
import dev.agentcraft.client.dev.DevBridge.DevException;
import dev.agentcraft.client.dev.Fields;
import dev.agentcraft.client.mp.dev.MpDevCommands;
import dev.agentcraft.layout.Anchor;
import dev.agentcraft.layout.AnchorNames;
import dev.agentcraft.layout.Anchors;
import dev.agentcraft.mp.StudioId;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -115,12 +117,14 @@ private static CameraPath parseKeys(Fields f, double duration, double defaultFov
Fields pathLook = f.optObj("lookAt");
double pathFov = f.optNum("fov", defaultFov, 30, 110);
boolean easeEnds = f.optBool("easeEnds", true);
// MP-12: an optional studio on the camera JSON (absent or "own" = the local studio); keys resolve there.
StudioId studio = MpDevCommands.resolveStudio(f.optStr("studio", null));
List<Key> keys = new ArrayList<>();
for (int i = 0; i < arr.size(); i++) {
if (!arr.get(i).isJsonObject()) {
throw new DevException("camera.keys[" + i + "] must be an object");
}
JsonObject ko = resolveAnchor(arr.get(i).getAsJsonObject(), i);
JsonObject ko = resolveAnchor(arr.get(i).getAsJsonObject(), i, studio);
Fields k = Fields.of(ko);
Key key = new Key();
key.t = k.num("t", 0, 1e6);
Expand Down Expand Up @@ -186,13 +190,13 @@ private static CameraPath parseKeys(Fields f, double duration, double defaultFov
}

/** {@code anchor:"name"} fills x/y/z/yaw/pitch (explicit fields win); non-camera anchors are feet positions. */
private static JsonObject resolveAnchor(JsonObject key, int index) {
private static JsonObject resolveAnchor(JsonObject key, int index, StudioId studio) {
Fields f = Fields.of(key);
if (!f.has("anchor")) {
return key;
}
String name = f.nonBlank("anchor");
Anchor a = Anchors.get(name);
Anchor a = Anchors.forStudio(studio).get(name);
if (a == null) {
throw new DevException("camera.keys[" + index + "]: unknown anchor '" + name + "' (see dev.anchors)");
}
Expand Down
18 changes: 13 additions & 5 deletions mod/src/client/java/dev/agentcraft/client/hq/HqCheck.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import com.google.gson.JsonObject;
import dev.agentcraft.client.agents.GridPathfinder;
import dev.agentcraft.client.dev.DevBridge;
import dev.agentcraft.client.dev.Fields;
import dev.agentcraft.client.mp.dev.MpDevCommands;
import dev.agentcraft.layout.Anchor;
import dev.agentcraft.layout.AnchorNames;
import dev.agentcraft.layout.Anchors;
import dev.agentcraft.mp.StudioId;
import java.util.List;
import java.util.Locale;
import java.util.Set;
Expand Down Expand Up @@ -47,10 +50,14 @@ private HqCheck() {

static void register() {
DevBridge.register("dev.hq.check", 30_000,
"{minLight?} -> HQ reachability (A* from entrance + lounge to every station anchor or its seat's step-in cell), furniture climbing, slot spacing, interior light",
"{minLight?, studio?} -> HQ reachability (A* from entrance + lounge to every station anchor or its seat's step-in cell), furniture climbing, slot spacing, interior light"
+ " for an optional studio UUID or 'own' (default)",
(req, mc) -> {
int minLight = req.has("minLight") ? req.get("minLight").getAsInt() : 7;
return DevBridge.onClient(mc, () -> check(mc, minLight));
Fields f = Fields.of(req);
int minLight = f.optInt("minLight", 7, 0, 15);
// MP-12: resolve through Anchors.forStudio so a named studio reads its own layout.
StudioId studio = MpDevCommands.resolveStudio(f.optStr("studio", null));
return DevBridge.onClient(mc, () -> check(mc, minLight, studio));
});
}

Expand Down Expand Up @@ -103,14 +110,15 @@ static boolean isSeat(ClientLevel level, Anchor a) {
return null;
}

static JsonObject check(Minecraft mc, int minLight) {
static JsonObject check(Minecraft mc, int minLight, StudioId studio) {
JsonObject o = new JsonObject();
ClientLevel level = mc.level;
Anchors.Layout layout = Anchors.current();
Anchors.Layout layout = Anchors.forStudio(studio);
if (level == null || layout.isEmpty() || layout.bounds() == null) {
o.addProperty("error", "no level or no layout");
return o;
}
o.addProperty("studio", studio.owner().toString());
o.addProperty("layout", layout.name());
JsonArray failures = new JsonArray();
JsonArray climbing = new JsonArray();
Expand Down
184 changes: 184 additions & 0 deletions mod/src/client/java/dev/agentcraft/client/mp/dev/MpDevCommands.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package dev.agentcraft.client.mp.dev;

import com.google.gson.JsonArray;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import dev.agentcraft.client.dev.DevBridge;
import dev.agentcraft.client.dev.DevBridge.DevException;
import dev.agentcraft.client.dev.DevCommands;
import dev.agentcraft.client.dev.Fields;
import dev.agentcraft.client.mp.Studios;
import dev.agentcraft.layout.Anchors;
import dev.agentcraft.mp.Plot;
import dev.agentcraft.mp.StudioId;
import dev.agentcraft.mp.net.HelloC2S;
import dev.agentcraft.mp.net.PublicStateC2S;
import dev.agentcraft.mp.net.StudioEventC2S;
import dev.agentcraft.mp.net.WorldIntentC2S;
import dev.agentcraft.mp.state.MpText;
import dev.agentcraft.mp.state.PublicEvent;
import dev.agentcraft.mp.state.PublicJson;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Optional;
import java.util.UUID;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.Minecraft;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import org.jspecify.annotations.Nullable;

/**
* DevBridge hooks for multiplayer. {@code dev.mp.send} puts a C2S payload on the real network for the
* relay and intent packets to receive; {@code dev.mp.relay} shows what the client last received.
* The packet's row owns this class; dev tools only, never a Foreman or network side effect of their own.
*/
public final class MpDevCommands {
/** Bound on the received-event ring so {@code dev.mp.relay} cannot grow without limit. */
static final int EVENT_RING = 32;

private static final Deque<ReceivedEvent> EVENTS = new ArrayDeque<>();
private static boolean listening;

private MpDevCommands() {
}

private record ReceivedEvent(StudioId studio, PublicEvent event) {
}

public static void register() {
if (!listening) {
listening = true;
// Client-thread listener; the ring is read on the same thread and never written to a log.
Studios.addEventListener((studio, event) -> {
synchronized (EVENTS) {
EVENTS.addLast(new ReceivedEvent(studio, event));
while (EVENTS.size() > EVENT_RING) {
EVENTS.removeFirst();
}
}
});
// The ring holds one connection's events. A mode listener cannot scope it: events injected in
// singleplayer would survive, because joining or leaving there finds the mode already SINGLEPLAYER.
// Fabric may fire DISCONNECT on Netty, so both hooks hop to the client thread.
ClientPlayConnectionEvents.JOIN.register((handler, sender, mc) -> mc.execute(MpDevCommands::clearEvents));
ClientPlayConnectionEvents.DISCONNECT.register((handler, mc) -> mc.execute(MpDevCommands::clearEvents));
}
DevBridge.register("dev.mp.send", 10_000,
"{payload: public_state|studio_event|world_intent|hello, state?|event?|intent?|protocol+modVersion}"
+ " - send one C2S payload through the real codec (a malformed request is refused here);"
+ " remote server only (needs AGENTCRAFT_DEV_REMOTE=1)",
(req, mc) -> {
CustomPacketPayload packet = parseSend(Fields.of(req));
return DevBridge.onClient(mc, () -> send(mc, packet));
});
DevBridge.register("dev.mp.relay", 10_000,
"{} - the client's last received states (studios) and events, and the current mode",
(req, mc) -> DevBridge.onClient(mc, MpDevCommands::relay));
}

/** Empty the received-event ring; runs on every play-connection join and disconnect. */
static void clearEvents() {
synchronized (EVENTS) {
EVENTS.clear();
}
}

// ------------------------------------------------------------------ pure parts

/** Resolve a dev command's optional studio token: absent, empty or "own" is the local studio. */
public static StudioId resolveStudio(@Nullable String token) {
if (token == null || token.isBlank() || token.equalsIgnoreCase("own")) {
return Anchors.self();
}
try {
return StudioId.of(UUID.fromString(token));
} catch (IllegalArgumentException e) {
throw new DevException("field 'studio' must be a studio UUID or 'own' (got '" + token + "')");
}
}

/**
* Build the payload a {@code dev.mp.send} request names, through the same {@link PublicJson} the
* network codecs use, so a malformed payload is refused with the codec's own message.
*/
public static CustomPacketPayload parseSend(Fields f) {
String payload = f.nonBlank("payload");
try {
return switch (payload) {
case "public_state" -> new PublicStateC2S(PublicJson.fromJson(f.obj("state").json()));
case "studio_event" -> new StudioEventC2S(PublicJson.eventFromJson(f.obj("event").json()));
case "world_intent" -> new WorldIntentC2S(PublicJson.intentFromJson(f.obj("intent").json()));
case "hello" -> {
int protocol = (int) f.integer("protocol", 0, Integer.MAX_VALUE);
String version = f.str("modVersion");
yield new HelloC2S(protocol, MpText.sanitize(version.substring(0, Math.min(32, version.length())), 32));
}
default -> throw new DevException("field 'payload' must be public_state|studio_event|world_intent|hello (got '" + payload + "')");
};
} catch (IllegalArgumentException e) {
// The network codec reads the record through PublicJson, so this is the codec's refusal.
throw new DevException("invalid '" + payload + "' payload: " + e.getMessage());
}
}

// ------------------------------------------------------------------ handlers

/** {@code dev.mp.send} never sends in singleplayer, and on a remote server only with the remote opt-in. */
public static void checkSend(boolean singleplayer) {
if (singleplayer) {
// The integrated server accepts the payload's receiver, but singleplayer takes no multiplayer path.
throw new DevException("dev.mp.send needs a remote server; singleplayer takes no multiplayer path");
}
DevCommands.requireRemoteOptIn();
}

private static JsonObject send(Minecraft mc, CustomPacketPayload packet) {
checkSend(mc.getSingleplayerServer() != null);
if (mc.getConnection() == null) {
throw new DevException("dev.mp.send needs a connection to a server");
}
if (!ClientPlayNetworking.canSend(packet.type())) {
throw new DevException("the server does not accept payload '" + packet.type().id() + "'");
}
ClientPlayNetworking.send(packet);
JsonObject o = new JsonObject();
o.addProperty("type", packet.type().id().toString());
o.addProperty("sent", true);
return o;
}

private static JsonObject relay() {
// dev.mp.studios already reports the mode and every studio's latest public state.
JsonObject out = MpDevFake.studios();
JsonArray events = new JsonArray();
synchronized (EVENTS) {
for (ReceivedEvent e : EVENTS) {
JsonObject o = PublicJson.toJson(e.event());
o.addProperty("studio", e.studio().owner().toString());
events.add(o);
}
}
out.add("events", events);
return out;
}

/** The multiplayer part of {@code dev.state}: mode, studios and the player's own plot. Client thread. */
public static JsonObject devState() {
JsonObject out = MpDevFake.studios();
// One source of truth: the hello (and, with it, MP-03's allocation) feeds Studios; singleplayer has none.
Optional<Plot> plot = Studios.plot(Anchors.self());
if (plot.isPresent()) {
Plot p = plot.get();
JsonObject o = new JsonObject();
o.addProperty("index", p.index());
o.addProperty("x", p.origin().getX());
o.addProperty("y", p.origin().getY());
o.addProperty("z", p.origin().getZ());
out.add("plot", o);
} else {
out.add("plot", JsonNull.INSTANCE);
}
return out;
}
}
14 changes: 10 additions & 4 deletions mod/src/client/java/dev/agentcraft/client/world/AnchorsDev.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import com.google.gson.JsonObject;
import dev.agentcraft.client.dev.DevBridge;
import dev.agentcraft.client.dev.Fields;
import dev.agentcraft.client.mp.dev.MpDevCommands;
import dev.agentcraft.layout.Anchor;
import dev.agentcraft.layout.Anchors;
import dev.agentcraft.mp.StudioId;
import java.util.concurrent.CompletableFuture;

/**
Expand All @@ -18,12 +20,16 @@ private AnchorsDev() {

public static void init() {
DevBridge.register("dev.anchors", 5_000,
"{prefix?} -> {layout, revision, bounds, anchors:{name:{x,y,z,yaw,pitch}}}. cam_* = eye position + view; others = feet/surface"
+ " (see AnchorNames). Use with dev.camera {anchor}",
"{prefix?, studio?} -> {studio, layout, revision, bounds, anchors:{name:{x,y,z,yaw,pitch}}}. cam_* = eye position + view; others = feet/surface"
+ " (see AnchorNames). Use with dev.camera {anchor}. studio is a UUID or 'own' (default)",
(req, mc) -> {
String prefix = Fields.of(req).optStr("prefix", "");
Anchors.Layout layout = Anchors.current();
Fields f = Fields.of(req);
String prefix = f.optStr("prefix", "");
// MP-12: resolve through Anchors.forStudio so a named studio reads its own layout.
StudioId studio = MpDevCommands.resolveStudio(f.optStr("studio", null));
Anchors.Layout layout = Anchors.forStudio(studio);
JsonObject o = new JsonObject();
o.addProperty("studio", studio.owner().toString());
o.addProperty("layout", layout.name());
o.addProperty("revision", layout.revision());
JsonObject full = Anchors.toJson(layout);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package dev.agentcraft.client.dev;

import static org.junit.jupiter.api.Assertions.*;

import com.google.gson.JsonObject;
import dev.agentcraft.layout.Anchors;
import dev.agentcraft.mp.StudioId;
import java.util.UUID;
import org.junit.jupiter.api.Test;

/** MP-12: {@code dev.camera} anchor resolution through an optional named studio. */
class DevAnchorStudioTest {
@Test void an_anchor_resolves_from_the_named_studio_and_a_missing_one_is_refused() {
StudioId remote = StudioId.of(UUID.fromString("11111111-2222-3333-4444-555555555555"));
Anchors.publish(remote, Anchors.builder("studio").bounds(-46, 60, -36, 46, 100, 54).spot("desk_kit", 1, 66, 2, 90).build());
try {
JsonObject request = new JsonObject();
request.addProperty("anchor", "desk_kit");
request.addProperty("studio", remote.owner().toString());
JsonObject resolved = DevCommands.resolveAnchor(request);
assertEquals(1.5, resolved.get("x").getAsDouble(), 1e-9);
assertEquals(66.0, resolved.get("y").getAsDouble(), 1e-9);
assertEquals(2.5, resolved.get("z").getAsDouble(), 1e-9);
assertTrue(resolved.get("feet").getAsBoolean());
assertFalse(resolved.has("anchor"));

JsonObject missing = new JsonObject();
missing.addProperty("anchor", "desk_kit");
missing.addProperty("studio", UUID.randomUUID().toString());
DevBridge.DevException error = assertThrows(DevBridge.DevException.class, () -> DevCommands.resolveAnchor(missing));
assertTrue(error.getMessage().contains("unknown anchor"), error.getMessage());

// No anchor field: nothing to resolve, the request is returned untouched.
JsonObject plain = new JsonObject();
plain.addProperty("x", 1.0);
assertSame(plain, DevCommands.resolveAnchor(plain));
} finally {
Anchors.remove(remote);
}
}
}
Loading
Loading