Skip to content

Commit

Permalink
ServerCommand interface, Advancements logic refactor
Browse files Browse the repository at this point in the history
*Breaking*: the advancement save file now saves only the advancement Identifier, you can just remove the `,` and what is after it in old save files.

Moved the grantCriterion logic to the `PLAYER_ADVANCEMENT` event from architectury.

The Discord bot will now cycle presence when the server saves.

Added Uptime to the `/status` slash command which had a makeover.

Added new Link server command, this will link the item the player is currently holding when using the `/link` command.

Added new TimeSince helper.
  • Loading branch information
Kyagara committed Sep 27, 2024
1 parent 49a61fc commit 1e94b0f
Show file tree
Hide file tree
Showing 12 changed files with 324 additions and 116 deletions.
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,42 @@ Sync advancements, chat relay for Discord and more.
This project is inspired by the [CooperativeAdvancements](https://modrinth.com/mod/cooperative-advancements) mod, my
goal with this is to add on the coop experience I really enjoyed from CooperativeAdvancements by giving more features in just one package.

Focusing only on server side for now.

## Features

- Bridges a Discord channel to the Minecraft server chat, allowing for chat between the two.
- Send events like advancements, join/leave, death, from the server to Discord.
- Sync advancements completion, all players share the same advancement progress.
- Discord commands to retrieve information about the server.
- Link items in the chat.

## TODO

- Build system needs some work, merging jars is really slow, shadowing is probably not done right, add sources to the artifacts.
- Maybe use a small database library for storage as it might be useful for other ideas.
- Build system needs some work, shadowing is probably not done right, add sources to the artifacts.
- Prevent toast from showing for synced players, at the moment the player that was synced will get a toast notification after completing the already completed advancement.
- Add Discord commands to retrieve general information about the server, TPS, uptime, etc.
- Add Discord commands to retrieve general information about the server, TPS, etc.

## Configuration

At startup, a config folder called `cooptweaks` will be created, it will include the following:
Configuration is located in a folder called `cooptweaks`, it contains the following:

- `saves`: Folder containing the advancements reached by the players, files are named by the world seed.
- `discord.toml`: Configure or enable/disable the Discord bridge.

The Discord bot requires `MESSAGE_CONTENT` and `GUILD_MEMBERS` intents.
The Discord bot requires the permission to create slash commands and `MESSAGE_CONTENT` and `GUILD_MEMBERS` intents.

## Commands
## Server Commands

### `/cooptweaks advancements <subcommand>`

- `progress`: Shows the advancement progress of the world.

## Slash Commands
### `/link`

Links the item being held by the player to the chat.

## Slash Commands (Discord)

- `/status`: Shows information about the server like motd, address, etc.
- `/status`: Shows information about the server like motd, uptime, address, etc.
19 changes: 16 additions & 3 deletions common/src/main/java/com/cooptweaks/Main.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.cooptweaks;

import com.cooptweaks.advancements.Advancements;
import com.cooptweaks.advancements.commands.Progress;
import com.cooptweaks.commands.misc.Link;
import com.cooptweaks.discord.Discord;
import com.cooptweaks.events.GrantCriterionCallback;
import dev.architectury.event.EventResult;
import dev.architectury.event.events.common.*;
import net.minecraft.util.math.BlockPos;
Expand All @@ -16,19 +17,24 @@ public final class Main {
public static final Discord DISCORD = new Discord();
public static final Advancements ADVANCEMENTS = new Advancements();

public static long STARTUP;

public static void init() {
Configuration.Verify();

LifecycleEvent.SERVER_BEFORE_START.register(server -> DISCORD.Start());

LifecycleEvent.SERVER_STARTED.register(server -> {
STARTUP = System.currentTimeMillis();
DISCORD.NotifyStarted(server);

// Requires the server to be started since the seed won't be available until then.
// This might be changed if manually reading the level.dat, haven't seen any issue from doing it this way yet.
ADVANCEMENTS.LoadAdvancements(server);
});

LifecycleEvent.SERVER_LEVEL_SAVE.register(world -> DISCORD.CyclePresence(world.getPlayers()));

LifecycleEvent.SERVER_STOPPING.register(server -> DISCORD.Stop());

PlayerEvent.PLAYER_JOIN.register(player -> {
Expand Down Expand Up @@ -76,7 +82,14 @@ public static void init() {
return EventResult.pass();
});

GrantCriterionCallback.EVENT.register(ADVANCEMENTS::OnCriterion);
CommandRegistrationEvent.EVENT.register(ADVANCEMENTS::RegisterCommands);
PlayerEvent.PLAYER_ADVANCEMENT.register(ADVANCEMENTS::OnCriterion);

CommandRegistrationEvent.EVENT.register((dispatcher, registryAccess, environment) -> {
// Advancements
new Progress().register(dispatcher, registryAccess, environment);

// Misc
new Link().register(dispatcher, registryAccess, environment);
});
}
}
139 changes: 83 additions & 56 deletions common/src/main/java/com/cooptweaks/advancements/Advancements.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,12 @@
import com.cooptweaks.Configuration;
import com.cooptweaks.Main;
import com.cooptweaks.discord.Discord;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.context.CommandContext;
import discord4j.rest.util.Color;
import net.minecraft.advancement.Advancement;
import net.minecraft.advancement.AdvancementCriterion;
import net.minecraft.advancement.AdvancementEntry;
import net.minecraft.advancement.PlayerAdvancementTracker;
import net.minecraft.command.CommandRegistryAccess;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.MutableText;
import net.minecraft.text.Text;
Expand All @@ -26,25 +22,53 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
Manages advancements and criteria syncing.
<p>
<p>
Startup:
<ol>
<li>Load all advancements and criteria from the server loaded by mods and the game.</li>
<li>Load all completed advancements from the save file, the file is named by the world seed.</li>
</ol>
p>
Syncing:
<ol>
<li>When an advancement is completed, append the save file with the advancement {@link Identifier}.</li>
<li>Send a message to the server chat(disabling the original announceAdvancement broadcast) and Discord channel.</li>
<li>When a player joins the server, go though all completed advancements and grant the criteria for each to the player.</li>
</ol>
*/
public final class Advancements {
private static final Discord DISCORD = Main.DISCORD;

private static MinecraftServer SERVER;

/** Map of all advancements. Loaded at startup. */
private static final HashMap<Identifier, AdvancementEntry> ALL_ADVANCEMENTS = HashMap.newHashMap(122);
private static final ConcurrentHashMap<String, AdvancementEntry> COMPLETED_ADVANCEMENTS = new ConcurrentHashMap<>(122);

/**
Map of criteria for each advancement. Loaded at startup.
<p>
Maps the advancement {@link AdvancementEntry#id() identifier} to a list of criteria.
*/
private static final HashMap<Identifier, List<String>> ALL_CRITERIA = new HashMap<>(122);

/**
Map of all completed advancements. Loaded from the save file.
<p>
Maps the advancement {@link AdvancementEntry#id() identifier} to the {@link AdvancementEntry}.
*/
private static final ConcurrentHashMap<Identifier, AdvancementEntry> COMPLETED_ADVANCEMENTS = new ConcurrentHashMap<>(122);

private static FileChannel CURRENT_SEED_FILE;

private static synchronized int appendToSave(String text) throws IOException {
private static synchronized void appendToSave(String text) throws IOException {
ByteBuffer buffer = ByteBuffer.wrap(text.getBytes());
return CURRENT_SEED_FILE.write(buffer);
CURRENT_SEED_FILE.write(buffer);
}

public void LoadAdvancements(MinecraftServer server) {
Expand All @@ -58,8 +82,14 @@ public void LoadAdvancements(MinecraftServer server) {
Main.LOGGER.info("Loaded {} advancements from the server.", totalAdvancements);
}

if (ALL_CRITERIA.isEmpty()) {
Main.LOGGER.error("No criteria loaded from the server.");
} else {
Main.LOGGER.info("Loaded {} criteria from the server.", ALL_CRITERIA.size());
}

try {
int savedAdvancements = loadSaveAdvancements(server, Configuration.ADVANCEMENTS_SAVE_PATH);
int savedAdvancements = loadSaveAdvancements(server);
if (savedAdvancements == 0) {
Main.LOGGER.info("No completed advancements data to load. Initialized new save file.");
} else {
Expand All @@ -76,17 +106,23 @@ private static int loadServerAdvancements(MinecraftServer server) {
for (AdvancementEntry entry : advancements) {
Advancement advancement = entry.value();

// If the advancement has a display, add it to the list.
// If the advancement has a display, add it to the advancement map.
advancement.display().ifPresent(display -> {
ALL_ADVANCEMENTS.put(entry.id(), entry);

// Add all criteria for this advancement to the criteria map.
Map<String, AdvancementCriterion<?>> criteria = advancement.criteria();
criteria.forEach((key, criterion) -> {
ALL_CRITERIA.computeIfAbsent(entry.id(), id -> new ArrayList<>()).add(key);
});
});
}

return ALL_ADVANCEMENTS.size();
}

private static int loadSaveAdvancements(MinecraftServer server, Path saveFolder) throws IOException {
Path save = saveFolder.resolve(String.valueOf(server.getOverworld().getSeed()));
private static int loadSaveAdvancements(MinecraftServer server) throws IOException {
Path save = Configuration.ADVANCEMENTS_SAVE_PATH.resolve(String.valueOf(server.getOverworld().getSeed()));

if (!Files.exists(save)) {
try {
Expand All @@ -99,26 +135,19 @@ private static int loadSaveAdvancements(MinecraftServer server, Path saveFolder)
}

BufferedReader reader = new BufferedReader(new FileReader(save.toString()));
String line = reader.readLine();
String entryName = reader.readLine();

while (line != null) {
String[] arr = line.split(",");
if (arr.length < 2) {
Main.LOGGER.warn("Skipping malformed line: '{}'", line.trim());
continue;
}
while (entryName != null) {
Identifier id = Identifier.of(entryName);

String entryName = arr[0];
String criterionName = arr[1];

AdvancementEntry entry = ALL_ADVANCEMENTS.get(Identifier.of(entryName));
AdvancementEntry entry = ALL_ADVANCEMENTS.get(id);
if (entry != null) {
COMPLETED_ADVANCEMENTS.put(criterionName, entry);
COMPLETED_ADVANCEMENTS.put(id, entry);
} else {
Main.LOGGER.warn("Advancement '{}' not found for criterion '{}'.", entryName, criterionName);
Main.LOGGER.error("Advancement '{}' not found.", entryName);
}

line = reader.readLine();
entryName = reader.readLine();
}

reader.close();
Expand All @@ -133,43 +162,54 @@ public void SyncPlayerOnJoin(ServerPlayerEntity player, String name) {

PlayerAdvancementTracker tracker = player.getAdvancementTracker();

Main.LOGGER.info("Syncing {} advancements.", name);
COMPLETED_ADVANCEMENTS.forEach((criterionName, entry) -> tracker.grantCriterion(entry, criterionName));
Main.LOGGER.info("Syncing player '{}' advancements.", name);

// Loop through all completed advancements.
COMPLETED_ADVANCEMENTS.forEach((id, entry) -> {
// Loop through all criteria for this advancement.
ALL_CRITERIA.get(id).forEach(criterionName -> {
// Grant the criterion to the player.
tracker.grantCriterion(entry, criterionName);
});
});
}

public void OnCriterion(ServerPlayerEntity currentPlayer, AdvancementEntry entry, String criterionName, boolean isDone) {
if (COMPLETED_ADVANCEMENTS.containsKey(criterionName)) {
public void OnCriterion(ServerPlayerEntity currentPlayer, AdvancementEntry entry) {
Identifier id = entry.id();

if (COMPLETED_ADVANCEMENTS.containsKey(id)) {
return;
}

Advancement advancement = entry.value();
advancement.display().ifPresent(display -> {
if (isDone) {
String id = entry.id().toString();
PlayerAdvancementTracker tracker = currentPlayer.getAdvancementTracker();
if (tracker.getProgress(entry).isDone()) {
String playerName = currentPlayer.getName().getString();

COMPLETED_ADVANCEMENTS.put(criterionName, entry);
COMPLETED_ADVANCEMENTS.put(id, entry);

Collection<AdvancementCriterion<?>> criteria = advancement.criteria().values();

// Grant the advancement to all players.
List<ServerPlayerEntity> players = SERVER.getPlayerManager().getPlayerList();
for (ServerPlayerEntity player : players) {
if (currentPlayer != player) {
player.getAdvancementTracker().grantCriterion(entry, criterionName);
criteria.forEach(criterion -> player.getAdvancementTracker().grantCriterion(entry, criterion.toString()));
}
}

try {
String line = String.format("%s,%s%n", id, criterionName);
int n = appendToSave(line);
Main.LOGGER.info("Saved line '{}' ({} bytes written).", line, n);
String line = String.format("%s%n", id);
appendToSave(line);
} catch (IOException e) {
// This should be handled in another way, has to be recoverable, so we can try again.
throw new RuntimeException(e);
}

Optional<Text> advancementName = advancement.name();
if (advancementName.isEmpty()) {
advancementName = Optional.of(Text.literal(criterionName));
advancementName = Optional.of(Text.literal(id.toString()));
}

// Send announcement to the server chat.
Expand All @@ -181,7 +221,7 @@ public void OnCriterion(ServerPlayerEntity currentPlayer, AdvancementEntry entry
// Send announcement to the Discord channel.
String title = display.getTitle().getString();
if (title.isEmpty()) {
title = criterionName;
title = id.toString();
}

String description = display.getDescription().getString();
Expand All @@ -191,14 +231,6 @@ public void OnCriterion(ServerPlayerEntity currentPlayer, AdvancementEntry entry
});
}

public void RegisterCommands(CommandDispatcher<ServerCommandSource> dispatcher, CommandRegistryAccess registryAccess, CommandManager.RegistrationEnvironment environment) {
dispatcher.register(CommandManager.literal("cooptweaks")
.then(CommandManager.literal("advancements")
.then(CommandManager.literal("progress").executes(this::progressCommand))
)
);
}

/**
Gets the advancements progress of the server.
Expand All @@ -207,9 +239,4 @@ public void RegisterCommands(CommandDispatcher<ServerCommandSource> dispatcher,
public static String getAdvancementsProgress() {
return String.format("%d/%d", COMPLETED_ADVANCEMENTS.size(), ALL_ADVANCEMENTS.size());
}

private int progressCommand(CommandContext<ServerCommandSource> context) {
context.getSource().sendFeedback(() -> Text.literal(String.format("%s advancements completed so far.", getAdvancementsProgress())), false);
return 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.cooptweaks.advancements.commands;

import com.cooptweaks.advancements.Advancements;
import com.cooptweaks.commands.ServerCommand;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.context.CommandContext;
import net.minecraft.command.CommandRegistryAccess;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.text.Text;

public class Progress implements ServerCommand {
@Override
public void register(CommandDispatcher<ServerCommandSource> dispatcher, CommandRegistryAccess registryAccess, CommandManager.RegistrationEnvironment environment) {
dispatcher
.register(CommandManager.literal("cooptweaks")
.then(CommandManager.literal("advancements")
.then(CommandManager.literal("progress")
.executes(this::execute))));
}

@Override
public int execute(CommandContext<ServerCommandSource> context) {
context.getSource().sendFeedback(() -> Text.literal(String.format("%s advancements completed.", Advancements.getAdvancementsProgress())), false);
return 0;
}
}
Loading

0 comments on commit 1e94b0f

Please sign in to comment.