diff --git a/build.gradle b/build.gradle index 07b1242..531cddb 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,26 @@ repositories { maven { url = "https://maven.gegy.dev/" } } +sourceSets { + gametest { + compileClasspath += main.compileClasspath + runtimeClasspath += main.runtimeClasspath + } +} + +loom { + runs { + gametest { + server() + name "Game Test" + vmArg "-Dfabric-api.gametest" + vmArg "-Dfabric-api.gametest.report-file=${project.buildDir}/junit.xml" + runDir "build/gametest" + source sourceSets.gametest + } + } +} + dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" @@ -24,6 +44,8 @@ dependencies { modImplementation 'xyz.nucleoid:plasmid:0.6.3-SNAPSHOT+1.21.4' modImplementation include('xyz.nucleoid:more-codecs:0.3.5+1.21.2') + + gametestImplementation sourceSets.main.output } processResources { @@ -36,7 +58,7 @@ processResources { tasks.withType(JavaCompile).configureEach { it.options.encoding = "UTF-8" - it.options.release = 16 + it.options.release = 21 } java { diff --git a/src/gametest/java/xyz/nucleoid/parties/test/CommandAssertion.java b/src/gametest/java/xyz/nucleoid/parties/test/CommandAssertion.java new file mode 100644 index 0000000..d8b8000 --- /dev/null +++ b/src/gametest/java/xyz/nucleoid/parties/test/CommandAssertion.java @@ -0,0 +1,99 @@ +package xyz.nucleoid.parties.test; + +import com.mojang.brigadier.Command; +import net.minecraft.SharedConstants; +import net.minecraft.test.TestContext; +import org.apache.commons.lang3.mutable.MutableInt; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public class CommandAssertion { + private final TestContext context; + + private final TrackingFakePlayerEntity player; + private final String command; + + private final Map> recipientsToExpectedMessages = new HashMap<>(); + + private CommandAssertion(TestContext context, TrackingFakePlayerEntity player, Set players, String command) { + this.context = Objects.requireNonNull(context); + this.player = Objects.requireNonNull(player); + this.command = Objects.requireNonNull(command); + + if (!players.contains(player)) { + throw new IllegalArgumentException("player must be in players set"); + } + + for (var recipient : players) { + this.recipientsToExpectedMessages.put(recipient, new ArrayList<>()); + } + } + + public CommandAssertion expectFeedback(String feedback) { + return this.expectMessage(feedback, this.player); + } + + public CommandAssertion expectMessage(String message, TrackingFakePlayerEntity... recipients) { + Objects.requireNonNull(message); + + for (var recipient : recipients) { + var messages = this.recipientsToExpectedMessages.get(recipient); + + if (messages == null) { + throw new IllegalArgumentException("recipient must be in players set"); + } + + messages.add(message); + } + + return this; + } + + public void executeSuccess() { + this.execute(Command.SINGLE_SUCCESS); + } + + public void execute(int expectedSuccessCount) { + var successCount = new MutableInt(); + + var source = player.getCommandSource() + .withLevel(4) + .withReturnValueConsumer((successful, successCountx) -> { + successCount.setValue(successCountx); + }); + + withDevelopment(() -> { + this.player.getServer().getCommandManager().executeWithPrefix(source, this.command); + }); + + for (var entry : this.recipientsToExpectedMessages.entrySet()) { + var recipient = entry.getKey(); + + var expectedMessages = entry.getValue(); + var actualMessages = recipient.consumeMessages(); + + var name = recipient == this.player ? "feedback" : "messages to " + recipient.getNameForScoreboard(); + this.context.assertEquals(actualMessages, expectedMessages, "'" + command + "' " + name); + } + + this.context.assertEquals(successCount.intValue(), expectedSuccessCount, "'" + command + "' success count"); + } + + public static CommandAssertion builder(TestContext context, TrackingFakePlayerEntity player, Set players, String command) { + return new CommandAssertion(context, player, players, command); + } + + private static void withDevelopment(Runnable runnable) { + boolean stored = SharedConstants.isDevelopment; + SharedConstants.isDevelopment = true; + + runnable.run(); + + SharedConstants.isDevelopment = stored; + } +} diff --git a/src/gametest/java/xyz/nucleoid/parties/test/GamePartiesTest.java b/src/gametest/java/xyz/nucleoid/parties/test/GamePartiesTest.java new file mode 100644 index 0000000..f74d6e0 --- /dev/null +++ b/src/gametest/java/xyz/nucleoid/parties/test/GamePartiesTest.java @@ -0,0 +1,189 @@ +package xyz.nucleoid.parties.test; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService; +import net.fabricmc.fabric.api.gametest.v1.FabricGameTest; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.test.GameTest; +import net.minecraft.test.TestContext; +import net.minecraft.util.ApiServices; +import net.minecraft.util.Uuids; +import xyz.nucleoid.parties.PartyManager; +import xyz.nucleoid.parties.test.mixin.MinecraftServerAccessor; +import xyz.nucleoid.parties.test.mixin.PlayerManagerAccessor; + +import java.net.Proxy; +import java.util.Set; +import java.util.UUID; + +public class GamePartiesTest { + @GameTest(templateName = FabricGameTest.EMPTY_STRUCTURE) + public void test(TestContext context) { + var server = context.getWorld().getServer(); + + var gameDir = FabricLoader.getInstance().getGameDir().toFile(); + + var apiServices = ApiServices.create(new YggdrasilAuthenticationService(Proxy.NO_PROXY), gameDir); + ((MinecraftServerAccessor) (Object) server).setApiServices(apiServices); + + var partyUuid = UUID.fromString("10a05e00-5992-448c-9bed-a14cb2a7a909"); + + var player1 = createFakePlayer(context, 1); + var player2 = createFakePlayer(context, 2); + var player3 = createFakePlayer(context, 3); + var player4 = createFakePlayer(context, 4); + var player5 = createFakePlayer(context, 5); + + var players = Set.of(player1, player2, player3, player4, player5); + + CommandAssertion.builder(context, player1, players, "/party list") + .expectFeedback("There are no parties!") + .execute(0); + + CommandAssertion.builder(context, player1, players, "/party leave") + .expectFeedback("You do not control any party!") + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party kick Player2") + .expectFeedback("You do not control any party!") + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party transfer Player2") + .expectFeedback("You do not control any party!") + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party invite Player1") + .expectFeedback("Cannot invite yourself to the party!") + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party invite Player2") + .expectFeedback("Invited Player2 to the party") + .expectMessage("You have been invited to join Player1's party! Click here to join", player2) + .executeSuccess(); + + var partyManager = PartyManager.get(server); + partyManager.getAllParties().forEach(party -> party.setUuid(partyUuid)); + + CommandAssertion.builder(context, player1, players, "/party list") + .expectFeedback(" - Party [10a05e00-5992-448c-9bed-a14cb2a7a909]") + .expectFeedback(" - Player1 (owner)") + .expectFeedback(" - Player2 (pending)") + .execute(1); + + CommandAssertion.builder(context, player2, players, "/party accept Player3") + .expectFeedback("You are not invited to this party!") + .executeSuccess(); + + CommandAssertion.builder(context, player2, players, "/party accept Player1") + .expectMessage("Player2 has joined the party!", player1, player2) + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party list") + .expectFeedback(" - Party [10a05e00-5992-448c-9bed-a14cb2a7a909]") + .expectFeedback(" - Player1 (owner)") + .expectFeedback(" - Player2") + .execute(1); + + CommandAssertion.builder(context, player1, players, "/party invite Player4") + .expectFeedback("Invited Player4 to the party") + .expectMessage("You have been invited to join Player1's party! Click here to join", player4) + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party invite Player5") + .expectFeedback("Invited Player5 to the party") + .expectMessage("You have been invited to join Player1's party! Click here to join", player5) + .executeSuccess(); + + CommandAssertion.builder(context, player2, players, "/party invite Player3") + .expectFeedback("You do not control this party!") + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party invite Player1") + .expectFeedback("Cannot invite yourself to the party!") + .executeSuccess(); + + CommandAssertion.builder(context, player2, players, "/party transfer Player3") + .expectFeedback("You do not control this party!") + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party transfer Player2") + .expectFeedback("Your party has been transferred to Player2") + .expectMessage("Player1's party has been transferred to you", player2) + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party list") + .expectFeedback(" - Party [10a05e00-5992-448c-9bed-a14cb2a7a909]") + .expectFeedback(" - Player2 (owner)") + .expectFeedback(" - Player1") + .expectFeedback(" - Player5 (pending)") + .expectFeedback(" - Player4 (pending)") + .execute(1); + + // Selectors are used for game profile arguments to bypass oddities with looking up game profiles from API services + + CommandAssertion.builder(context, player1, players, "/party kick @a[name=Player3,limit=1]") + .expectFeedback("You do not control this party!") + .executeSuccess(); + + CommandAssertion.builder(context, player2, players, "/party kick @a[name=Player1,limit=1]") + .expectFeedback("Player1 has been kicked from the party") + .expectMessage("You have been kicked from the party", player1) + .executeSuccess(); + + CommandAssertion.builder(context, player2, players, "/party kick @a[name=Player1,limit=1]") + .expectFeedback("Player1 is not in this party!") + .executeSuccess(); + + CommandAssertion.builder(context, player2, players, "/party kick @a[name=Player2,limit=1]") + .expectFeedback("Cannot remove yourself from the party!") + .executeSuccess(); + + CommandAssertion.builder(context, player2, players, "/party kick @a[name=Player3,limit=1]") + .expectFeedback("Player3 is not in this party!") + .executeSuccess(); + + CommandAssertion.builder(context, player4, players, "/party accept 10a05e00-5992-448c-9bed-a14cb2a7a909") + .expectMessage("Player4 has joined the party!", player2, player4) + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party list") + .expectFeedback(" - Party [10a05e00-5992-448c-9bed-a14cb2a7a909]") + .expectFeedback(" - Player2 (owner)") + .expectFeedback(" - Player4") + .expectFeedback(" - Player5 (pending)") + .execute(1); + + CommandAssertion.builder(context, player4, players, "/party leave") + .expectMessage("Player4 has left the party!", player2, player4) + .executeSuccess(); + + CommandAssertion.builder(context, player5, players, "/party leave") + .expectFeedback("You do not control any party!") + .executeSuccess(); + + CommandAssertion.builder(context, player1, players, "/party list") + .expectFeedback(" - Party [10a05e00-5992-448c-9bed-a14cb2a7a909]") + .expectFeedback(" - Player2 (owner)") + .expectFeedback(" - Player5 (pending)") + .execute(1); + + context.complete(); + } + + private static TrackingFakePlayerEntity createFakePlayer(TestContext context, int id) { + var world = context.getWorld(); + + var username = "Player" + id; + var uuid = Uuids.getOfflinePlayerUuid(username); + + var profile = new GameProfile(uuid, username); + var player = new TrackingFakePlayerEntity(world, profile); + + var playerManager = world.getServer().getPlayerManager(); + + playerManager.getPlayerList().add(player); + ((PlayerManagerAccessor) playerManager).getPlayerMap().put(uuid, player); + + return player; + } +} diff --git a/src/gametest/java/xyz/nucleoid/parties/test/TrackingFakePlayerEntity.java b/src/gametest/java/xyz/nucleoid/parties/test/TrackingFakePlayerEntity.java new file mode 100644 index 0000000..ceeebed --- /dev/null +++ b/src/gametest/java/xyz/nucleoid/parties/test/TrackingFakePlayerEntity.java @@ -0,0 +1,33 @@ +package xyz.nucleoid.parties.test; + +import com.mojang.authlib.GameProfile; +import net.fabricmc.fabric.api.entity.FakePlayer; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; + +import java.util.ArrayList; +import java.util.List; + +public class TrackingFakePlayerEntity extends FakePlayer { + private final List messages = new ArrayList<>(); + + protected TrackingFakePlayerEntity(ServerWorld world, GameProfile profile) { + super(world, profile); + } + + @Override + public void sendMessageToClient(Text message, boolean overlay) { + var string = message.getString(); + + for (var line : string.split("\n")) { + this.messages.add(line); + } + } + + public List consumeMessages() { + var result = new ArrayList<>(this.messages); + this.messages.clear(); + + return result; + } +} diff --git a/src/gametest/java/xyz/nucleoid/parties/test/mixin/MinecraftServerAccessor.java b/src/gametest/java/xyz/nucleoid/parties/test/mixin/MinecraftServerAccessor.java new file mode 100644 index 0000000..db9447d --- /dev/null +++ b/src/gametest/java/xyz/nucleoid/parties/test/mixin/MinecraftServerAccessor.java @@ -0,0 +1,14 @@ +package xyz.nucleoid.parties.test.mixin; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ApiServices; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(MinecraftServer.class) +public interface MinecraftServerAccessor { + @Accessor + @Mutable + void setApiServices(ApiServices apiServices); +} diff --git a/src/gametest/java/xyz/nucleoid/parties/test/mixin/PlayerManagerAccessor.java b/src/gametest/java/xyz/nucleoid/parties/test/mixin/PlayerManagerAccessor.java new file mode 100644 index 0000000..42d79d1 --- /dev/null +++ b/src/gametest/java/xyz/nucleoid/parties/test/mixin/PlayerManagerAccessor.java @@ -0,0 +1,15 @@ +package xyz.nucleoid.parties.test.mixin; + +import net.minecraft.server.PlayerManager; +import net.minecraft.server.network.ServerPlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import java.util.Map; +import java.util.UUID; + +@Mixin(PlayerManager.class) +public interface PlayerManagerAccessor { + @Accessor + Map getPlayerMap(); +} diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json new file mode 100644 index 0000000..b06dab8 --- /dev/null +++ b/src/gametest/resources/fabric.mod.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "id": "game_parties_testmod", + "version": "${version}", + "name": "Game Parties Testmod", + "entrypoints": { + "fabric-gametest": ["xyz.nucleoid.parties.test.GamePartiesTest"] + }, + "mixins": [ + "game_parties_testmod.mixins.json" + ] +} diff --git a/src/gametest/resources/game_parties_testmod.mixins.json b/src/gametest/resources/game_parties_testmod.mixins.json new file mode 100644 index 0000000..758c396 --- /dev/null +++ b/src/gametest/resources/game_parties_testmod.mixins.json @@ -0,0 +1,12 @@ +{ + "required": true, + "package": "xyz.nucleoid.parties.test.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PlayerManagerAccessor", + "MinecraftServerAccessor" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/java/xyz/nucleoid/parties/Party.java b/src/main/java/xyz/nucleoid/parties/Party.java index 086ff02..91f00d4 100644 --- a/src/main/java/xyz/nucleoid/parties/Party.java +++ b/src/main/java/xyz/nucleoid/parties/Party.java @@ -1,76 +1,86 @@ package xyz.nucleoid.parties; +import com.google.common.annotations.VisibleForTesting; +import it.unimi.dsi.fastutil.objects.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectArrayList; -import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; import net.minecraft.server.MinecraftServer; import xyz.nucleoid.plasmid.api.game.player.MutablePlayerSet; import xyz.nucleoid.plasmid.api.util.PlayerRef; import java.util.List; -import java.util.Set; import java.util.UUID; +import java.util.function.Predicate; public final class Party { - private PlayerRef owner; - - private final List members = new ObjectArrayList<>(); - private final Set pendingMembers = new ObjectOpenHashSet<>(); - private final MutablePlayerSet memberPlayers; - private final UUID uuid; + private final Object2ObjectMap participantToParty; + private final Object2ObjectMap members = new Object2ObjectOpenHashMap<>(); + + private UUID uuid; - Party(MinecraftServer server, PlayerRef owner) { + Party(MinecraftServer server, Object2ObjectMap participantToParty, PlayerRef owner) { this.memberPlayers = new MutablePlayerSet(server); - this.setOwner(owner); + + this.participantToParty = participantToParty; + this.putMember(owner, PartyMember.Type.OWNER); this.uuid = UUID.randomUUID(); } - void setOwner(PlayerRef owner) { - this.owner = owner; - this.add(owner); + PartyMember getMember(PlayerRef player) { + return this.members.get(player); } - boolean invite(PlayerRef player) { - if (this.memberPlayers.contains(player)) { - return false; - } - return this.pendingMembers.add(player); + private boolean matches(PlayerRef player, Predicate predicate) { + var member = this.members.get(player); + return member != null && predicate.test(member); } - void add(PlayerRef player) { - if (this.memberPlayers.add(player)) { - this.members.add(player); - } + boolean isPending(PlayerRef player) { + return this.matches(player, PartyMember::isPending); } - boolean remove(PlayerRef player) { - if (this.memberPlayers.remove(player)) { - this.members.remove(player); - return true; - } - return this.pendingMembers.remove(player); + boolean isParticipant(PlayerRef player) { + return this.matches(player, PartyMember::isParticipant); } - boolean acceptInvite(PlayerRef player) { - if (this.pendingMembers.remove(player)) { - this.add(player); - return true; - } - return false; + boolean isOwner(PlayerRef player) { + return this.matches(player, PartyMember::isOwner); } - public boolean contains(PlayerRef player) { - return this.memberPlayers.contains(player); + void putMember(PlayerRef player, PartyMember.Type type) { + var member = new PartyMember(this, player, type); + + if (member.isParticipant()) { + var existingParty = this.participantToParty.put(player, this); + + if (existingParty != null && existingParty != this) { + throw new IllegalStateException("player is already in a party"); + } + } + + this.members.put(player, member); + + if (member.isParticipant()) { + this.memberPlayers.add(player); + } } - public boolean isOwner(PlayerRef from) { - return from.equals(this.owner); + PartyMember removeMember(PlayerRef player) { + var member = this.members.remove(player); + this.memberPlayers.remove(player); + + if (member != null && member.isParticipant() && !this.participantToParty.remove(player, this)) { + throw new IllegalStateException("player is not in this party"); + } + + return member; } - public List getMembers() { - return this.members; + public List getMembers() { + return new ObjectArrayList<>(this.members.values()); } public MutablePlayerSet getMemberPlayers() { @@ -80,4 +90,14 @@ public MutablePlayerSet getMemberPlayers() { public UUID getUuid() { return this.uuid; } + + @VisibleForTesting + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + @Override + public String toString() { + return "Party{members=" + members + ", uuid=" + uuid + "}"; + } } diff --git a/src/main/java/xyz/nucleoid/parties/PartyCommand.java b/src/main/java/xyz/nucleoid/parties/PartyCommand.java index 7f96c67..9f9990e 100644 --- a/src/main/java/xyz/nucleoid/parties/PartyCommand.java +++ b/src/main/java/xyz/nucleoid/parties/PartyCommand.java @@ -9,6 +9,7 @@ import net.minecraft.command.argument.UuidArgumentType; import net.minecraft.screen.ScreenTexts; import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.text.MutableText; import net.minecraft.text.Text; import net.minecraft.util.Formatting; import xyz.nucleoid.plasmid.api.util.PlayerRef; @@ -98,18 +99,11 @@ private static int listParties(CommandContext ctx) throws C text.append(PartyTexts.listEntry(party.getUuid())); var members = new ArrayList<>(party.getMembers()); - members.sort(Comparator.comparing(PlayerRef::id)); + members.sort(null); for (var member : members) { text.append(ScreenTexts.LINE_BREAK); - - if (party.isOwner(member)) { - text.append(PartyTexts.listMemberEntryType(member, server, PartyTexts.listMemberTypeOwner().formatted(Formatting.LIGHT_PURPLE))); - } else if (party.contains(member)) { - text.append(PartyTexts.listMemberEntry(member, server)); - } else { - text.append(PartyTexts.listMemberEntryType(member, server, PartyTexts.listMemberTypePending().formatted(Formatting.GRAY))); - } + text.append(member.getListEntry(server)); } } @@ -127,17 +121,16 @@ private static int invitePlayer(CommandContext ctx) throws var partyManager = PartyManager.get(source.getServer()); var result = partyManager.invitePlayer(PlayerRef.of(owner), PlayerRef.of(player)); - if (result.isOk()) { + result.ifSuccessElse(party -> { source.sendFeedback(() -> PartyTexts.invitedSender(player).formatted(Formatting.GOLD), false); - var notification = PartyTexts.invitedReceiver(owner, result.party().getUuid()) + var notification = PartyTexts.invitedReceiver(owner, party.getUuid()) .formatted(Formatting.GOLD); player.sendMessage(notification, false); - } else { - var error = result.error(); + }, error -> { source.sendError(PartyTexts.displayError(error, player)); - } + }); return Command.SINGLE_SUCCESS; } @@ -151,20 +144,23 @@ private static int kickPlayer(CommandContext ctx) throws Co for (var profile : profiles) { var partyManager = PartyManager.get(source.getServer()); - var result = partyManager.kickPlayer(PlayerRef.of(owner), PlayerRef.of(profile)); - if (result.isOk()) { - var party = result.party(); + var ref = PlayerRef.of(profile); + var result = partyManager.kickPlayer(PlayerRef.of(owner), ref); + result.ifSuccessElse(party -> { + MutableText message; + var player = ref.getEntity(server); + + if (player == null) { + message = PartyTexts.kickedSender(Text.literal(profile.getName())); + } else { + message = PartyTexts.kickedSender(player.getDisplayName()); + player.sendMessage(PartyTexts.kickedReceiver().formatted(Formatting.RED), false); + } - var message = PartyTexts.kickedSender(owner); party.getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); - - PlayerRef.of(profile).ifOnline(server, player -> { - player.sendMessage(PartyTexts.kickedReceiver().formatted(Formatting.RED), false); - }); - } else { - var error = result.error(); - source.sendError(PartyTexts.displayError(error, profile.getName())); - } + }, error -> { + source.sendError(PartyTexts.displayError(error, Text.literal(profile.getName()))); + }); } return Command.SINGLE_SUCCESS; @@ -177,7 +173,7 @@ private static int transferToPlayer(CommandContext ctx) thr var partyManager = PartyManager.get(source.getServer()); var result = partyManager.transferParty(PlayerRef.of(oldOwner), PlayerRef.of(newOwner)); - if (result.isOk()) { + result.ifSuccessElse(party -> { source.sendFeedback( () -> PartyTexts.transferredSender(newOwner).formatted(Formatting.GOLD), false @@ -187,10 +183,9 @@ private static int transferToPlayer(CommandContext ctx) thr PartyTexts.transferredReceiver(oldOwner).formatted(Formatting.GOLD), false ); - } else { - var error = result.error(); + }, error -> { source.sendError(PartyTexts.displayError(error, newOwner)); - } + }); return Command.SINGLE_SUCCESS; } @@ -199,29 +194,29 @@ private static int acceptInviteByOwner(CommandContext ctx) var owner = EntityArgumentType.getPlayer(ctx, "owner"); var partyManager = PartyManager.get(ctx.getSource().getServer()); - return acceptInvite(ctx, partyManager.getOwnParty(PlayerRef.of(owner))); + return acceptInvite(ctx, partyManager.getOwnParty(PlayerRef.of(owner), PartyError.NOT_INVITED)); } private static int acceptInviteByUuid(CommandContext ctx) throws CommandSyntaxException { var uuid = UuidArgumentType.getUuid(ctx, "party"); var partyManager = PartyManager.get(ctx.getSource().getServer()); - return acceptInvite(ctx, partyManager.getParty(uuid)); + return acceptInvite(ctx, partyManager.getParty(uuid, PartyError.NOT_INVITED)); } - private static int acceptInvite(CommandContext ctx, Party party) throws CommandSyntaxException { + private static int acceptInvite(CommandContext ctx, PartyResult result) throws CommandSyntaxException { var source = ctx.getSource(); var player = source.getPlayer(); var partyManager = PartyManager.get(source.getServer()); - var result = partyManager.acceptInvite(PlayerRef.of(player), party); - if (result.isOk()) { - var message = PartyTexts.joinSuccess(player); - party.getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); - } else { - var error = result.error(); - source.sendError(PartyTexts.displayError(error, player)); - } + result + .map(party -> partyManager.acceptInvite(PlayerRef.of(player), party)) + .ifSuccessElse(party -> { + var message = PartyTexts.joinSuccess(player); + party.getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); + }, error -> { + source.sendError(PartyTexts.displayError(error, player)); + }); return Command.SINGLE_SUCCESS; } @@ -232,15 +227,13 @@ private static int leave(CommandContext ctx) throws Command var partyManager = PartyManager.get(source.getServer()); var result = partyManager.leaveParty(PlayerRef.of(player)); - if (result.isOk()) { - var party = result.party(); - - var message = PartyTexts.leaveSuccess(player); - party.getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); - } else { - var error = result.error(); + result.ifSuccessElse(party -> { + var message = PartyTexts.leaveSuccess(player).formatted(Formatting.GOLD); + party.getMemberPlayers().sendMessage(message); + player.sendMessage(message, false); + }, error -> { source.sendError(PartyTexts.displayError(error, player)); - } + }); return Command.SINGLE_SUCCESS; } @@ -251,15 +244,12 @@ private static int disband(CommandContext ctx) throws Comma var partyManager = PartyManager.get(source.getServer()); var result = partyManager.disbandParty(PlayerRef.of(owner)); - if (result.isOk()) { - var party = result.party(); - + result.ifSuccessElse(party -> { var message = PartyTexts.disbandSuccess(); party.getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); - } else { - var error = result.error(); + }, error -> { source.sendError(PartyTexts.displayError(error, owner)); - } + }); return Command.SINGLE_SUCCESS; } @@ -275,22 +265,22 @@ private static int addPlayerByUuid(CommandContext ctx) thro var uuid = UuidArgumentType.getUuid(ctx, "party"); var partyManager = PartyManager.get(ctx.getSource().getServer()); - return addPlayer(ctx, partyManager.getParty(uuid)); + return addPlayer(ctx, partyManager.getParty(uuid, PartyError.DOES_NOT_EXIST)); } - private static int addPlayer(CommandContext ctx, Party party) throws CommandSyntaxException { + private static int addPlayer(CommandContext ctx, PartyResult result) throws CommandSyntaxException { var source = ctx.getSource(); var player = EntityArgumentType.getPlayer(ctx, "player"); var partyManager = PartyManager.get(source.getServer()); - var result = partyManager.addPlayer(PlayerRef.of(player), party); - if (result.isOk()) { + result.map(party -> { + return partyManager.addPlayer(PlayerRef.of(player), party); + }).ifSuccessElse(party -> { var message = PartyTexts.addSuccess(player); party.getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); - } else { - var error = result.error(); + }, error -> { source.sendError(PartyTexts.displayError(error, player)); - } + }); return Command.SINGLE_SUCCESS; } @@ -301,13 +291,12 @@ private static int removePlayer(CommandContext ctx) throws var partyManager = PartyManager.get(source.getServer()); var result = partyManager.removePlayer(PlayerRef.of(player)); - if (result.isOk()) { + result.ifSuccessElse(party -> { var message = PartyTexts.removeSuccess(player); - result.party().getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); - } else { - var error = result.error(); + party.getMemberPlayers().sendMessage(message.formatted(Formatting.GOLD)); + }, error -> { source.sendError(PartyTexts.displayError(error, player)); - } + }); return Command.SINGLE_SUCCESS; } diff --git a/src/main/java/xyz/nucleoid/parties/PartyError.java b/src/main/java/xyz/nucleoid/parties/PartyError.java index 5e39b50..d5fce30 100644 --- a/src/main/java/xyz/nucleoid/parties/PartyError.java +++ b/src/main/java/xyz/nucleoid/parties/PartyError.java @@ -4,7 +4,9 @@ public enum PartyError { DOES_NOT_EXIST, ALREADY_INVITED, ALREADY_IN_PARTY, + CANNOT_INVITE_SELF, CANNOT_REMOVE_SELF, NOT_IN_PARTY, - NOT_INVITED + NOT_INVITED, + NOT_OWNER } diff --git a/src/main/java/xyz/nucleoid/parties/PartyManager.java b/src/main/java/xyz/nucleoid/parties/PartyManager.java index 10c8df1..eed58a2 100644 --- a/src/main/java/xyz/nucleoid/parties/PartyManager.java +++ b/src/main/java/xyz/nucleoid/parties/PartyManager.java @@ -21,7 +21,7 @@ public final class PartyManager { private static PartyManager instance; private final MinecraftServer server; - private final Object2ObjectMap playerToParty = new Object2ObjectOpenHashMap<>(); + private final Object2ObjectMap participantToParty = new Object2ObjectOpenHashMap<>(); private PartyManager(MinecraftServer server) { this.server = server; @@ -67,13 +67,14 @@ public static PartyManager get(MinecraftServer server) { public void onPlayerLogOut(ServerPlayerEntity player) { var ref = PlayerRef.of(player); - var party = this.playerToParty.remove(ref); + var party = this.getParty(ref); if (party == null) { return; } - if (party.remove(ref)) { - if (party.isOwner(ref)) { + var member = party.removeMember(ref); + if (member != null) { + if (member.isOwner()) { this.onPartyOwnerLogOut(player, party); } @@ -86,202 +87,198 @@ private void onPartyOwnerLogOut(ServerPlayerEntity player, Party party) { if (!members.isEmpty()) { var nextMember = members.get(0); - party.setOwner(nextMember); + party.putMember(nextMember.player(), PartyMember.Type.OWNER); - nextMember.ifOnline(this.server, nextPlayer -> { + nextMember.player().ifOnline(this.server, nextPlayer -> { nextPlayer.sendMessage(PartyTexts.transferredReceiver(player), false); }); } } public PartyResult invitePlayer(PlayerRef owner, PlayerRef player) { - var party = this.getOrCreateOwnParty(owner); - if (party != null) { - if (party.invite(player)) { - return PartyResult.ok(party); - } else { - return PartyResult.err(PartyError.ALREADY_INVITED); - } + if (owner.equals(player)) { + return new PartyResult.Error(PartyError.CANNOT_INVITE_SELF); } - return PartyResult.err(PartyError.DOES_NOT_EXIST); + var result = this.getOrCreateOwnParty(owner); + + return result.map(party -> { + var member = party.getMember(player); + if (member == null) { + party.putMember(player, PartyMember.Type.PENDING); + return new PartyResult.Success(party); + } else { + return new PartyResult.Error(PartyError.ALREADY_INVITED); + } + }); } public PartyResult kickPlayer(PlayerRef owner, PlayerRef player) { if (owner.equals(player)) { - return PartyResult.err(PartyError.CANNOT_REMOVE_SELF); + return new PartyResult.Error(PartyError.CANNOT_REMOVE_SELF); } - var party = this.getOwnParty(owner); - if (party == null) { - return PartyResult.err(PartyError.DOES_NOT_EXIST); - } + var result = this.getOwnParty(owner, null); - if (party.remove(player)) { - this.playerToParty.remove(player, party); - return PartyResult.ok(party); - } + return result.map(party -> { + if (party.removeMember(player) != null) { + return new PartyResult.Success(party); + } - return PartyResult.err(PartyError.NOT_IN_PARTY); + return new PartyResult.Error(PartyError.NOT_IN_PARTY); + }); } - public PartyResult acceptInvite(PlayerRef player, @Nullable Party party) { - if (this.playerToParty.containsKey(player)) { - return PartyResult.err(PartyError.ALREADY_IN_PARTY); - } - - if (party == null) { - return PartyResult.err(PartyError.DOES_NOT_EXIST); + public PartyResult acceptInvite(PlayerRef player, Party party) { + if (this.participantToParty.containsKey(player)) { + return new PartyResult.Error(PartyError.ALREADY_IN_PARTY); } - if (party.acceptInvite(player)) { - this.playerToParty.put(player, party); - return PartyResult.ok(party); + if (party.isPending(player)) { + party.putMember(player, PartyMember.Type.MEMBER); + return new PartyResult.Success(party); } - return PartyResult.err(PartyError.NOT_INVITED); + return new PartyResult.Error(PartyError.NOT_INVITED); } public PartyResult leaveParty(PlayerRef player) { var party = this.getParty(player); if (party == null) { - return PartyResult.err(PartyError.DOES_NOT_EXIST); + return new PartyResult.Error(PartyError.DOES_NOT_EXIST); } if (party.isOwner(player)) { if (party.getMembers().size() > 1) { - return PartyResult.err(PartyError.CANNOT_REMOVE_SELF); + return new PartyResult.Error(PartyError.CANNOT_REMOVE_SELF); } return this.disbandParty(player); } - if (party.remove(player)) { - this.playerToParty.remove(player, party); - return PartyResult.ok(party); + if (party.removeMember(player) != null) { + return new PartyResult.Success(party); } else { - return PartyResult.err(PartyError.NOT_IN_PARTY); + return new PartyResult.Error(PartyError.NOT_IN_PARTY); } } public PartyResult transferParty(PlayerRef from, PlayerRef to) { - var party = this.getOwnParty(from); - if (party == null) { - return PartyResult.err(PartyError.DOES_NOT_EXIST); - } + var result = this.getOwnParty(from, null); - if (!party.contains(to)) { - return PartyResult.err(PartyError.NOT_IN_PARTY); - } + return result.map(party -> { + if (!party.isParticipant(to)) { + return new PartyResult.Error(PartyError.NOT_IN_PARTY); + } + + party.putMember(from, PartyMember.Type.MEMBER); + party.putMember(to, PartyMember.Type.OWNER); - party.setOwner(to); - return PartyResult.ok(party); + return new PartyResult.Success(party); + }); } public PartyResult disbandParty(PlayerRef owner) { - var party = this.getOwnParty(owner); - if (party != null) { + var result = this.getOwnParty(owner, null); + + return result.map(party -> { this.disbandParty(party); - return PartyResult.ok(party); - } else { - return PartyResult.err(PartyError.DOES_NOT_EXIST); - } + return new PartyResult.Success(party); + }); } public void disbandParty(Party party) { - for (PlayerRef member : party.getMembers()) { - this.playerToParty.remove(member, party); + for (var member : party.getMembers()) { + this.participantToParty.remove(member, party); } } public PartyResult addPlayer(PlayerRef player, @Nullable Party party) { if (party == null) { - return PartyResult.err(PartyError.DOES_NOT_EXIST); + return new PartyResult.Error(PartyError.DOES_NOT_EXIST); } var oldParty = this.getParty(player); if (party == oldParty) { - return PartyResult.err(PartyError.ALREADY_IN_PARTY); + return new PartyResult.Error(PartyError.ALREADY_IN_PARTY); } else if (oldParty != null) { - if (party.isOwner(player)) { + if (oldParty.isOwner(player)) { this.disbandParty(player); - } else if (party.remove(player)) { - this.playerToParty.remove(player, party); + } else { + oldParty.removeMember(player); } } - this.playerToParty.put(player, party); - if (!party.acceptInvite(player)) { - party.add(player); - } + party.putMember(player, PartyMember.Type.MEMBER); - return PartyResult.ok(party); + return new PartyResult.Success(party); } public PartyResult removePlayer(PlayerRef player) { var party = this.getParty(player); if (party == null) { - return PartyResult.err(PartyError.NOT_IN_PARTY); + return new PartyResult.Error(PartyError.NOT_IN_PARTY); } if (party.isOwner(player)) { this.disbandParty(player); - } else if (party.remove(player)) { - this.playerToParty.remove(player, party); + } else { + party.removeMember(player); } - return PartyResult.ok(party); + return new PartyResult.Success(party); } @Nullable public Party getParty(PlayerRef player) { - return this.playerToParty.get(player); + return this.participantToParty.get(player); } - @Nullable - public Party getParty(UUID uuid) { - for (Party party : this.playerToParty.values()) { + public PartyResult getParty(UUID uuid, PartyError error) { + for (Party party : this.participantToParty.values()) { if (party.getUuid().equals(uuid)) { - return party; + return new PartyResult.Success(party); } } - return null; + return new PartyResult.Error(error); } - @Nullable - public Party getOwnParty(PlayerRef owner) { - var party = this.playerToParty.get(owner); - if (party != null && party.isOwner(owner)) { - return party; + public PartyResult getOwnParty(PlayerRef owner, @Nullable PartyError error) { + var party = this.participantToParty.get(owner); + + if (party == null) { + return new PartyResult.Error(error == null ? PartyError.DOES_NOT_EXIST : error); + } else if (!party.isOwner(owner)) { + return new PartyResult.Error(error == null ? PartyError.NOT_OWNER : error); } - return null; + + return new PartyResult.Success(party); } - @Nullable - Party getOrCreateOwnParty(PlayerRef owner) { - var party = this.playerToParty.computeIfAbsent(owner, this::createParty); + public PartyResult getOrCreateOwnParty(PlayerRef owner) { + var party = this.participantToParty.computeIfAbsent(owner, this::createParty); if (party.isOwner(owner)) { - return party; + return new PartyResult.Success(party); } - return null; + return new PartyResult.Error(PartyError.NOT_OWNER); } private Party createParty(PlayerRef owner) { - return new Party(this.server, owner); + return new Party(this.server, this.participantToParty, owner); } public Collection getPartyMembers(ServerPlayerEntity player, boolean own) { var ref = PlayerRef.of(player); - var party = own ? this.getOwnParty(ref) : this.getParty(ref); + var result = own ? this.getOwnParty(PlayerRef.of(player), null) : this.getParty(ref); - if (party != null) { - return Lists.newArrayList(party.getMemberPlayers()); + if (result instanceof PartyResult.Success success) { + return Lists.newArrayList(success.party().getMemberPlayers()); } else { return Collections.singleton(player); } } public Collection getAllParties() { - return new HashSet<>(this.playerToParty.values()); + return new HashSet<>(this.participantToParty.values()); } } diff --git a/src/main/java/xyz/nucleoid/parties/PartyMember.java b/src/main/java/xyz/nucleoid/parties/PartyMember.java new file mode 100644 index 0000000..a2916f1 --- /dev/null +++ b/src/main/java/xyz/nucleoid/parties/PartyMember.java @@ -0,0 +1,47 @@ +package xyz.nucleoid.parties; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import xyz.nucleoid.plasmid.api.util.PlayerRef; + +public record PartyMember(Party party, PlayerRef player, Type type) implements Comparable { + public boolean isPending() { + return this.type == Type.PENDING; + } + + public boolean isParticipant() { + return !this.isPending(); + } + + public boolean isOwner() { + return this.type == Type.OWNER; + } + + public Text getListEntry(MinecraftServer server) { + if (this.isOwner()) { + return PartyTexts.listMemberEntryType(this.player, server, PartyTexts.listMemberTypeOwner().formatted(Formatting.LIGHT_PURPLE)); + } else if (this.isParticipant()) { + return PartyTexts.listMemberEntry(this.player, server); + } else { + return PartyTexts.listMemberEntryType(this.player, server, PartyTexts.listMemberTypePending().formatted(Formatting.GRAY)); + } + } + + @Override + public int compareTo(PartyMember o) { + int result = o.type.compareTo(this.type); + return result != 0 ? result : this.player.id().compareTo(o.player.id()); + } + + @Override + public String toString() { + return this.player.id() + " (" + this.type + ")"; + } + + enum Type { + PENDING, + MEMBER, + OWNER; + } +} diff --git a/src/main/java/xyz/nucleoid/parties/PartyResult.java b/src/main/java/xyz/nucleoid/parties/PartyResult.java index 1869998..4a8ec5f 100644 --- a/src/main/java/xyz/nucleoid/parties/PartyResult.java +++ b/src/main/java/xyz/nucleoid/parties/PartyResult.java @@ -1,39 +1,43 @@ package xyz.nucleoid.parties; -import org.jetbrains.annotations.Nullable; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; -public final class PartyResult { - private final Party party; - private final PartyError error; +public sealed interface PartyResult permits PartyResult.Success, PartyResult.Error { + PartyResult map(Function mapper); - private PartyResult(Party party, PartyError error) { - this.party = party; - this.error = error; - } + void ifSuccessElse(Consumer onSuccess, Consumer onError); - public static PartyResult ok(Party party) { - return new PartyResult(party, null); - } + record Success(Party party) implements PartyResult { + public Success { + Objects.requireNonNull(party); + } - public static PartyResult err(PartyError error) { - return new PartyResult(null, error); - } + @Override + public PartyResult map(Function mapper) { + return mapper.apply(this.party); + } - public boolean isOk() { - return this.error == null; + @Override + public void ifSuccessElse(Consumer onSuccess, Consumer onError) { + onSuccess.accept(this.party); + } } - public boolean isErr() { - return this.error != null; - } + record Error(PartyError error) implements PartyResult { + public Error { + Objects.requireNonNull(error); + } - @Nullable - public Party party() { - return this.party; - } + @Override + public PartyResult map(Function mapper) { + return this; + } - @Nullable - public PartyError error() { - return this.error; + @Override + public void ifSuccessElse(Consumer onSuccess, Consumer onError) { + onError.accept(this.error); + } } } diff --git a/src/main/java/xyz/nucleoid/parties/PartyTexts.java b/src/main/java/xyz/nucleoid/parties/PartyTexts.java index 971a140..a0ad061 100644 --- a/src/main/java/xyz/nucleoid/parties/PartyTexts.java +++ b/src/main/java/xyz/nucleoid/parties/PartyTexts.java @@ -13,17 +13,19 @@ public final class PartyTexts { public static MutableText displayError(PartyError error, ServerPlayerEntity player) { - return displayError(error, player.getGameProfile().getName()); + return displayError(error, player.getDisplayName()); } - public static MutableText displayError(PartyError error, String player) { + public static MutableText displayError(PartyError error, Text player) { return switch (error) { case DOES_NOT_EXIST -> Text.translatable("text.game_parties.party.error.does_not_exist"); case ALREADY_INVITED -> Text.translatable("text.game_parties.party.error.already_invited", player); case ALREADY_IN_PARTY -> Text.translatable("text.game_parties.party.error.already_in_party"); + case CANNOT_INVITE_SELF -> Text.translatable("text.game_parties.party.error.cannot_invite_self"); case CANNOT_REMOVE_SELF -> Text.translatable("text.game_parties.party.error.cannot_remove_self"); case NOT_IN_PARTY -> Text.translatable("text.game_parties.party.error.not_in_party", player); case NOT_INVITED -> Text.translatable("text.game_parties.party.error.not_invited"); + case NOT_OWNER -> Text.translatable("text.game_parties.party.error.not_owner"); }; } @@ -55,8 +57,8 @@ public static MutableText transferredReceiver(ServerPlayerEntity transferredFrom return Text.translatable("text.game_parties.party.transferred.receiver", transferredFrom.getDisplayName()); } - public static MutableText kickedSender(ServerPlayerEntity player) { - return Text.translatable("text.game_parties.party.kicked.sender", player.getDisplayName()); + public static MutableText kickedSender(Text playerName) { + return Text.translatable("text.game_parties.party.kicked.sender", playerName); } public static MutableText kickedReceiver() { diff --git a/src/main/resources/data/game_parties/lang/en_us.json b/src/main/resources/data/game_parties/lang/en_us.json index f2eac95..62e392e 100644 --- a/src/main/resources/data/game_parties/lang/en_us.json +++ b/src/main/resources/data/game_parties/lang/en_us.json @@ -3,10 +3,12 @@ "text.game_parties.party.disband.success": "Your party has been disbanded!", "text.game_parties.party.error.already_in_party": "You are already in this party!", "text.game_parties.party.error.already_invited": "%s is already invited to this party!", + "text.game_parties.party.error.cannot_invite_self": "Cannot invite yourself to the party!", "text.game_parties.party.error.cannot_remove_self": "Cannot remove yourself from the party!", "text.game_parties.party.error.does_not_exist": "You do not control any party!", "text.game_parties.party.error.not_in_party": "%s is not in this party!", "text.game_parties.party.error.not_invited": "You are not invited to this party!", + "text.game_parties.party.error.not_owner": "You do not control this party!", "text.game_parties.party.invited.receiver": "You have been invited to join %s's party! ", "text.game_parties.party.invited.receiver.click": "Click here to join", "text.game_parties.party.invited.receiver.hover": "Join %s's party!",