feat: Add spectator functionality and late join support for minigames

This commit is contained in:
2026-06-21 12:12:38 -07:00
parent 511d0e8d4b
commit cefe008cae
11 changed files with 331 additions and 77 deletions
@@ -23,6 +23,12 @@ public final class HytaleAdapters {
String getDisplayName(String playerId); String getDisplayName(String playerId);
void setGameMode(String playerId, GameMode gameMode); void setGameMode(String playerId, GameMode gameMode);
void giveItem(String playerId, String itemId, int amount); void giveItem(String playerId, String itemId, int amount);
/** Makes the player a true spectator: invulnerable, intangible (noclip), flying, and hidden from adventure players. */
void applySpectatorState(String playerId);
/** Reverts {@link #applySpectatorState}: removes invulnerability/intangibility/hiding and disables flight. */
void clearSpectatorState(String playerId);
} }
public interface WorldAdapter { public interface WorldAdapter {
@@ -18,6 +18,10 @@ import com.hypixel.hytale.protocol.packets.inventory.UpdatePlayerInventory;
import com.hypixel.hytale.server.core.inventory.InventoryComponent; import com.hypixel.hytale.server.core.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.InventoryUtils; import com.hypixel.hytale.server.core.inventory.InventoryUtils;
import com.hypixel.hytale.server.core.inventory.ItemStack; import com.hypixel.hytale.server.core.inventory.ItemStack;
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
import com.hypixel.hytale.server.core.modules.entity.component.HiddenFromAdventurePlayers;
import com.hypixel.hytale.server.core.modules.entity.component.Intangible;
import com.hypixel.hytale.server.core.modules.entity.component.Invulnerable;
import com.hypixel.hytale.server.core.modules.entity.teleport.Teleport; import com.hypixel.hytale.server.core.modules.entity.teleport.Teleport;
import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.Universe;
@@ -84,6 +88,48 @@ public final class HytaleServerAdapters implements
}); });
} }
@Override
public void applySpectatorState(String playerId) {
withPlayerRefAsync(playerId, ref -> {
var store = ref.getStore();
store.putComponent(ref, Invulnerable.getComponentType(), Invulnerable.INSTANCE);
store.putComponent(ref, Intangible.getComponentType(), Intangible.INSTANCE);
store.putComponent(ref, HiddenFromAdventurePlayers.getComponentType(), HiddenFromAdventurePlayers.INSTANCE);
setFlying(store, ref, true);
return null;
});
}
@Override
public void clearSpectatorState(String playerId) {
withPlayerRefAsync(playerId, ref -> {
var store = ref.getStore();
store.tryRemoveComponent(ref, Invulnerable.getComponentType());
store.tryRemoveComponent(ref, Intangible.getComponentType());
store.tryRemoveComponent(ref, HiddenFromAdventurePlayers.getComponentType());
setFlying(store, ref, false);
return null;
});
}
/** Toggles the player's flight capability, mirroring {@link Player#setGameMode}'s MovementManager handling. */
private void setFlying(com.hypixel.hytale.component.Store<EntityStore> store, Ref<EntityStore> ref, boolean canFly) {
MovementManager movement = store.getComponent(ref, MovementManager.getComponentType());
if (movement == null) {
return;
}
if (movement.getDefaultSettings() != null) {
movement.getDefaultSettings().canFly = canFly;
}
if (movement.getSettings() != null) {
movement.getSettings().canFly = canFly;
}
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef != null) {
movement.update(playerRef.getPacketHandler());
}
}
@Override @Override
public void giveItem(String playerId, String itemId, int amount) { public void giveItem(String playerId, String itemId, int amount) {
if (itemId == null || itemId.isBlank() || amount <= 0) return; if (itemId == null || itemId.isBlank() || amount <= 0) return;
@@ -1,5 +1,6 @@
package net.kewwbec.minigames.command.sub; package net.kewwbec.minigames.command.sub;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.command.system.CommandContext; import com.hypixel.hytale.server.core.command.system.CommandContext;
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
@@ -7,8 +8,12 @@ import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredAr
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase; import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.minigames.model.MinigameDefinition;
import net.kewwbec.minigames.runtime.MinigameRuntime;
import net.kewwbec.minigames.service.MinigameQueueService; import net.kewwbec.minigames.service.MinigameQueueService;
import net.kewwbec.minigames.service.MinigameServices; import net.kewwbec.minigames.service.MinigameServices;
import net.kewwbec.minigames.volume.effect.SetPlayerLoadoutEffect;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
@@ -40,7 +45,17 @@ public final class MinigameJoinCommand extends CommandBase {
return; return;
} }
MinigameQueueService.JoinResult result = services.queue().join(defOpt.get(), target.getUuid().toString()); String playerId = target.getUuid().toString();
MinigameDefinition def = defOpt.get();
// A game is already running for this minigame: route to late-join / spectate instead of queueing.
var runtimes = services.runtime().runtimesForMinigame(id);
if (!runtimes.isEmpty()) {
joinRunning(ctx, def, runtimes.iterator().next(), target, playerId, id);
return;
}
MinigameQueueService.JoinResult result = services.queue().join(def, playerId);
switch (result) { switch (result) {
case JOINED -> ctx.sendMessage(Message.translation("minigames.command.join.success").param("id", id)); case JOINED -> ctx.sendMessage(Message.translation("minigames.command.join.success").param("id", id));
case ALREADY_QUEUED -> ctx.sendMessage(Message.translation("minigames.command.join.already_queued").param("id", id)); case ALREADY_QUEUED -> ctx.sendMessage(Message.translation("minigames.command.join.already_queued").param("id", id));
@@ -48,4 +63,52 @@ public final class MinigameJoinCommand extends CommandBase {
case DISABLED -> ctx.sendMessage(Message.translation("minigames.queue.disabled").param("id", id)); case DISABLED -> ctx.sendMessage(Message.translation("minigames.queue.disabled").param("id", id));
} }
} }
private void joinRunning(CommandContext ctx, MinigameDefinition def, MinigameRuntime runtime,
PlayerRef target, String playerId, String id) {
if (services.runtime().runtimeForPlayer(playerId).isPresent()) {
ctx.sendMessage(Message.translation("minigames.command.join.already_in_game").param("id", id));
return;
}
if (def.isAllowJoinMidgame()) {
MinigameQueueService.JoinResult result = services.runtime().onPlayerJoinMidgame(runtime, playerId);
switch (result) {
case JOINED -> {
spawnAndEquip(target, runtime, playerId);
ctx.sendMessage(Message.translation("minigames.command.join.midgame").param("id", id));
}
case QUEUE_FULL -> ctx.sendMessage(Message.translation("minigames.queue.full").param("id", id));
default -> ctx.sendMessage(Message.translation("minigames.command.join.already_in_game").param("id", id));
}
return;
}
if (def.isAllowSpectators()) {
services.runtime().onPlayerSpectate(runtime, playerId);
SpectatorArenaSupport.teleportToArena(services, target, runtime);
ctx.sendMessage(Message.translation("minigames.command.join.as_spectator").param("id", id));
return;
}
ctx.sendMessage(Message.translation("minigames.command.join.in_progress").param("id", id));
}
/** Late joiners miss the start/round volume triggers, so teleport + equip them explicitly. */
private void spawnAndEquip(PlayerRef target, MinigameRuntime runtime, String playerId) {
var player = runtime.players().get(playerId);
var ref = target.getReference();
Store<EntityStore> store = ref != null && ref.isValid() ? ref.getStore() : null;
String dest = player != null ? services.maps().respawnDestination(store, runtime, player, null) : "";
if (!dest.isBlank()) {
services.adapters().teleport(playerId, dest);
} else {
// No team / checkpoint spawn resolvable (e.g. FFA): fall back to the main arena volume.
SpectatorArenaSupport.teleportToArena(services, target, runtime);
}
// Grant the definition's default kit (StartItems) — mirrors a blank-loadout SetPlayerLoadout.
SetPlayerLoadoutEffect.grantLoadout(services, runtime, playerId, null);
}
} }
@@ -1,9 +1,5 @@
package net.kewwbec.minigames.command.sub; package net.kewwbec.minigames.command.sub;
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.command.system.CommandContext; import com.hypixel.hytale.server.core.command.system.CommandContext;
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
@@ -11,16 +7,10 @@ import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredAr
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase; import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.minigames.model.PlayerMinigameState;
import net.kewwbec.minigames.runtime.MinigameRuntime; import net.kewwbec.minigames.runtime.MinigameRuntime;
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
import net.kewwbec.minigames.service.MinigameServices; import net.kewwbec.minigames.service.MinigameServices;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
public final class MinigameSpectateCommand extends CommandBase { public final class MinigameSpectateCommand extends CommandBase {
private final MinigameServices services; private final MinigameServices services;
@@ -67,52 +57,9 @@ public final class MinigameSpectateCommand extends CommandBase {
} }
MinigameRuntime runtime = runtimes.iterator().next(); MinigameRuntime runtime = runtimes.iterator().next();
PlayerMinigameState state = runtime.players().computeIfAbsent(playerId, services.runtime().onPlayerSpectate(runtime, playerId);
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
state.status(PlayerStatus.SPECTATOR);
runtime.spectators().add(playerId);
teleportToArena(target, runtime); SpectatorArenaSupport.teleportToArena(services, target, runtime);
ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id)); ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id));
} }
/** Best effort: drop the spectator at the arena's MainArena volume if one is visible from their world. */
private void teleportToArena(PlayerRef target, MinigameRuntime runtime) {
var ref = target.getReference();
if (ref == null || !ref.isValid()) {
return;
}
Store<EntityStore> store = ref.getStore();
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
TriggerVolumeManager manager = tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null;
if (manager == null) {
return;
}
VolumeEntry arena = findMainArena(manager, runtime);
if (arena == null) {
return;
}
var pos = arena.getPosition();
String destination = pos.x() + "," + pos.y() + "," + pos.z();
services.adapters().teleport(target.getUuid().toString(), destination);
}
@Nullable
private static VolumeEntry findMainArena(TriggerVolumeManager manager, MinigameRuntime runtime) {
for (VolumeEntry volume : manager.getVolumes()) {
var tags = volume.getRawTags();
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
continue;
}
if (!MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(tags.get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) {
continue;
}
String mapId = tags.getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "");
if (!runtime.mapId().isBlank() && !mapId.isBlank() && !runtime.mapId().equals(mapId)) {
continue;
}
return volume;
}
return null;
}
} }
@@ -0,0 +1,59 @@
package net.kewwbec.minigames.command.sub;
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.minigames.runtime.MinigameRuntime;
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
import net.kewwbec.minigames.service.MinigameServices;
import javax.annotation.Nullable;
/** Shared helper for dropping a player at a runtime's MainArena volume (used by spectate + late join). */
final class SpectatorArenaSupport {
private SpectatorArenaSupport() {
}
/** Best effort: drop the player at the arena's MainArena volume if one is visible from their world. */
static void teleportToArena(MinigameServices services, PlayerRef target, MinigameRuntime runtime) {
var ref = target.getReference();
if (ref == null || !ref.isValid()) {
return;
}
Store<EntityStore> store = ref.getStore();
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
TriggerVolumeManager manager = tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null;
if (manager == null) {
return;
}
VolumeEntry arena = findMainArena(manager, runtime);
if (arena == null) {
return;
}
var pos = arena.getPosition();
String destination = pos.x() + "," + pos.y() + "," + pos.z();
services.adapters().teleport(target.getUuid().toString(), destination);
}
@Nullable
private static VolumeEntry findMainArena(TriggerVolumeManager manager, MinigameRuntime runtime) {
for (VolumeEntry volume : manager.getVolumes()) {
var tags = volume.getRawTags();
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
continue;
}
if (!MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(tags.get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) {
continue;
}
String mapId = tags.getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "");
if (!runtime.mapId().isBlank() && !mapId.isBlank() && !runtime.mapId().equals(mapId)) {
continue;
}
return volume;
}
return null;
}
}
@@ -357,6 +357,8 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
} }
if (playerAdapter != null) { if (playerAdapter != null) {
// A reactivated spectator must lose the spectator presentation before becoming playable.
playerAdapter.clearSpectatorState(playerId);
playerAdapter.setGameMode(playerId, def.getRequiredGameMode()); playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
} }
if (def.isShowHud() && hudService != null) { if (def.isShowHud() && hudService != null) {
@@ -364,12 +366,72 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
} }
} }
@Override
public void onPlayerSpectate(MinigameRuntime runtime, String playerId) {
var state = runtime.players().computeIfAbsent(playerId,
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
state.status(PlayerStatus.SPECTATOR);
runtime.spectators().add(playerId);
runtime.eliminatedPlayers().remove(playerId);
if (playerAdapter != null) {
playerAdapter.applySpectatorState(playerId);
}
}
@Override
public MinigameQueueService.JoinResult onPlayerJoinMidgame(MinigameRuntime runtime, String playerId) {
var existing = runtime.players().get(playerId);
if (existing != null && existing.status() != PlayerStatus.LEFT && existing.status() != PlayerStatus.SPECTATOR) {
return MinigameQueueService.JoinResult.ALREADY_QUEUED;
}
var def = runtime.definition();
long participants = runtime.players().values().stream()
.filter(p -> p.status() != PlayerStatus.LEFT && p.status() != PlayerStatus.SPECTATOR)
.count();
if (participants >= def.getMaxPlayers()) {
return MinigameQueueService.JoinResult.QUEUE_FULL;
}
var player = runtime.players().computeIfAbsent(playerId,
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
player.lives(Math.max(0, def.getDefaultPlayerLives()));
player.status(PlayerStatus.ACTIVE);
runtime.spectators().remove(playerId);
runtime.eliminatedPlayers().remove(playerId);
if (def.getTeamMode() == TeamMode.TEAMS) {
assignToSmallestTeam(runtime, playerId);
}
onPlayerActivated(runtime, playerId);
var payload = new HashMap<>(eventPayload(runtime));
payload.put("player_id", playerId);
dispatch(new MinigameEvent("on_player_joined_midgame", runtime.minigameId(), Map.copyOf(payload)));
return MinigameQueueService.JoinResult.JOINED;
}
/** Places a late joiner on the team with the fewest players (vs. setupTeams which reshuffles everyone). */
private void assignToSmallestTeam(MinigameRuntime runtime, String playerId) {
List<TeamState> teams = ensureTeams(runtime);
if (teams.isEmpty()) {
return;
}
TeamState smallest = teams.get(0);
for (TeamState team : teams) {
if (team.players().size() < smallest.players().size()) {
smallest = team;
}
}
runtime.players().get(playerId).teamId(smallest.teamId());
smallest.players().add(playerId);
}
@Override @Override
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) { public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
var player = runtime.players().get(playerId); var player = runtime.players().get(playerId);
if (player == null || player.status() == PlayerStatus.LEFT) { if (player == null || player.status() == PlayerStatus.LEFT) {
return; return;
} }
boolean wasSpectator = runtime.spectators().contains(playerId);
player.status(PlayerStatus.LEFT); player.status(PlayerStatus.LEFT);
if (player.teamId() != null) { if (player.teamId() != null) {
var team = runtime.teams().get(player.teamId()); var team = runtime.teams().get(player.teamId());
@@ -378,6 +440,9 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
} }
} }
runtime.spectators().remove(playerId); runtime.spectators().remove(playerId);
if (wasSpectator && playerAdapter != null) {
playerAdapter.clearSpectatorState(playerId);
}
if (hudService != null) { if (hudService != null) {
hudService.removeForPlayer(playerId); hudService.removeForPlayer(playerId);
} }
@@ -573,6 +638,12 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
// Explicitly zero scores before clearing maps so any retained state object is not left with stale points. // Explicitly zero scores before clearing maps so any retained state object is not left with stale points.
runtime.players().values().forEach(player -> player.score(0)); runtime.players().values().forEach(player -> player.score(0));
runtime.teams().values().forEach(team -> team.score(0)); runtime.teams().values().forEach(team -> team.score(0));
// Strip spectator presentation before dropping the set so invuln/noclip/flight/hidden don't persist.
if (playerAdapter != null) {
for (String spectatorId : runtime.spectators()) {
playerAdapter.clearSpectatorState(spectatorId);
}
}
runtime.players().clear(); runtime.players().clear();
runtime.teams().clear(); runtime.teams().clear();
runtime.objectives().clear(); runtime.objectives().clear();
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.runtime;
import net.kewwbec.minigames.model.MinigameDefinition; import net.kewwbec.minigames.model.MinigameDefinition;
import net.kewwbec.minigames.model.MinigameMapCandidate; import net.kewwbec.minigames.model.MinigameMapCandidate;
import net.kewwbec.minigames.service.MinigameQueueService.JoinResult;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.util.Collection; import java.util.Collection;
@@ -42,6 +43,21 @@ public interface MinigameRuntimeService {
void onPlayerActivated(MinigameRuntime runtime, String playerId); void onPlayerActivated(MinigameRuntime runtime, String playerId);
/**
* Makes the player a spectator of the runtime: sets {@code SPECTATOR} status, registers them in
* {@code runtime.spectators()}, and applies the spectator presentation (invulnerable / noclip /
* flight / hidden). The caller is responsible for the initial teleport.
*/
void onPlayerSpectate(MinigameRuntime runtime, String playerId);
/**
* Adds a brand-new participant to an already-running runtime as {@code ACTIVE}. Honors the
* runtime's player cap and (for team modes) balances them onto the smallest team. Does not
* teleport or equip the player — the caller handles spawn + loadout. Returns {@code JOINED},
* {@code ALREADY_QUEUED} (already in a game) or {@code QUEUE_FULL}.
*/
JoinResult onPlayerJoinMidgame(MinigameRuntime runtime, String playerId);
/** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */ /** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */
void setupTeams(MinigameRuntime runtime); void setupTeams(MinigameRuntime runtime);
@@ -41,6 +41,16 @@ public final class MinigameRuleEnforcementSystems {
return services.runtime().runtimeForPlayer(player.getUuid().toString()).orElse(null); return services.runtime().runtimeForPlayer(player.getUuid().toString()).orElse(null);
} }
/** True when the player behind {@code ref} is a spectator of their current runtime. */
private static boolean isSpectator(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
if (player == null) {
return false;
}
MinigameRuntime runtime = runtimeFor(store, ref);
return runtime != null && runtime.spectators().contains(player.getUuid().toString());
}
/** Cancels player-vs-player damage when either party's minigame disables PvP. */ /** Cancels player-vs-player damage when either party's minigame disables PvP. */
public static final class PvpGuard extends DamageEventSystem { public static final class PvpGuard extends DamageEventSystem {
@Nullable @Nullable
@@ -75,6 +85,11 @@ public final class MinigameRuleEnforcementSystems {
} }
Ref<EntityStore> victimRef = archetypeChunk.getReferenceTo(index); Ref<EntityStore> victimRef = archetypeChunk.getReferenceTo(index);
// Spectators can neither be hit nor deal damage, regardless of the definition's PvP flag.
if (isSpectator(store, victimRef) || isSpectator(store, attackerRef)) {
damage.setCancelled(true);
return;
}
MinigameRuntime victimRuntime = runtimeFor(store, victimRef); MinigameRuntime victimRuntime = runtimeFor(store, victimRef);
if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) { if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) {
damage.setCancelled(true); damage.setCancelled(true);
@@ -110,7 +125,12 @@ public final class MinigameRuleEnforcementSystems {
if (event.isCancelled()) { if (event.isCancelled()) {
return; return;
} }
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index)); Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);
if (isSpectator(store, ref)) {
event.setCancelled(true);
return;
}
MinigameRuntime runtime = runtimeFor(store, ref);
if (runtime != null && !runtime.definition().isAllowBlockBreaking()) { if (runtime != null && !runtime.definition().isAllowBlockBreaking()) {
event.setCancelled(true); event.setCancelled(true);
} }
@@ -140,7 +160,12 @@ public final class MinigameRuleEnforcementSystems {
if (event.isCancelled()) { if (event.isCancelled()) {
return; return;
} }
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index)); Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);
if (isSpectator(store, ref)) {
event.setCancelled(true);
return;
}
MinigameRuntime runtime = runtimeFor(store, ref);
if (runtime != null && !runtime.definition().isAllowBlockPlacing()) { if (runtime != null && !runtime.definition().isAllowBlockPlacing()) {
event.setCancelled(true); event.setCancelled(true);
} }
@@ -6,6 +6,7 @@ import com.hypixel.hytale.codec.KeyedCodec;
import com.hypixel.hytale.codec.builder.BuilderCodec; import com.hypixel.hytale.codec.builder.BuilderCodec;
import net.kewwbec.minigames.model.ItemStackConfig; import net.kewwbec.minigames.model.ItemStackConfig;
import net.kewwbec.minigames.model.LoadoutConfig; import net.kewwbec.minigames.model.LoadoutConfig;
import net.kewwbec.minigames.service.MinigameServices;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
@@ -38,8 +39,7 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
return; return;
} }
var player = rt.get().players().get(id); if (rt.get().players().get(id) == null) {
if (player == null) {
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
return; return;
} }
@@ -50,44 +50,53 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
return; return;
} }
// Items are only ever granted through this effect — nothing is given automatically debugMessage(context, TYPE_ID, "FIRED player='" + id + "' " + grantLoadout(services, rt.get(), id, loadoutId));
// on activation. A blank LoadoutId grants the definition's StartItems instead. }
/**
* Grants a loadout's items to a player already present in {@code runtime}. A blank {@code loadoutId}
* grants the definition's StartItems. Items are only ever granted through here — never automatically
* on activation. Returns a short outcome string for logging. Reused by mid-game late-join equipping.
*/
public static String grantLoadout(@Nonnull MinigameServices services, @Nonnull net.kewwbec.minigames.runtime.MinigameRuntime runtime, @Nonnull String playerId, String loadoutId) {
var player = runtime.players().get(playerId);
if (player == null) {
return "SKIPPED (not in runtime)";
}
if (loadoutId == null || loadoutId.isBlank()) { if (loadoutId == null || loadoutId.isBlank()) {
for (ItemStackConfig item : rt.get().definition().getStartItems()) { for (ItemStackConfig item : runtime.definition().getStartItems()) {
if (item != null) { if (item != null) {
services.adapters().giveItem(id, item.getItemId(), item.getAmount()); services.adapters().giveItem(playerId, item.getItemId(), item.getAmount());
} }
} }
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' granted StartItems"); return "granted StartItems";
return;
} }
LoadoutConfig selected = null; LoadoutConfig selected = null;
for (LoadoutConfig loadout : rt.get().definition().getStartLoadouts()) { for (LoadoutConfig loadout : runtime.definition().getStartLoadouts()) {
if (loadout != null && loadoutId.equals(loadout.getId())) { if (loadout != null && loadoutId.equals(loadout.getId())) {
selected = loadout; selected = loadout;
break; break;
} }
} }
if (selected == null) { if (selected == null) {
debugMessage(context, TYPE_ID, "SKIPPED (loadout='" + loadoutId + "' not found in definition)"); return "SKIPPED (loadout='" + loadoutId + "' not found in definition)";
return;
} }
String previous = player.loadoutId(); String previous = player.loadoutId();
player.loadoutId(loadoutId); player.loadoutId(loadoutId);
if (selected.isPlayerCustomizable() && services.menus() != null) { if (selected.isPlayerCustomizable() && services.menus() != null) {
services.menus().requestLoadoutSelection(id, rt.get()); services.menus().requestLoadoutSelection(playerId, runtime);
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' opened loadout selection"); return "opened loadout selection";
return;
} }
for (ItemStackConfig item : selected.getItems()) { for (ItemStackConfig item : selected.getItems()) {
if (item != null) { if (item != null) {
services.adapters().giveItem(id, item.getItemId(), item.getAmount()); services.adapters().giveItem(playerId, item.getItemId(), item.getAmount());
} }
} }
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' loadout " + previous + "->" + loadoutId); return "loadout " + previous + "->" + loadoutId;
} }
} }
@@ -38,21 +38,29 @@ public final class SetPlayerStatusEffect extends MinigameRuntimeEffect {
var player = rt.get().players().get(id); var player = rt.get().players().get(id);
if (player != null) { if (player != null) {
PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE; PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE;
var svc = services();
boolean wasSpectator = rt.get().spectators().contains(id);
player.status(resolvedStatus); player.status(resolvedStatus);
if (resolvedStatus == PlayerStatus.SPECTATOR) { if (resolvedStatus == PlayerStatus.SPECTATOR) {
rt.get().spectators().add(id); if (svc != null) {
svc.runtime().onPlayerSpectate(rt.get(), id);
} else {
rt.get().spectators().add(id);
}
} else { } else {
rt.get().spectators().remove(id); rt.get().spectators().remove(id);
// ACTIVE clears spectator presentation itself (onPlayerActivated); handle other exits here.
if (wasSpectator && resolvedStatus != PlayerStatus.ACTIVE && svc != null) {
svc.adapters().clearSpectatorState(id);
}
} }
if (resolvedStatus == PlayerStatus.ACTIVE) { if (resolvedStatus == PlayerStatus.ACTIVE) {
rt.get().eliminatedPlayers().remove(id); rt.get().eliminatedPlayers().remove(id);
var svc = services();
if (svc != null) { if (svc != null) {
svc.runtime().onPlayerActivated(rt.get(), id); svc.runtime().onPlayerActivated(rt.get(), id);
} }
} else if (resolvedStatus == PlayerStatus.ELIMINATED) { } else if (resolvedStatus == PlayerStatus.ELIMINATED) {
rt.get().eliminatedPlayers().add(id); rt.get().eliminatedPlayers().add(id);
var svc = services();
if (svc != null) { if (svc != null) {
svc.runtime().dispatch(rt.get(), "on_player_eliminated", Map.of("player_id", id)); svc.runtime().dispatch(rt.get(), "on_player_eliminated", Map.of("player_id", id));
} }
@@ -162,6 +162,10 @@ command.edit.error.unknown_property = Unknown property '{property}'. Valid prope
command.edit.error.save_failed = Could not save '{id}': {error} command.edit.error.save_failed = Could not save '{id}': {error}
command.join.success = Joined the queue for {id}. command.join.success = Joined the queue for {id}.
command.join.already_queued = You are already queued for {id}. command.join.already_queued = You are already queued for {id}.
command.join.already_in_game = You are already in a minigame.
command.join.midgame = Joined {id} in progress.
command.join.as_spectator = {id} is in progress — joined as a spectator.
command.join.in_progress = {id} has already started and does not allow joining or spectating.
command.leave.success = You left {id}. command.leave.success = You left {id}.
command.leave.not_in_game = You are not in a minigame. command.leave.not_in_game = You are not in a minigame.
command.spectate.success = Now spectating {id}. command.spectate.success = Now spectating {id}.