Compare commits

..
1 Commits
Author SHA1 Message Date
dmg 8cfee3ce39 feat(tyrant): migrate distribution to Java 25 and Purpur
Release / release (push) Successful in 6m13s
CI / build (push) Successful in 1m24s
BREAKING CHANGE: New releases require Java 25/Purpur 26.2 and use purpur-tyrant-<version>.jar. Runtime SpigotTyrant identity, persisted state and item keys remain unchanged. Replace the old JAR rather than installing both distributions.
2026-09-12 13:03:37 -04:00
11 changed files with 105 additions and 24 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Validate conventional commits - name: Validate conventional commits
@@ -35,6 +35,6 @@ jobs:
- name: Upload development artifact - name: Upload development artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: spigot-tyrant-${{ github.sha }} name: purpur-tyrant-${{ github.sha }}
path: build/libs/*.jar path: build/libs/*.jar
if-no-files-found: error if-no-files-found: error
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Set up Node.js - name: Set up Node.js
@@ -86,8 +86,8 @@ jobs:
if: steps.release.outputs.created == 'true' if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: spigot-tyrant-${{ steps.release.outputs.version }} name: purpur-tyrant-${{ steps.release.outputs.version }}
path: build/libs/spigot-tyrant-${{ steps.release.outputs.version }}.jar path: build/libs/purpur-tyrant-${{ steps.release.outputs.version }}.jar
if-no-files-found: error if-no-files-found: error
- name: Create Gitea release and upload plugin - name: Create Gitea release and upload plugin
@@ -101,7 +101,7 @@ jobs:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }} PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
run: | run: |
api_url="${GITEA_SERVER_URL}/api/v1" api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/spigot-tyrant-${VERSION}.jar" jar="build/libs/purpur-tyrant-${VERSION}.jar"
export RELEASE_BODY export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD") RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
@@ -138,4 +138,4 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \ --data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-tyrant-${VERSION}.jar" "${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-tyrant-${VERSION}.jar"
+3 -3
View File
@@ -1,9 +1,9 @@
# spigot-tyrant agent entrypoint # purpur-tyrant agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md). The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-tyrant/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-tyrant/` stories. Also follow the parent workspace `AGENTS.md` when present. Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-tyrant/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-tyrant/` stories. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-tyrant/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access. For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tyrant/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push. Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+21
View File
@@ -0,0 +1,21 @@
# Purpur Tyrant
The asymmetric Tyrant/Vigilante game plugin, targeting Java 25 and Purpur 26.2 build 2618.
## Build and releases
```sh
./gradlew clean check jar
```
New artifacts use `build/libs/purpur-tyrant-<version>.jar`. Gitea CI checks pushes/pull requests; approved conventional commits on main drive versioned releases. Distribution tests inspect the actual JAR, version, Java 25 bytecode, retained runtime identity and workflow names.
## Migration compatibility
The repository and checkout are now `purpur-tyrant`, previously `spigot-tyrant`. Runtime identity remains **SpigotTyrant**, including `plugins/SpigotTyrant/`, Java entrypoints/packages, command/permission names and persistent keys for roles, captured mobs and legacy items. The migration changes the build/distribution target, not gameplay or persisted schemas. New releases no longer support Java 17.
Replace the old plugin JAR rather than installing both distributions. Old tags/assets remain available; both the origin GitOps v1.24.0 and separately prepared local v1.25.0 URLs were verified to redirect and download identical bytes after the repository rename. No deployment or GitOps push is implied by publication.
Unrelated local testing files are preserved; their commands may still reference the historical artifact names.
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tyrant/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-tyrant/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
+9 -5
View File
@@ -9,23 +9,24 @@ version = providers.gradleProperty("releaseVersion")
repositories { repositories {
mavenCentral() mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") maven("https://repo.purpurmc.org/snapshots/")
} }
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(17) languageVersion = JavaLanguageVersion.of(25)
} }
} }
tasks.withType<JavaCompile>().configureEach { tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) // Retain supported legacy Bukkit APIs while preserving runtime compatibility.
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
} }
dependencies { dependencies {
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0") testImplementation("org.mockito:mockito-core:5.18.0")
@@ -35,6 +36,9 @@ dependencies {
tasks.test { tasks.test {
useJUnitPlatform() useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
} }
val pluginVersion = version val pluginVersion = version
+1 -1
View File
@@ -1 +1 @@
rootProject.name = "spigot-tyrant" rootProject.name = "purpur-tyrant"
@@ -33,6 +33,7 @@ class BukkitRoleControlItemServiceTest {
void setup() { void setup() {
Plugin plugin = mock(Plugin.class); Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn("SpigotTyrant"); when(plugin.getName()).thenReturn("SpigotTyrant");
when(plugin.namespace()).thenReturn("spigottyrant");
service = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of())); service = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of()));
when(player.getUniqueId()).thenReturn(owner); when(player.getUniqueId()).thenReturn(owner);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
@@ -79,6 +79,7 @@ class CapturedMobInventorySynchronizationTest {
when(view.getCursor()).thenAnswer(call -> cursor.get()); when(view.getCursor()).thenAnswer(call -> cursor.get());
Plugin plugin = mock(Plugin.class); Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn("SpigotTyrant"); when(plugin.getName()).thenReturn("SpigotTyrant");
when(plugin.namespace()).thenReturn("spigottyrant");
items = new BukkitCapturedMobItemService(plugin, PluginSettings.from(Map.of())); items = new BukkitCapturedMobItemService(plugin, PluginSettings.from(Map.of()));
egg = egg(owner, captureId); egg = egg(owner, captureId);
contents[3] = egg; contents[3] = egg;
@@ -0,0 +1,53 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class PurpurDistributionTest {
@Test
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-tyrant-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotTyrant", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigottyrant.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
}
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/spigottyrant/SpigotTyrantPlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort());
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-tyrant-"), workflow);
assertFalse(content.contains("spigot-tyrant-"), workflow);
}
}
}
@@ -27,7 +27,7 @@ final class RoleArenaLifecycleTest {
void vacantArenaWithoutNearbyPlayersDoesNotSpawnABoss() { void vacantArenaWithoutNearbyPlayersDoesNotSpawnABoss() {
Fixture f = new Fixture(); Fixture f = new Fixture();
f.controller.run(); f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any()); verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
} }
@org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.ParameterizedTest
@@ -39,7 +39,7 @@ final class RoleArenaLifecycleTest {
} }
Player player = f.player(18.01, 64, 0); Player player = f.player(18.01, 64, 0);
f.controller.run(); f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any()); verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
when(player.getLocation()).thenReturn(new Location(f.world, 18, 64, 0)); when(player.getLocation()).thenReturn(new Location(f.world, 18, 64, 0));
f.controller.run(); f.controller.run();
f.controller.run(); f.controller.run();
@@ -61,11 +61,11 @@ final class RoleArenaLifecycleTest {
when(f.world.isChunkLoaded(0, 0)).thenReturn(false); when(f.world.isChunkLoaded(0, 0)).thenReturn(false);
f.controller.run(); f.controller.run();
verify(f.world, never()).getChunkAt(0, 0); verify(f.world, never()).getChunkAt(0, 0);
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any()); verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
when(f.world.isChunkLoaded(0, 0)).thenReturn(true); when(f.world.isChunkLoaded(0, 0)).thenReturn(true);
when(f.chunk.isEntitiesLoaded()).thenReturn(false); when(f.chunk.isEntitiesLoaded()).thenReturn(false);
f.controller.run(); f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any()); verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
when(f.chunk.isEntitiesLoaded()).thenReturn(true); when(f.chunk.isEntitiesLoaded()).thenReturn(true);
f.controller.run(); f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size()); org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
@@ -132,7 +132,7 @@ final class RoleArenaLifecycleTest {
f.player(15, 64, 0); f.player(15, 64, 0);
Zombie rejected = f.taggedBoss(); Zombie rejected = f.taggedBoss();
f.entities.remove(rejected.getUniqueId()); f.entities.remove(rejected.getUniqueId());
when(f.world.spawn(any(Location.class), eq(Zombie.class), any())).thenReturn(rejected); when(f.world.spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any())).thenReturn(rejected);
f.controller.run(); f.controller.run();
verify(rejected).remove(); verify(rejected).remove();
verify(f.world, never()).spawnParticle(eq(org.bukkit.Particle.PORTAL), any(Location.class), verify(f.world, never()).spawnParticle(eq(org.bukkit.Particle.PORTAL), any(Location.class),
@@ -236,13 +236,13 @@ final class RoleArenaLifecycleTest {
void failedSpawnLeavesNoOwnershipAndBacksOffBeforeRetry() { void failedSpawnLeavesNoOwnershipAndBacksOffBeforeRetry() {
Fixture f = new Fixture(); Fixture f = new Fixture();
f.player(15, 64, 0); f.player(15, 64, 0);
when(f.world.spawn(any(Location.class), eq(Zombie.class), any())) when(f.world.spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any()))
.thenThrow(new IllegalStateException("spawn rejected")); .thenThrow(new IllegalStateException("spawn rejected"));
org.junit.jupiter.api.Assertions.assertDoesNotThrow(f.controller::run); org.junit.jupiter.api.Assertions.assertDoesNotThrow(f.controller::run);
for (int i = 0; i < 9; i++) { for (int i = 0; i < 9; i++) {
f.controller.run(); f.controller.run();
} }
verify(f.world).spawn(any(Location.class), eq(Zombie.class), any()); verify(f.world).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
org.junit.jupiter.api.Assertions.assertEquals(0, f.entities.size()); org.junit.jupiter.api.Assertions.assertEquals(0, f.entities.size());
f.controller.close(); f.controller.close();
} }
@@ -385,7 +385,7 @@ final class RoleArenaLifecycleTest {
PlayerState.newPlayer(player.getUniqueId(), "Visitor"))); PlayerState.newPlayer(player.getUniqueId(), "Visitor")));
return states; return states;
}); });
when(world.spawn(any(Location.class), eq(Zombie.class), any())).thenAnswer(call -> { when(world.spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any())).thenAnswer(call -> {
Zombie boss = newZombie(); Zombie boss = newZombie();
spawned.add(boss); spawned.add(boss);
java.util.function.Consumer<Zombie> initializer = call.getArgument(2); java.util.function.Consumer<Zombie> initializer = call.getArgument(2);
@@ -225,6 +225,7 @@ class RoleControlInventoryMovementTest {
when(view.getCursor()).thenAnswer(call -> cursor.get()); when(view.getCursor()).thenAnswer(call -> cursor.get());
Plugin plugin = mock(Plugin.class); Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn("SpigotTyrant"); when(plugin.getName()).thenReturn("SpigotTyrant");
when(plugin.namespace()).thenReturn("spigottyrant");
var items = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of())); var items = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of()));
var manager = mock(TyrantStateManager.class); var manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(new GameState(GameLifecycle.RUNNING, when(manager.game()).thenReturn(new GameState(GameLifecycle.RUNNING,