From 5d3acef8ab2fca4fdd1067b3095db7064a755914 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 18:24:55 -0400 Subject: [PATCH] feat(onboarding): welcome eligible new players --- design/log.md | 7 +++ .../us-004-introduce-new-players-to-leaf.md | 18 ++++---- src/main/java/games/dmg/leaf/LeafRuntime.java | 6 +++ .../java/games/dmg/leaf/LeafRuntimeTest.java | 44 +++++++++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/design/log.md b/design/log.md index 5961ff5..c208985 100644 --- a/design/log.md +++ b/design/log.md @@ -49,3 +49,10 @@ - Ambiguous, cancelled, zero-damage, environmental, and self damage do not opt out a player. - Combat opt-out bypasses administrative locks, immediately reconciles effect and prefix state, persists once, and sends one explanatory message. - Verified attribution, victim safety, lock bypass, duplicate suppression, and the full build with `./gradlew clean check jar`. + +### US-004 onboarding completed + +- Join observation now sends the configurable welcome message on each login before the original first-join instant plus the configured calendar-day window. +- The exact expiration boundary and globally disabled behavior suppress reminders without resetting first-join state. +- The default message names all player commands and explains automatic combat opt-out. +- Verified repeated joins, expiration, global suppression, first-join retention, and the full build with `./gradlew clean check jar`. diff --git a/design/user-stories/us-004-introduce-new-players-to-leaf.md b/design/user-stories/us-004-introduce-new-players-to-leaf.md index 30c7f0c..13e5ae9 100644 --- a/design/user-stories/us-004-introduce-new-players-to-leaf.md +++ b/design/user-stories/us-004-introduce-new-players-to-leaf.md @@ -2,7 +2,7 @@ type: User Story title: "US-004: Introduce new players to Leaf" description: Remind players how to control Leaf during their first seven calendar days on the server. -status: backlog +status: done --- # US-004: Introduce new players to Leaf @@ -11,14 +11,14 @@ As a **new player**, I want a brief explanation of Leaf when I join so that I kn ## Acceptance criteria -- [ ] Leaf records the first observed join time for each player by UUID as an RFC 3339 UTC timestamp. -- [ ] On every login before the first-join timestamp plus seven calendar days, the player receives a concise welcome message explaining Leaf protection. -- [ ] The welcome message tells the player to use `/leaf on`, `/leaf off`, and `/leaf status`. -- [ ] The message makes clear that attacking another player automatically opts the attacker out. -- [ ] Players no longer receive the welcome message once their seven-day onboarding period expires. -- [ ] Existing first-join timestamps are not reset by logout, restart, opt-in changes, or global disablement. -- [ ] No onboarding reminders are shown while Leaf is globally disabled. -- [ ] Re-enabling Leaf allows reminders to resume on subsequent logins only for players whose original seven-day period has not expired. +- [x] Leaf records the first observed join time for each player by UUID as an RFC 3339 UTC timestamp. +- [x] On every login before the first-join timestamp plus seven calendar days, the player receives a concise welcome message explaining Leaf protection. +- [x] The welcome message tells the player to use `/leaf on`, `/leaf off`, and `/leaf status`. +- [x] The message makes clear that attacking another player automatically opts the attacker out. +- [x] Players no longer receive the welcome message once their seven-day onboarding period expires. +- [x] Existing first-join timestamps are not reset by logout, restart, opt-in changes, or global disablement. +- [x] No onboarding reminders are shown while Leaf is globally disabled. +- [x] Re-enabling Leaf allows reminders to resume on subsequent logins only for players whose original seven-day period has not expired. ## Related diff --git a/src/main/java/games/dmg/leaf/LeafRuntime.java b/src/main/java/games/dmg/leaf/LeafRuntime.java index 0444a51..7359ac6 100644 --- a/src/main/java/games/dmg/leaf/LeafRuntime.java +++ b/src/main/java/games/dmg/leaf/LeafRuntime.java @@ -2,6 +2,7 @@ package games.dmg.leaf; import java.io.IOException; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Objects; import java.util.UUID; import org.bukkit.Server; @@ -51,6 +52,11 @@ public final class LeafRuntime { ); stateManager.saveIfDirty(); reconcile(player); + LeafSettings settings = settingsProvider.current(); + Instant onboardingEnd = state.firstJoin().plus(settings.onboardingDays(), ChronoUnit.DAYS); + if (settings.enabled() && observedAt.isBefore(onboardingEnd)) { + player.sendMessage(LeafText.color(settings.welcomeMessage())); + } return state; } diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java index 9089486..bbd0442 100644 --- a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -43,6 +44,49 @@ final class LeafRuntimeTest { verify(protection, org.mockito.Mockito.times(2)).apply(player, 1); } + @Test + void welcomesPlayersOnEachJoinOnlyDuringTheConfiguredWindow() throws Exception { + UUID playerId = UUID.randomUUID(); + Player player = player(playerId, "Alex"); + Server server = mock(Server.class); + when(server.getPlayer(playerId)).thenReturn(player); + LeafRuntime runtime = runtime( + server, + mock(LeafProtection.class), + temporaryDirectory.resolve("welcome.yml") + ); + Instant firstJoin = Instant.parse("2026-08-01T00:00:00Z"); + + runtime.observe(player, firstJoin); + runtime.observe(player, firstJoin.plusSeconds(6 * 86_400L)); + runtime.observe(player, firstJoin.plusSeconds(7 * 86_400L)); + + verify(player, times(2)).sendMessage(contains("/leaf on, /leaf off, or /leaf status")); + verify(player, times(2)).sendMessage(contains("Attacking another player opts you out")); + assertEquals(firstJoin, runtime.status(playerId).firstJoin()); + } + + @Test + void suppressesWelcomeWhileLeafIsGloballyDisabled() throws Exception { + UUID playerId = UUID.randomUUID(); + Player player = player(playerId, "Alex"); + Server server = mock(Server.class); + when(server.getPlayer(playerId)).thenReturn(player); + LeafRuntime runtime = new LeafRuntime( + server, + new LeafSettingsProvider(LeafSettings.from(Map.of("enabled", false))), + new LeafStateManager(new YamlLeafStateRepository( + temporaryDirectory.resolve("disabled-welcome.yml") + )), + mock(LeafProtection.class), + mock(LeafIdentity.class) + ); + + runtime.observe(player, Instant.parse("2026-08-01T00:00:00Z")); + + verify(player, never()).sendMessage(contains("Leaf protection is available")); + } + @Test void lockedPlayerCannotChangeTheirChoice() throws Exception { UUID playerId = UUID.randomUUID();