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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/release-notes/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,21 @@
"released": false,
"released_in": null,
"released_at": null
},
{
"id": "discord-join-version-and-dev-ping",
"version": "0.303.0",
"type": "feat",
"title": "Discord join messages show the mod version + dev milestone ping",
"summary": "Discord join and 'started the game' messages posted by the bundled Discord Presence integration now include the Dungeon Train version. The developer is also notified the first time a player starts a new run after their first death.",
"highlights": [
"Mod version on Discord join/start messages",
"Dev pinged on a player's first new run after first death"
],
"date": "2026-06-14",
"released": false,
"released_in": null,
"released_at": null
}
]
}
9 changes: 7 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ loader_version_range=[1,)
mod_id=dungeontrain
mod_name=Dungeon Train
mod_license=PolyForm Shield 1.0.0
mod_version=0.302.0
mod_version=0.302.1
mod_group_id=games.brennan.dungeontrain
mod_authors=Brennan Hatton
mod_description=A Minecraft port of Dungeon Train (brennanhatton.itch.io/dungeontrain) - a moving train that hosts procedurally generated dungeons.
Expand Down Expand Up @@ -77,7 +77,12 @@ playermob_version=0.38.0
# sets so DP's generic auto-death-report stays off and only DT's own "Run Ended" embed posts. 0.11.0 adds
# runWithAdvancementAnnounceSuppressed(): DT wraps its cross-world advancement replay (AchievementEvents
# login re-grant) in it so replayed grants don't double-post to Discord — see GlobalAchievementStore.
discordpresence_version=0.11.0
# 0.12.0 shows advancement requirements in Discord embeds (#13). 0.13.0 adds joinMessageSuffix(uuid, name):
# DT appends "DungeonTrain <version>" to every Discord join message (DungeonTrain.commonSetup provider), plus
# the developer's mention on the first NEW world a player starts after their first-ever death (DevPingService
# + GlobalDeathPingStore, once per player). DP lets that one trusted suffix mention notify via
# allowed_mentions.users; blanking developerPingRelayToken disables it.
discordpresence_version=0.13.0

# VS deps kept commented for archival reference only — the project does not plan
# to re-adopt VS unless Sable proves untenable. To re-enable, uncomment in build.gradle
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/games/brennan/dungeontrain/DungeonTrain.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import games.brennan.dungeontrain.config.ClientDisplayConfig;
import games.brennan.dungeontrain.config.DungeonTrainCommonConfig;
import games.brennan.dungeontrain.config.DungeonTrainConfig;
import games.brennan.dungeontrain.discord.DevPingService;
import games.brennan.dungeontrain.registry.ModBlocks;
import games.brennan.dungeontrain.registry.ModCreativeTabs;
import games.brennan.dungeontrain.registry.ModDataAttachments;
Expand All @@ -27,6 +28,8 @@
import org.apache.logging.log4j.core.config.Configurator;
import org.slf4j.Logger;

import java.util.UUID;

/**
* Dungeon Train — Minecraft port of the itch.io game.
* Entry point for the NeoForge mod.
Expand Down Expand Up @@ -123,6 +126,15 @@ private void commonSetup(final FMLCommonSetupEvent event) {
return "https://brennan.games/api/dp-relay/adc3dc432f437e9401092c143dec86767dd06c2a5d94f48f";
}
@Override public boolean suppressAutoDeathReport() { return true; } // DT posts its own "Run Ended"
@Override public String joinMessageSuffix(UUID playerId, String playerName) {
// Always tag the build version on the Discord join message; additionally append the
// relay dev-ping marker the first time a player starts a NEW world after their
// first-ever death (once per player; dormant unless developerPingRelayToken is set).
String version = "DungeonTrain " + ModList.get().getModContainerById(MOD_ID)
.map(c -> c.getModInfo().getVersion().toString()).orElse("");
String marker = DevPingService.relayMarkerIfQualifies(playerId);
return marker.isBlank() ? version : version + "\n" + marker;
}
});

// Befriend advancements (A Silent Friend / Friends) observe PlayerMob
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ public final class DungeonTrainConfig {
public static final int DEFAULT_RANDOM_BOOK_ONE_IN = 100;

public static final boolean DEFAULT_DEATH_REPORT_TO_DISCORD = true;
/**
* Ships as the developer's Discord mention so the first-new-world-after-death ping notifies them
* across installs (Discord Presence allows this one trusted mention to ping via
* allowed_mentions.users). Set blank to disable the dev-ping; the version line is unaffected.
*/
public static final String DEFAULT_DEVELOPER_PING_RELAY_TOKEN = "<@342110421114945537>";

/** Play the fly-up spawn cinematic the first time each player enters a world. */
public static final boolean DEFAULT_INTRO_CINEMATIC_ENABLED = true;
Expand All @@ -85,6 +91,7 @@ public final class DungeonTrainConfig {
public static final ModConfigSpec.BooleanValue FIRST_LEVEL_STARTER_LOOT;
public static final ModConfigSpec.IntValue RANDOM_BOOK_FROM_BOOKSHELF_ONE_IN;
public static final ModConfigSpec.BooleanValue DEATH_REPORT_TO_DISCORD;
public static final ModConfigSpec.ConfigValue<String> DEVELOPER_PING_RELAY_TOKEN;
public static final ModConfigSpec.BooleanValue INTRO_CINEMATIC_ENABLED;
public static final ModConfigSpec.IntValue INTRO_CINEMATIC_DURATION_TICKS;

Expand All @@ -107,6 +114,7 @@ public final class DungeonTrainConfig {
FIRST_LEVEL_STARTER_LOOT = pair.getLeft().firstLevelStarterLoot;
RANDOM_BOOK_FROM_BOOKSHELF_ONE_IN = pair.getLeft().randomBookFromBookshelfOneIn;
DEATH_REPORT_TO_DISCORD = pair.getLeft().deathReportToDiscord;
DEVELOPER_PING_RELAY_TOKEN = pair.getLeft().developerPingRelayToken;
INTRO_CINEMATIC_ENABLED = pair.getLeft().introCinematicEnabled;
INTRO_CINEMATIC_DURATION_TICKS = pair.getLeft().introCinematicDurationTicks;
}
Expand Down Expand Up @@ -172,6 +180,14 @@ private static Holder build(ModConfigSpec.Builder b) {
"config/discordpresence-server.toml. To avoid a duplicate post, also set autoDeathReport=false",
"there (this richer report replaces Discord Presence's basic vanilla one).")
.define("deathReportToDiscord", DEFAULT_DEATH_REPORT_TO_DISCORD);
ModConfigSpec.ConfigValue<String> developerPingRelayToken = b
.comment("Text appended to a player's Discord join message the first time they start a NEW world",
"after their first-ever death (once per player, ever). Ships as the developer's Discord",
"mention so that milestone pings them; Discord Presence lets this exact mention notify",
"(allowed_mentions.users) while player names / chat never can. BLANK = dev-ping disabled",
"(the version line still posts). Set to a relay sentinel instead if you route the mention",
"through a relay. Requires the bundled Discord Presence mod.")
.define("developerPingRelayToken", DEFAULT_DEVELOPER_PING_RELAY_TOKEN);
b.pop();
b.push("intro");
ModConfigSpec.BooleanValue introCinematicEnabled = b
Expand All @@ -186,7 +202,7 @@ private static Holder build(ModConfigSpec.Builder b) {
b.pop();
return new Holder(numCarriages, speed, trainY, generateTracks, generateTunnels, generationMode, groupSize,
difficultyEnabled, carriagesPerTier, difficultyAffectsBabyMobs, progressionLevelDelay, firstLevelEasyMobs,
firstLevelStarterLoot, randomBookFromBookshelfOneIn, deathReportToDiscord,
firstLevelStarterLoot, randomBookFromBookshelfOneIn, deathReportToDiscord, developerPingRelayToken,
introCinematicEnabled, introCinematicDurationTicks);
}

Expand Down Expand Up @@ -264,6 +280,14 @@ public static boolean isDeathReportToDiscord() {
return isLoaded() ? DEATH_REPORT_TO_DISCORD.get() : DEFAULT_DEATH_REPORT_TO_DISCORD;
}

/**
* Text appended to the developer "first new world after first death" join ping — the developer's
* Discord mention by default, or {@code ""} (disabled) when blanked / outside a loaded world.
*/
public static String getDeveloperPingRelayToken() {
return isLoaded() ? DEVELOPER_PING_RELAY_TOKEN.get() : DEFAULT_DEVELOPER_PING_RELAY_TOKEN;
}

/** Whether the fly-up spawn cinematic plays the first time a player enters a world. */
public static boolean isIntroCinematicEnabled() {
return isLoaded() ? INTRO_CINEMATIC_ENABLED.get() : DEFAULT_INTRO_CINEMATIC_ENABLED;
Expand Down Expand Up @@ -356,6 +380,7 @@ private record Holder(
ModConfigSpec.BooleanValue firstLevelStarterLoot,
ModConfigSpec.IntValue randomBookFromBookshelfOneIn,
ModConfigSpec.BooleanValue deathReportToDiscord,
ModConfigSpec.ConfigValue<String> developerPingRelayToken,
ModConfigSpec.BooleanValue introCinematicEnabled,
ModConfigSpec.IntValue introCinematicDurationTicks
) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package games.brennan.dungeontrain.discord;

import games.brennan.dungeontrain.config.DungeonTrainConfig;

import java.util.UUID;

/**
* Decides whether a joining player should carry the developer ping-marker on their Discord join
* message: the marker fires on the <b>first new world the player starts/joins after their
* first-ever death</b>, at most once per player, ever.
*
* <p>The actual {@code <@mention>} is NOT built here — the brennan.games relay swaps the
* configured marker token for the real mention (and sets {@code allowed_mentions} so it pings),
* keeping the developer's Discord id off the jar. This service only decides <i>when</i> to emit
* the token.</p>
*
* <p>"New world" = a world created <i>after</i> the player's first death, detected by comparing
* the per-world creation time (cached here at server start from {@code DungeonTrainWorldData}, so
* the join path never touches per-world SavedData) against the cross-world first-death time
* ({@link GlobalDeathPingStore}). Legacy worlds created before this feature have a creation time
* of {@code 0} and therefore never qualify.</p>
*
* <p>When the configured token is blank the feature is <b>dormant</b>: nothing is emitted and the
* once-only flag is left unconsumed, so players stay eligible until the token is set.</p>
*/
public final class DevPingService {

/**
* Wall-clock creation time (millis) of the running server's overworld, cached at server start
* (from {@code TrainBootstrapEvents.onServerStarted}, a both-dist hook) so
* {@link #relayMarkerIfQualifies} never reads per-world SavedData off the server thread.
* {@code 0} when no server is running or the world predates the feature. Re-set on every
* server start — which always precedes any join — so a previous world's value never leaks.
*/
private static volatile long currentWorldCreatedAtMillis = 0L;

private DevPingService() {}

/** Cache the active world's creation time. Call once from the both-dist server-started hook. */
public static void setCurrentWorldCreatedAt(long millis) {
currentWorldCreatedAtMillis = millis;
}

/**
* The relay ping-marker token for this player when they qualify for the one-time developer
* ping, else {@code ""}. A blank configured token keeps the feature dormant (emits nothing and
* does NOT consume eligibility). When the token is set and the player qualifies, the once-only
* flag is consumed atomically so the marker is emitted at most once, ever.
*/
public static String relayMarkerIfQualifies(UUID playerId) {
String token = DungeonTrainConfig.getDeveloperPingRelayToken();
// Read-only qualification first (pure, unit-tested via decideMarker).
String decision = decideMarker(token,
GlobalDeathPingStore.firstDeathAt(playerId),
currentWorldCreatedAtMillis,
GlobalDeathPingStore.devPingSent(playerId));
if (decision.isBlank()) {
return "";
}
// Qualifies — consume the once-ever flag atomically; emit only if we win the race, which
// guards against a double-fire from rapid re-joins.
return GlobalDeathPingStore.markDevPingSentIfUnset(playerId) ? decision : "";
}

/**
* Pure qualification decision: the marker token to emit, or {@code ""} when the player does not
* (yet) qualify. Does NOT mutate state — the caller consumes the once-only flag separately.
*
* @param token the configured relay marker ({@code ""} = feature dormant)
* @param firstDeathAtMillis the player's first-ever death time ({@code 0} = never died)
* @param worldCreatedAtMillis the current world's creation time ({@code 0} = legacy/unknown)
* @param alreadyPinged whether the once-only ping was already emitted for this player
*/
static String decideMarker(String token, long firstDeathAtMillis, long worldCreatedAtMillis, boolean alreadyPinged) {
if (token.isBlank()) return ""; // dormant
if (firstDeathAtMillis == 0L) return ""; // never died
if (worldCreatedAtMillis <= firstDeathAtMillis) return ""; // not a world created after the death
if (alreadyPinged) return ""; // already fired once
return token;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package games.brennan.dungeontrain.discord;

import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.mojang.logging.LogUtils;
import com.mojang.serialization.Codec;
import com.mojang.serialization.JsonOps;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.neoforged.fml.loading.FMLPaths;
import org.slf4j.Logger;

import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;

/**
* Sidecar JSON store — per player UUID and OUTSIDE any world save — holding the cross-world
* state needed to fire the developer "first new world after first death" ping exactly once per
* player. Lives at {@code <minecraft>/config/dungeontrain-devping/<uuid>.json}.
*
* <p>{@code firstDeathAtMillis} is the wall-clock time of the player's first-ever death on this
* instance ({@code 0} = has not died); {@code devPingSent} flips true the one time the ping
* marker is actually emitted. Cross-world by design — a death in one world gates the marker on
* the first world created afterwards (see {@link DevPingService}).</p>
*
* <p>JSON shape: {@code { "firstDeathAtMillis": 1718000000000, "devPingSent": false }}</p>
*
* <p>Concurrency: methods are {@code synchronized} on the class, mirroring
* {@code GlobalAchievementStore}. Writes are rare (one death stamp, one ping per player) and use
* an atomic-replace so a crash mid-write can't corrupt the file.</p>
*/
public final class GlobalDeathPingStore {

private static final Logger LOGGER = LogUtils.getLogger();
private static final String DIR_NAME = "dungeontrain-devping";

/** Schema record; both fields optional so older/partial files load with sensible defaults. */
public record Data(long firstDeathAtMillis, boolean devPingSent) {
public static final Codec<Data> CODEC = RecordCodecBuilder.create(in -> in.group(
Codec.LONG.optionalFieldOf("firstDeathAtMillis", 0L).forGetter(Data::firstDeathAtMillis),
Codec.BOOL.optionalFieldOf("devPingSent", false).forGetter(Data::devPingSent)
).apply(in, Data::new));

public static final Data EMPTY = new Data(0L, false);
}

private GlobalDeathPingStore() {}

/** Resolve the sidecar file path for {@code playerUuid}. */
public static Path file(UUID playerUuid) {
return FMLPaths.CONFIGDIR.get().resolve(DIR_NAME).resolve(playerUuid + ".json");
}

/** Read the sidecar, returning {@link Data#EMPTY} when the file is missing or malformed. */
public static synchronized Data read(UUID playerUuid) {
Path path = file(playerUuid);
if (!Files.isRegularFile(path)) return Data.EMPTY;
try (Reader reader = Files.newBufferedReader(path)) {
JsonElement element = JsonParser.parseReader(reader);
var result = Data.CODEC.parse(JsonOps.INSTANCE, element);
if (result.error().isPresent()) {
LOGGER.warn("[DungeonTrain] GlobalDeathPingStore: failed to parse {}: {}",
path, result.error().get().message());
return Data.EMPTY;
}
return result.result().orElse(Data.EMPTY);
} catch (IOException e) {
LOGGER.warn("[DungeonTrain] GlobalDeathPingStore: I/O error reading {}: {}",
path, e.getMessage());
return Data.EMPTY;
}
}

/** Wall-clock millis of the player's first-ever death, or {@code 0} when they have not died. */
public static synchronized long firstDeathAt(UUID playerUuid) {
return read(playerUuid).firstDeathAtMillis();
}

/** Whether the developer ping has already been emitted once for this player. */
public static synchronized boolean devPingSent(UUID playerUuid) {
return read(playerUuid).devPingSent();
}

/**
* Stamp the first-death time the first time only; later deaths leave it unchanged.
*
* @return {@code true} when the file was actually mutated.
*/
public static synchronized boolean recordFirstDeathIfUnset(UUID playerUuid, long nowMillis) {
Data current = read(playerUuid);
if (current.firstDeathAtMillis() != 0L) return false;
writeAtomic(playerUuid, new Data(nowMillis, current.devPingSent()));
return true;
}

/**
* Atomically mark the developer ping consumed; returns {@code true} only for the first caller
* so the marker is emitted at most once, ever, per player.
*/
public static synchronized boolean markDevPingSentIfUnset(UUID playerUuid) {
Data current = read(playerUuid);
if (current.devPingSent()) return false;
writeAtomic(playerUuid, new Data(current.firstDeathAtMillis(), true));
return true;
}

private static void writeAtomic(UUID playerUuid, Data data) {
Path path = file(playerUuid);
try {
Files.createDirectories(path.getParent());
} catch (IOException e) {
LOGGER.error("[DungeonTrain] GlobalDeathPingStore: failed to create dir {}: {}",
path.getParent(), e.getMessage());
return;
}
Path tmp = path.resolveSibling(path.getFileName() + ".tmp");
var result = Data.CODEC.encodeStart(JsonOps.INSTANCE, data);
if (result.error().isPresent()) {
LOGGER.error("[DungeonTrain] GlobalDeathPingStore: encode failed: {}",
result.error().get().message());
return;
}
JsonElement element = result.result().orElseThrow();
try (Writer writer = Files.newBufferedWriter(tmp)) {
writer.write(element.toString());
} catch (IOException e) {
LOGGER.error("[DungeonTrain] GlobalDeathPingStore: write tmp {} failed: {}",
tmp, e.getMessage());
return;
}
try {
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
// Fall back to non-atomic on filesystems that don't support ATOMIC_MOVE.
try {
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e2) {
LOGGER.error("[DungeonTrain] GlobalDeathPingStore: rename {} -> {} failed: {}",
tmp, path, e2.getMessage());
}
}
}
}
Loading
Loading