feat(04-01): register GravityFlipWand interaction + item JSON (WAND-01, WAND-02)

- GravityFlipWandInteraction extends SimpleInstantInteraction; Primary/Secondary
  routed through firstRun(), writes to WandSelectionStore via volatile bindStore()
- Items/gravityflip_wand.json: Utility item (Icon Torch_Fire) whose Primary and
  Secondary Interactions reference Type=GravityFlipWand
- GravityFlipPlugin.setup(): constructs WandSelectionStore, injects it into the
  interaction class, then registers via getCodecRegistry(Interaction.CODEC)
  (pattern from InstancesPlugin:158 / ExitInstanceInteraction)
- Expose wandSelections() getter for Phase 04-02+ commands
This commit is contained in:
2026-04-23 18:51:13 +02:00
parent e0a329480a
commit ae0e2064dc
3 changed files with 159 additions and 0 deletions
@@ -15,6 +15,9 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
import com.mythlane.gravityflip.region.RegionRegistry;
import com.mythlane.gravityflip.tick.RegionTickLoop;
import com.mythlane.gravityflip.viz.RegionVisualizer;
import com.mythlane.gravityflip.wand.GravityFlipWandInteraction;
import com.mythlane.gravityflip.wand.WandSelectionStore;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction;
import java.util.concurrent.ScheduledFuture;
import java.util.logging.Level;
@@ -46,6 +49,7 @@ public class GravityFlipPlugin extends JavaPlugin {
private GravityApplier gravityApplier;
private FallDamageGuard fallDamageGuard;
private RegionVisualizer regionVisualizer;
private WandSelectionStore wandSelectionStore;
public GravityFlipPlugin(JavaPluginInit init) {
super(init);
@@ -69,6 +73,18 @@ public class GravityFlipPlugin extends JavaPlugin {
getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem(
fallDamageGuard,
th -> getLogger().at(Level.WARNING).withCause(th).log("fallDamageSuppressor handle failed")));
// Plan 04-01 : Gravity Flip wand.
// Interaction binding pattern per 04-00 SPIKE-RESULT (Finding 3) — same shape as
// InstancesPlugin.java:158 / ExitInstanceInteraction. The JSON Item at
// src/main/resources/Items/gravityflip_wand.json references this Type in
// Interactions.Primary / Interactions.Secondary.
this.wandSelectionStore = new WandSelectionStore();
GravityFlipWandInteraction.bindStore(this.wandSelectionStore);
getCodecRegistry(Interaction.CODEC).register(
"GravityFlipWand",
GravityFlipWandInteraction.class,
GravityFlipWandInteraction.CODEC);
}
@Override
@@ -151,6 +167,14 @@ public class GravityFlipPlugin extends JavaPlugin {
/** Exposed for Phase 3 (gravity physics) and Phase 4 (commands). */
public RegionRegistry regions() { return registry; }
/**
* Per-player wand selection store. Populated by
* {@link GravityFlipWandInteraction}; consumed by
* {@code /gravityflip define} (Phase 04-02+).
* <p>Returns {@code null} until {@link #setup()} has run.
*/
public WandSelectionStore wandSelections() { return wandSelectionStore; }
/**
* Accessor for the region config holder. <strong>SAVE CONTRACT:</strong> any
* caller that mutates {@code configHolder().get().getRegions()} MUST call
@@ -0,0 +1,119 @@
package com.mythlane.gravityflip.wand;
import com.hypixel.hytale.codec.builder.BuilderCodec;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.protocol.BlockPosition;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.entity.InteractionContext;
import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.util.UUID;
/**
* Gravity Flip wand interaction — reads Primary / Secondary clicks and pushes
* the targeted block position into a {@link WandSelectionStore} keyed by the
* clicker's player UUID.
*
* <p><b>Binding :</b> registered at {@code setup()} time via
* {@code getCodecRegistry(Interaction.CODEC).register("GravityFlipWand", …)} —
* same pattern as {@code ExitInstanceInteraction} (cf. 04-00 SPIKE-RESULT,
* Finding 3). The matching {@code "Type": "GravityFlipWand"} reference in
* {@code Items/gravityflip_wand.json} wires the click packet to this class.
*
* <p><b>One class for both click types :</b> {@link #firstRun} receives the
* {@link InteractionType}. Primary → {@code setPos1}, Secondary → {@code setPos2}.
* Centralising both in one class keeps a single registration entry and a
* single CODEC — any other split would duplicate wiring for no gain.
*
* <p><b>Store injection :</b> the Hytale CODEC instantiates interactions via a
* no-arg constructor, so we cannot inject the store through the constructor.
* Instead, {@link #bindStore(WandSelectionStore)} installs a {@code volatile}
* reference at plugin start. If the store is not bound (mis-wired plugin),
* {@link #firstRun} is a safe no-op — fail-silent.
*
* <p><b>Threat surface :</b>
* <ul>
* <li>Chat feedback uses {@link PlayerRef#sendMessage} — directed to the
* clicker only, no broadcast (T-04-01-03).</li>
* <li>{@code TargetBlock} coordinates are stored raw as {@code int[]} — no
* numeric processing in this plan; {@code /gravityflip define} (04-03)
* is responsible for clamping / validating before region creation
* (T-04-01-01).</li>
* </ul>
*/
public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
@Nonnull
public static final BuilderCodec<GravityFlipWandInteraction> CODEC =
((BuilderCodec.Builder) BuilderCodec
.builder(GravityFlipWandInteraction.class,
GravityFlipWandInteraction::new,
SimpleInstantInteraction.CODEC)
.documentation(
"Gravity Flip wand: Primary click sets pos1, "
+ "Secondary click sets pos2, for the clicker's selection."))
.build();
/**
* Store shared by every instance of this interaction — injected at plugin
* start via {@link #bindStore}. Volatile so the writer thread ({@code setup()})
* publishes a safe reference to the reader threads (interaction dispatch).
*/
private static volatile WandSelectionStore STORE;
/** Wire the selection store before any click can be processed. Call once at {@code setup()}. */
public static void bindStore(WandSelectionStore store) {
STORE = store;
}
/** Required no-arg constructor used by the CODEC factory. */
public GravityFlipWandInteraction() {
}
@Override
protected void firstRun(@Nonnull InteractionType type,
@Nonnull InteractionContext context,
@Nonnull CooldownHandler cooldownHandler) {
WandSelectionStore store = STORE;
if (store == null) {
// Plugin mis-wired (bindStore never called). Silent no-op — don't crash the click.
return;
}
Ref<EntityStore> entityRef = context.getEntity();
CommandBuffer<EntityStore> commandBuffer = context.getCommandBuffer();
if (entityRef == null || commandBuffer == null) {
return;
}
PlayerRef playerRef = commandBuffer.getComponent(entityRef, PlayerRef.getComponentType());
if (playerRef == null) {
// Clicker is not a player (mob, arrow, …) — ignore.
return;
}
BlockPosition bp = context.getTargetBlock();
if (bp == null) {
playerRef.sendMessage(Message.raw("[gravityflip] No block targeted."));
return;
}
UUID uuid = playerRef.getUuid();
if (type == InteractionType.Primary) {
store.setPos1(uuid, bp.x, bp.y, bp.z);
playerRef.sendMessage(Message.raw(
"[gravityflip] pos1 set: (%d, %d, %d)".formatted(bp.x, bp.y, bp.z)));
} else if (type == InteractionType.Secondary) {
store.setPos2(uuid, bp.x, bp.y, bp.z);
playerRef.sendMessage(Message.raw(
"[gravityflip] pos2 set: (%d, %d, %d)".formatted(bp.x, bp.y, bp.z)));
}
// Any other InteractionType (Ability1, Pick, Equipped, …) is ignored.
}
}