Author SHA1 Message Date
besiece a202565341 Add command permissions for dimension scheduling and Nether access management 2026-11-08 23:27:46 +01:00
besiece c36b0a02fa Update dimension-schedule.yml to configure Nether opening timings and associated sound effects 2026-11-08 23:17:43 +01:00
besiece fe3af32f89 Implement Marked Nether Portal System
- Introduced MarkedNetherPortal and MarkedNetherPortals classes to manage the marking and activation of Nether portals.
- Added PortalMarkStore for persistent storage of marked portals in YAML format.
- Created NetherPortalIgniter for a special item that allows marking portals in creative mode.
- Enhanced NetherAccess and NetherAccessListener to integrate the new portal marking system.
- Implemented commands and event handling for marking portals and managing their states.
- Added validation and chunk loading mechanisms to ensure proper portal functionality.
2026-11-08 22:29:23 +01:00
besiece 895d2bc2d3 Implement Nether access management and enhance dimension scheduling
- Added `NetherAccess` class to manage Nether access state and commands.
- Introduced `NetherOpeningSchedule` for polling Nether opening times.
- Created `NetherAccessListener` to handle portal events and deny access when closed.
- Implemented `NetherCommand` for public information about Nether access.
- Added `NetherPreventCommand` for controlling Nether access via commands.
- Enhanced `DimensionScheduleText` to include Nether-specific messages and colors.
- Refactored `EndOpeningSchedule` to utilize `OpeningAnnouncements` for managing announcements.
- Updated `DimensionScheduleCommand` to display Nether opening times.
- Improved README documentation to reflect new Nether functionality and commands.
2026-11-08 21:53:49 +01:00
besiece c747c3d1b6 Add nether activation messages and opening effects to dimension schedule 2026-11-08 20:55:00 +01:00
besiece ad8a2991f3 Update dimension-schedule.yml for end opening timings and sound effects volume adjustments 2026-11-08 20:53:04 +01:00
besiece e558ae3fac Refactor DimensionScheduler to support sound settings in message groups and effects 2026-11-08 20:44:55 +01:00
besiece 23d8fc85de Add EndCommand and DimensionScheduleText for improved End dimension management and messaging 2026-11-08 20:15:16 +01:00
besiece 63dbefdde8 Refactor EndPreventListener and EndOpeningEffects for improved message handling and consistency in chat notifications 2026-11-08 20:07:42 +01:00
besiece 20616f1e02 Enhance DimensionScheduler and EndOpeningEffects with dynamic effect handling and YAML configuration support 2026-11-08 19:47:21 +01:00
besiece f3f19a3370 Enhance DimensionScheduler and EndOpeningSchedule with load time tracking and state restoration logic 2026-11-08 19:35:22 +01:00
besiece 45685c0c13 Refactor End dimension access management and enhance announcement system 2026-11-08 19:23:45 +01:00
besiece 02d396db68 Implement EndOpeningSchedule for managing End dimension access timing 2026-11-08 18:55:11 +01:00
besiece 3b43b594f4 Add DimensionScheduler and DimensionScheduleCommand for managing dimension access timings 2026-11-08 18:42:32 +01:00
MineTec d18ddc7b07 Remove outdated outlawed PvP system and related configurations 2026-10-29 19:51:36 +01:00
MineTec b06ba40d16 Merge pull request 'added combined info bar for TPS, MSPT and online player count' (#17) from feature/combinedInfoBar into master
Reviewed-on: #17
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2026-10-28 11:57:53 +00:00
jannis 3eca590c6f added combined info bar for TPS, MSPT and online player count 2026-10-18 23:31:10 +02:00
MineTec f6ac150a8f arena: challenger idling 1 min in the options gets sent back to the tribune 2026-10-18 17:53:13 +02:00
MineTec 92a655e107 arena: full pads wait for the challenger to close the options (max 20 s) instead of closing them 2026-10-18 17:50:16 +02:00
MineTec 77a740edb7 arena: leaving the box during a fight (e.g. ender pearl glitch) counts as knocked out 2026-10-18 17:06:14 +02:00
MineTec 4a87bb3fad chat settings are synced with velocity again (join/leave, mentions) 2026-10-17 22:57:42 +02:00
MineTec ca0e364d85 chat moves to the velocity plugin chat-bridge: backend only silences chat and sends rank prefixes 2026-10-17 22:41:48 +02:00
MineTec fb88856925 Merge branch 'feature/arena' 2026-10-15 23:40:25 +02:00
MineTec 3882f64289 Merge remote-tracking branch 'origin/master' 2026-10-15 23:40:25 +02:00
MineTec 6bda5b6adb arena options stay changeable until the countdown and reopen with a right click 2026-10-15 23:25:21 +02:00
MineTec ae3bde626f added team fights up to 4 vs 4 to the arena 2026-10-15 23:21:20 +02:00
MineTec 4add446940 doubled the size of arena status displays 2026-10-15 23:08:19 +02:00
jannis d03a21d460 changed way to unlock Quarz Recipe 2026-10-15 23:07:26 +02:00
MineTec a91f550153 fixed duplicated display villagers when entities were not loaded yet 2026-10-15 22:55:54 +02:00
MineTec b8ba85f8c5 widened arena spectator range 2026-10-15 22:52:07 +02:00
MineTec 65c48eae45 fixed arena transfer with empty inventory slots and blocked advancements in the arena 2026-10-15 22:42:03 +02:00
MineTec c6bf0b0163 Merge branch 'feature/banEvasion' 2026-10-15 22:33:48 +02:00
MineTec 8b616ba2a2 added Arena appliances for fights on a separate server 2026-10-15 22:33:48 +02:00
MineTec 6590513d56 simplified BanEvasion 2026-10-14 21:07:27 +02:00
MineTec 0800793e64 added lookup indexes to login attempts 2026-10-14 21:07:27 +02:00
MineTec 882bacf57b added optional request timeout to http repositories 2026-10-14 21:07:27 +02:00
MineTec ddcbdbaec5 fixed dropped database statements never completing their query 2026-10-14 21:07:27 +02:00
MineTec 7bd1e13660 reworked household and exemption dialogs 2026-10-14 17:16:13 +02:00
MineTec 974c5c5200 confirmed ban evasion cases stay closed 2026-10-14 17:16:13 +02:00
MineTec 66dace7f2a added timeouts to ban evasion lookups 2026-10-14 17:12:24 +02:00
MineTec f4faf763e3 fixed database queries never completing on runtime errors 2026-10-14 17:12:24 +02:00
MineTec ee265a858c made network migration repeatable 2026-10-14 17:06:18 +02:00
MineTec 84c3e7df13 added BanEvasion appliance 2026-10-14 17:04:33 +02:00
MineTec 675d87f6e9 added optional permission for appliance commands 2026-10-14 17:04:06 +02:00
MineTec 86ae261c60 added connection key and recorded event to LoginLog 2026-10-14 17:00:11 +02:00
MineTec 095db7e796 added website endpoint to LoginLog for reporting signup and login IPs 2026-10-14 14:47:59 +02:00
MineTec 490d8b9891 added floodgate uuid check to bedrock utils 2026-10-14 14:47:59 +02:00
MineTec 097222a565 fixed api error responses failing to serialize exceptions 2026-10-14 14:47:59 +02:00
MineTec 6bfb14db2b added LoginLog appliance recording login attempts with IP 2026-10-14 14:38:54 +02:00
MineTec e3de258124 added shared database access and file based migrations to core 2026-10-14 14:38:54 +02:00
MineTec d08fd927dc Merge pull request 'added Anti Player Loot despawn' (#16) from feature/AntiPlayerLootDespawn into master
Reviewed-on: #16
2026-10-14 10:31:28 +00:00
MineTec 067a6f41dc Merge branch 'master' into feature/AntiPlayerLootDespawn 2026-10-14 12:31:17 +02:00
MineTec 5b7910b697 Renamed method AboveMaxAge to isItemAboveMaxAge 2026-10-14 12:31:05 +02:00
jannis 1180af64a1 moved logic from listener to appliance 2026-10-14 01:23:32 +02:00
jannis 73fa6649af fixed issues in AntiPlayerLootDespawn 2026-10-14 00:14:08 +02:00
MineTec 9dbee7d63b Merge pull request 'added New Year Firework' (#15) from feature/NewYearFirework into master
Reviewed-on: #15
2026-10-13 21:58:03 +00:00
MineTec 2c8c4fbf87 Merge branch 'master' into feature/NewYearFirework 2026-10-13 21:57:52 +00:00
jannis 252758797d fixed issues in NewYearFirework and QuarzRecipe 2026-10-13 22:52:42 +02:00
MineTec 46ee734bbc Refactored game rule handling in ProjectStart to use a unified GameRuleSetting record, eliminating redundant methods and improving code clarity. 2026-10-13 22:41:14 +02:00
jannis a1ab06a80d fixed game rules 2026-10-13 22:29:33 +02:00
MineTec 1b79e35bd2 Merge branch 'master' into feature/NewYearFirework 2026-10-13 20:20:51 +00:00
MineTec c3b30e4329 Merge branch 'master' into feature/AntiPlayerLootDespawn 2026-10-13 20:05:19 +00:00
jannis cf3fd2f38f changed old gamerules to new and added quarz recipe 2026-10-13 21:41:00 +02:00
jannis c119913071 added Anti Player Loot despawn 2026-10-08 22:46:05 +02:00
MineTecandClaude Opus 5.5 7d0a28b015 updated to Paper 26.2, Java 25 and Gradle 9.6.1
- paper-api 26.2.build.129-stable (all modules), Java toolchain 25, shadow 9.6.1
- fixed NoSuchFieldError on join/quit/chat: Adventure 5 ClickEvent API
  (ClickEvent.suggestCommand / openUrl instead of ClickEvent.Action constants)
- fixed NBTComponent generics in AntiFormattedBook
- replaced javax.annotation with org.jetbrains.annotations (no longer provided by paper-api)
- local.gradle.example: Gradle 9 compatible (archiveFile, providers.exec)

The previous state is tagged as paper-1.21.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:44:13 +02:00
jannis 62a42e561e added New Year Firework 2026-10-06 20:48:33 +02:00
Pupsi 9dad34719b removed Exception throw from CustomAdvancements, fixed time reset at project start 2026-07-15 16:54:44 +02:00
Pupsi 323e316f0f fixed bloodmoon sleep / time skip issue, added endermite hordes for end, fixed server restart during bloodmoon issue 2026-01-17 17:09:54 +01:00
Pupsi 0a16e1e049 added missing setting check for bloodmoon warning messages 2026-01-09 19:25:10 +01:00
MineTec c371893407 added support for portable stonecutter alongside crafting table, renamed listener for clarity 2026-01-02 22:29:27 +01:00
MineTec f2bf8f1858 fixed username validation to allow exceptions for Bedrock players due to specific naming constraints 2025-12-29 09:05:57 +01:00
MineTec a0a33f1f56 filtered null and duplicate player names in ReportCommand responses for improved reliability 2025-12-28 17:36:01 +01:00
Pupsi 164a160dbb fixed bloodmoon hordes for players with deactivated setting 2025-12-28 17:21:33 +01:00
MineTec efdbc6fe9f updated feedback link behavior, disabled outdated appliances, adjusted infection spawn and silverfish mechanics 2025-12-28 11:40:21 +01:00
Pupsi 7ac02b4ec4 added missing return for /event 2025-12-27 11:35:20 +01:00
MineTec 93971650ce adjusted strike punishment durations for better balance and scaling 2025-12-27 11:08:47 +01:00
MineTec 336e3f934c remove wrong location description 2025-12-27 00:47:13 +01:00
Pupsi 91e350ee62 disabled worldmuseum for craftattack 8 2025-12-26 19:50:59 +01:00
MineTec 8736e78adf Merge remote-tracking branch 'origin/master' 2025-12-25 22:49:19 +01:00
MineTec 329ec8e1da renamed adminPermission constant to adminmarker for improved clarity and consistency 2025-12-25 22:49:15 +01:00
Pupsi ebf392b024 fixed bloodmoon progress bar trying updates when inactive 2025-12-24 14:26:23 +01:00
Pupsi 33d00e97c6 fixed recovery compass at project start 2025-12-24 01:07:53 +01:00
Pupsi 1fdcc11211 Merge pull request 'master-big-events' (#11) from master-big-events into master
Reviewed-on: #11
2025-12-23 23:54:14 +00:00
Pupsi e0ed7ecdf5 Merge branch 'master' into master-big-events 2025-12-23 23:53:42 +00:00
Pupsi 2ca97c88fc made score staying the highest ever reached 2025-12-24 00:49:50 +01:00
Pupsi fc502e3da8 fixed player join when no event is loaded 2025-12-24 00:16:22 +01:00
Pupsi 5e3db1e78c fixed event commands, removed infinite saturation, added player join check 2025-12-23 23:40:58 +01:00
MineTec fc6fd9ebb5 Merge branch 'master-big-events' 2025-12-23 22:34:51 +01:00
MineTec 84edbcc0e4 Merge branch 'master-big-events' 2025-12-23 22:34:33 +01:00
Pupsi 9ec883d6ad made Scoreboard more beautiful 2025-12-23 22:33:32 +01:00
Pupsi 2b0c7c1a9e added countdown and sounds 2025-12-23 21:43:25 +01:00
MineTec df39093c69 enhanced Outlawed PVP activation message to include cooldown duration; fixed minor typo in forced status message 2025-12-23 21:32:35 +01:00
MineTec dfbf87dcd4 updated ProjectStart to adjust coordinates, music, and glass material; refined player inventory handling logic; renamed vogelfrei to PVP in Outlawed messages for clarity 2025-12-23 21:07:32 +01:00
MineTec 8953a19400 added Bedrock player warning in joinEvent method to handle potential compatibility issues 2025-12-23 19:52:17 +01:00
MineTec 85065bcc73 made SignEditListener formatting consistent and optimized ReportJoinListener to handle admin notifications asynchronously 2025-12-23 19:14:53 +01:00
Pupsi 2209b42766 fixed wrong reports info filtering 2025-12-23 19:11:59 +01:00
Pupsi bf11bb0b70 fixed wrong reports info filtering 2025-12-23 19:05:53 +01:00
Pupsi b98d33af40 added report reminder on admin join 2025-12-23 18:56:30 +01:00
Pupsi 0d6b21701f Merge remote-tracking branch 'origin/master' 2025-12-23 15:50:41 +01:00
Pupsi b3240cdb22 fixed maximum of 8 settings per category 2025-12-23 15:50:23 +01:00
MineTec 89c1c4335b refined SignEditListener to filter illegal characters more robustly by normalizing input and introducing specific character whitelists 2025-12-22 21:41:34 +01:00
MineTec 71f2da8e99 introduced AntiIllegalSignCharacters appliance to prevent usage of illegal characters in sign edits 2025-12-22 18:44:22 +01:00
MineTec a257b604ea updated InventoryTrackerListener to monitor InventoryOpenEvent with high priority and prevent processing of cancelled events 2025-12-21 09:32:40 +01:00
Pupsi ef153d5d8f added invincibility before event start, added join craftattack command, added player teleport, portal and pvp listeners to deathrun 2025-12-20 23:13:14 +01:00
Pupsi bd883a4fa1 added big events in craftattack plugin 2025-12-20 18:23:06 +01:00
MineTec ac7e04829e introduced ReportCreatedListener, updated ReportCreatedEvent to use UUIDs for reporter and reported, and improved admin notifications for new reports 2025-12-20 17:46:02 +01:00
Pupsi 5cda58408a Merge remote-tracking branch 'origin/master' into master-big-events 2025-12-20 17:45:26 +01:00
MineTec 9767896cde updated OutlawedCommand and Outlawed to rename vogelfrei to PVP, adjusted related messages accordingly 2025-12-20 11:51:30 +01:00
Pupsi f0e0cfbb85 fixed scoreboard order 2025-12-19 14:33:01 +01:00
Pupsi de112f7e13 added timer 2025-12-19 14:04:36 +01:00
Pupsi 215259c6b9 added 'this' qualifier in Bloodmoon 2025-12-19 13:01:01 +01:00
Pupsi 36520a87f9 moved automatic scoreboard update start to EventController 2025-12-19 12:58:10 +01:00
Pupsi 914aaff10b added deathrun listener for border; fixed listener problem when disabling appliance 2025-12-13 16:02:23 +01:00
MineTec e015bbb356 added unsuspectedKick and crashKick features to Kick appliance; updated AcInform to include related commands 2025-12-13 15:00:48 +01:00
Pupsi ec262710ec removed possibility for multiple events 2025-12-13 13:50:42 +01:00
MineTec 1ac19014c1 introduced AntiInventoryMove appliance with related listeners and optimized admin notification logic in AcInform 2025-12-13 13:45:37 +01:00
Pupsi dd1518fce4 split Event into Scorable and Event; removed AbstractEvent; added list, select and unselect commands 2025-12-12 19:30:21 +01:00
Pupsi 04cb233604 WIP: moved scores and score updates to EventScoreboardBuilder 2025-12-12 12:09:25 +01:00
Pupsi 2ff95f8450 changed scoreboard package name to lowercase 2025-12-02 12:52:18 +01:00
Pupsi 6ed48895ca made appliance ignore destruct when no listeners 2025-12-01 22:29:13 +01:00
Pupsi bff8cf24cd added AbstractEvent and basic Scoreboard functionality 2025-12-01 22:28:15 +01:00
Pupsi 212f84b6de added start, stop and getScore methods to Event 2025-11-30 18:07:15 +01:00
Pupsi f7430c8fc8 added possibility to disable appliances 2025-11-28 21:08:27 +01:00
Pupsi c81a2d2161 started big events 2025-11-28 16:52:42 +01:00
MineTec 2087b4c379 Merge pull request 'develop-bloodmoon' (#10) from develop-bloodmoon into master
Reviewed-on: #10
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2025-11-23 14:03:06 +00:00
Pupsi 4d9548aafc resolved pr comments 2025-11-23 15:00:16 +01:00
Pupsi 4c63800189 Merge branch 'master' into develop-bloodmoon 2025-11-23 12:58:45 +00:00
Pupsi b8725bc0f2 made bossbar shrink until end of bloodmoon, fixed horde spawning 2025-11-23 13:58:02 +01:00
Pupsi c90d767f0f added todos 2025-11-21 09:12:17 +01:00
Pupsi 143f4ee8eb adjusted bloodmoon settings 2025-11-21 09:03:43 +01:00
MineTec ba2befb467 refactored event hierarchy; replaced SpawnEvent with direct Event implementation, added StrikeUpdateListener and refactored whitelist profile update logic 2025-11-16 12:02:49 +01:00
MineTec 7a2b9b9763 updated Paper API dependency to 1.21.10-R0.1-SNAPSHOT across all modules 2025-11-16 12:00:58 +01:00
MineTec 0b9dc5358d added HTTP hooks framework with actions for signup, report, and strike events; introduced SpawnEvent support for event broadcasting 2025-11-15 12:50:01 +01:00
MineTec 448e9472db updated outlawed info messages 2025-11-09 20:36:21 +01:00
MineTec 933c4c0db0 refactored InfoBars handling logic; replaced persistent data with direct visibility control 2025-11-09 19:32:44 +01:00
MineTec f27474016a added @Flags annotation to Appliance, disabled DeviceFingerprinting appliance by default 2025-11-09 19:31:25 +01:00
MineTec 17e5b2e049 Merge branch 'develop-backendUpdate' 2025-11-09 19:21:39 +01:00
MineTec d3512cb2eb Merge remote-tracking branch 'origin/master' 2025-11-09 19:13:34 +01:00
MineTec 7b19bfd39e Merge branch 'develop-deviceFingerprinting' 2025-11-09 19:13:18 +01:00
MineTec 0ab67bb426 refactored strike handling logic; added ban determination and updated whitelist integration 2025-11-09 19:13:08 +01:00
MineTec 29a362b580 updated default shortcut setting value 2025-11-09 16:08:28 +01:00
Pupsi a7f298682b Merge pull request 'develop-statisticsApi' (#9) from develop-statisticsApi into master
Reviewed-on: #9
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2025-11-08 18:36:41 +00:00
Pupsi 895a51e71a Merge branch 'master' into develop-statisticsApi 2025-11-08 18:28:10 +00:00
Pupsi 4a5c24235b added player display name to StatisticsResponse 2025-11-07 21:46:16 +01:00
Pupsi 62c0250049 added null value check for material 2025-11-07 21:27:33 +01:00
Pupsi b4ccc3c4c8 added statistics appliance in craftattack 2025-11-07 19:47:32 +01:00
MineTec 239094971c Revert "simplified event message handling logic in ChatMessagesListener"
This reverts commit db13a9f0a2.
2025-11-02 14:18:43 +01:00
Pupsi 53dbeff829 merged automatic start with hordes per player 2025-11-01 17:43:35 +01:00
Pupsi b5da06fd49 Merge remote-tracking branch 'origin/develop-bloodmoon' into develop-bloodmoon
# Conflicts:
#	craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/bloodmoon/Bloodmoon.java
2025-11-01 17:32:36 +01:00
Pupsi b55035f1f0 added timings and announcements for automatic bloodmoon start 2025-11-01 17:29:39 +01:00
MineTec 469cd19b55 added AntiBoatFreecam to detect and notify admins of illegal boat yaw behavior 2025-10-27 17:54:19 +01:00
MineTec 91a28b4500 Merge branch 'master' into develop-backendUpdate 2025-10-27 17:11:01 +01:00
MineTec e745ff4721 added AntiFormattedBook to detect and sanitize illegal book formatting 2025-10-27 17:10:44 +01:00
MineTec 23af3ff784 Merge branch 'master' into develop-backendUpdate 2025-10-27 16:14:24 +01:00
MineTec bc5c9a2a13 added AntiIllegalBundlePicker to track and notify admins on illegal bundle interactions 2025-10-27 16:02:13 +01:00
MineTec c220479052 WIP: refactored report and feedback systems; updated repository models, APIs, and component utilities 2025-10-27 14:25:44 +01:00
MineTec 78f87d97f0 Merge remote-tracking branch 'origin/master' 2025-10-19 12:54:35 +02:00
MineTec db13a9f0a2 simplified event message handling logic in ChatMessagesListener 2025-10-19 12:54:31 +02:00
MineTec aad1fcafa6 added FingerprintData class and improved device fingerprinting logic 2025-10-05 15:46:45 +02:00
MineTec 9fca7430a8 implemented working fingerprinting prototype 2025-10-05 13:24:47 +02:00
240 changed files with 11908 additions and 1230 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
dependencies {
implementation project(':common')
implementation project(':core')
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
testImplementation platform('org.junit:junit-bom:5.13.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
@@ -0,0 +1,401 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import com.google.gson.Gson;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.util.entity.DisplayVillager;
import eu.mhsl.craftattack.spawn.core.util.listener.DismissInventoryOpenFromHolder;
import eu.mhsl.craftattack.spawn.core.util.listener.PlayerInteractAtEntityEventListener;
import eu.mhsl.craftattack.spawn.core.util.server.PluginMessage;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.Listener;
import org.bukkit.potion.PotionEffect;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Stream;
/**
* The arena server: tribune, start pads, arenas, fights and resets.
* The main server sends a copy of the player's inventory to {@code /api/arena/inventory} before connecting the player.
*/
public class Arena extends Appliance {
public enum PlayerState {
TRIBUNE,
PAD,
FIGHT,
BUILD
}
private record InventoryRequest(UUID player, String items) {
}
private final Map<UUID, PlayerState> states = new HashMap<>();
private ArenaWorlds worlds;
private ArenaSettings settings;
private Teams teams;
private ArenaResetter resetter;
private final List<ArenaSlot> slots = new ArrayList<>();
private @Nullable Lobby lobby;
private @Nullable DisplayVillager.ConfigBound villager;
private @Nullable BukkitTask tickTask;
public Arena() {
super("arena");
}
@Override
public void onEnable() {
this.worlds = new ArenaWorlds();
this.villager = new DisplayVillager.ConfigBound(this.localConfig(), villager -> {
villager.customName(Component.text("Zurück zu CraftAttack", NamedTextColor.GOLD));
villager.setProfession(Villager.Profession.CARTOGRAPHER);
villager.setVillagerType(Villager.Type.PLAINS);
});
this.villager.getVillager().addScoreboardTag(Displays.MANAGED_TAG);
this.start();
}
@Override
public void onDisable() {
this.stop();
}
/**
* Reads the settings and builds pads, arenas and displays. Separate from onEnable so setup changes
* can be applied without restarting the appliance.
*/
private void start() {
this.settings = ArenaSettings.read(this.localConfig(), this.worlds.play());
this.teams = new Teams();
this.resetter = new ArenaResetter(this.worlds, this.settings.resetBlocksPerTick());
this.settings.arenas().stream()
.filter(ArenaSettings.Slot::isComplete)
.forEach(slot -> this.slots.add(new ArenaSlot(slot, this.worlds.play())));
this.settings.arenas().stream()
.filter(slot -> !slot.isComplete())
.forEach(slot -> Main.logger().warning("Arena '" + slot.name() + "' is incomplete (region, spawn1, spawn2) and stays disabled"));
if(this.settings.padsComplete()) this.lobby = new Lobby(this, this.settings.pads());
else Main.logger().warning("Arena: pads are not set up, fights cannot be started");
this.syncRegions().forEach(region -> this.worlds.keepLoaded(region, Main.instance()));
this.removeStrayVillagers();
this.placeStarterPlatform();
this.syncAll();
Bukkit.getOnlinePlayers().stream()
.filter(player -> this.state(player) != PlayerState.BUILD)
.forEach(this::toTribune);
this.tickTask = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tick, 1, 1);
}
private void stop() {
if(this.tickTask != null) this.tickTask.cancel();
this.slots.forEach(slot -> {
if(slot.fight != null) slot.fight.abort();
slot.remove();
});
this.slots.clear();
if(this.lobby != null) this.lobby.shutdown();
this.lobby = null;
this.resetter.cancel();
this.teams.clear();
this.worlds.releaseChunks(Main.instance());
}
public void reload() {
if(this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FIGHT))
throw new ApplianceCommand.Error("Es läuft gerade ein Kampf");
if(this.lobby != null && !this.lobby.isIdle())
throw new ApplianceCommand.Error("Gerade steht jemand auf einem Startfeld");
this.stop();
this.start();
}
/**
* Copies the whole tribune area and all arenas from the template.
*/
public void syncAll() {
this.slots.forEach(slot -> slot.state = ArenaSlot.State.RESET);
List<Region> regions = this.syncRegions();
for(int i = 0; i < regions.size(); i++) {
boolean last = i == regions.size() - 1;
this.resetter.reset(regions.get(i), () -> {
if(!last) return;
this.slots.forEach(slot -> slot.state = ArenaSlot.State.FREE);
Main.logger().info("Arena: all regions synced from the template");
});
}
if(regions.isEmpty()) this.slots.forEach(slot -> slot.state = ArenaSlot.State.FREE);
}
public boolean isBusy() {
return this.slots.stream().anyMatch(slot -> slot.state != ArenaSlot.State.FREE)
|| (this.lobby != null && !this.lobby.isIdle());
}
private List<Region> syncRegions() {
return Stream.concat(
Stream.ofNullable(this.settings.syncRegion()),
this.slots.stream().map(slot -> slot.settings.region())
).toList();
}
private void tick() {
this.resetter.tick();
if(this.lobby != null) this.lobby.tick();
if(Bukkit.getCurrentTick() % 10 != 0) return;
this.slots.forEach(ArenaSlot::updateDisplay);
// nobody takes damage outside of fights, so falling into the void would never end
World play = this.worlds.play();
play.getPlayers().stream()
.filter(player -> player.getY() < play.getMinHeight() - 16 && this.state(player) != PlayerState.FIGHT)
.forEach(this::toTribune);
}
/**
* Removes old copies of the return villager, for example after its position was changed in the config by hand.
*/
private void removeStrayVillagers() {
Region region = this.settings.syncRegion();
if(region == null || this.villager == null) return;
UUID current = this.villager.getUniqueId();
for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) {
for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) {
for(Entity entity : this.worlds.play().getChunkAt(cx, cz).getEntities()) {
if(!(entity instanceof Villager) || entity.getUniqueId().equals(current)) continue;
if(!entity.getScoreboardTags().contains(Displays.MANAGED_TAG)) continue;
entity.remove();
Main.logger().info("Arena: removed a stray return villager at " + entity.getLocation().toVector());
}
}
}
}
/**
* A fresh, empty world gets a small platform at the tribune spawn, so the first visitors do not fall into the void.
*/
private void placeStarterPlatform() {
Location spawn = this.settings.tribuneSpawn();
if(spawn == null) return;
for(World world : new World[]{this.worlds.play(), this.worlds.template()}) {
Location below = spawn.clone();
below.setWorld(world);
below.subtract(0, 1, 0);
if(!below.getBlock().getType().isAir()) return;
}
for(World world : new World[]{this.worlds.play(), this.worlds.template()}) {
for(int dx = -3; dx <= 3; dx++) {
for(int dz = -3; dz <= 3; dz++) {
world.getBlockAt(spawn.getBlockX() + dx, spawn.getBlockY() - 1, spawn.getBlockZ() + dz).setType(Material.SMOOTH_STONE, false);
}
}
}
Main.logger().info("Arena: placed a starter platform at the tribune spawn");
}
boolean hasFreeSlot() {
return this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FREE);
}
void startFight(List<List<Player>> teams, FightMode mode, Map<UUID, UUID> kits) {
ArenaSlot slot = this.slots.stream().filter(s -> s.state == ArenaSlot.State.FREE).findFirst().orElse(null);
if(slot == null) {
teams.forEach(team -> team.forEach(this::toTribune));
return;
}
new Fight(this, slot, teams, mode, kits).start();
}
void resetSlot(ArenaSlot slot) {
if(!this.slots.contains(slot)) return;
slot.state = ArenaSlot.State.RESET;
this.resetter.reset(slot.settings.region(), () -> {
if(!this.slots.contains(slot)) return;
slot.state = ArenaSlot.State.FREE;
Location display = slot.settings.displayLocation(this.worlds.play());
Style.play(display, Sound.BLOCK_BEACON_ACTIVATE, 1.5f, 1.2f);
});
}
/**
* Brings the player back to the tribune with the own inventory, locked.
*/
public void toTribune(Player player) {
if(this.lobby != null) this.lobby.leave(player);
this.setState(player, PlayerState.TRIBUNE);
this.teams.leave(player);
player.closeInventory();
Location spawn = this.settings.tribuneSpawn();
player.teleport(spawn != null ? spawn : this.worlds.play().getSpawnLocation());
restore(player, GameMode.ADVENTURE);
InventoryStore.apply(player, player.getUniqueId());
}
/**
* Full health and food, no effects, no fire.
*/
static void restore(Player player, GameMode gameMode) {
player.setGameMode(gameMode);
player.getActivePotionEffects().stream().map(PotionEffect::getType).forEach(player::removePotionEffect);
player.setHealth(Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue());
player.setAbsorptionAmount(0);
player.setFoodLevel(20);
player.setSaturation(5);
player.setExhaustion(0);
player.setFireTicks(0);
player.setFreezeTicks(0);
player.setFallDistance(0);
player.setArrowsInBody(0, false);
player.setVelocity(new Vector());
}
public void toggleBuild(Player player) {
PlayerState state = this.state(player);
if(state == PlayerState.BUILD) {
Location target = player.getLocation().clone();
target.setWorld(this.worlds.play());
this.toTribune(player);
if(!this.isInsideArena(target)) player.teleport(target);
player.sendMessage(Style.message("Bau-Modus beendet. Änderungen an der Vorlage übernimmst du mit /arenasetup sync."));
return;
}
if(state != PlayerState.TRIBUNE) throw new ApplianceCommand.Error("Nur von der Tribüne aus möglich");
this.setState(player, PlayerState.BUILD);
this.teams.leave(player);
Location target = player.getLocation().clone();
target.setWorld(this.worlds.template());
player.getInventory().clear();
player.teleport(target);
player.setGameMode(GameMode.CREATIVE);
player.sendMessage(Style.message("Du bist in der Vorlage. Mit /build kommst du zurück."));
}
void moveVillager(Location location) {
Objects.requireNonNull(this.villager).updateLocation(location);
}
List<String> arenaNames() {
return this.settings.arenas().stream().map(ArenaSettings.Slot::name).toList();
}
public void sendToCraftAttack(Player player) {
PluginMessage.connect(player, this.settings.returnServer());
}
public void handleQuit(Player player) {
Fight fight = this.fightOf(player);
if(fight != null && fight.isAlive(player.getUniqueId())) fight.forfeit(player.getUniqueId());
if(this.lobby != null) this.lobby.leave(player);
this.teams.leave(player);
this.states.remove(player.getUniqueId());
}
public PlayerState state(Player player) {
return this.states.getOrDefault(player.getUniqueId(), PlayerState.TRIBUNE);
}
void setState(Player player, PlayerState state) {
this.states.put(player.getUniqueId(), state);
}
/**
* The fight the player is part of, also during the short celebration after it ended.
*/
public @Nullable Fight fightOf(Player player) {
return this.slots.stream()
.map(slot -> slot.fight)
.filter(fight -> fight != null && fight.involves(player.getUniqueId()))
.findFirst()
.orElse(null);
}
/**
* Whether blocks may change at this position of the play world: only inside arenas.
*/
public boolean isInsideArena(Location location) {
return this.slots.stream().anyMatch(slot -> slot.settings.region().contains(location.getX(), location.getY(), location.getZ()));
}
public @Nullable Region arenaRegionOf(Player player) {
Fight fight = this.fightOf(player);
return fight == null ? null : fight.region();
}
public @Nullable Lobby lobby() {
return this.lobby;
}
public ArenaWorlds worlds() {
return this.worlds;
}
ArenaSettings settings() {
return this.settings;
}
Teams teams() {
return this.teams;
}
@Override
public void httpApi(HttpServer.ApiBuilder apiBuilder) {
Gson gson = new Gson();
apiBuilder.rawPost("inventory", (request, response) -> {
InventoryRequest body = gson.fromJson(request.body(), InventoryRequest.class);
if(body == null || body.player() == null || body.items() == null) throw new IllegalArgumentException("player and items are required");
InventoryStore.put(body.player(), Base64.getDecoder().decode(body.items()));
return HttpServer.nothing;
});
}
@Override
@NotNull
protected List<ApplianceCommand<?>> commands() {
return List.of(new BuildCommand(), new ArenaSetupCommand(), new CraftAttackCommand());
}
@Override
@NotNull
protected List<Listener> listeners() {
UUID villager = Objects.requireNonNull(this.villager).getUniqueId();
return List.of(
new PlayerConnectionListener(),
new LockListener(),
new FightListener(),
new WorldProtectionListener(),
new ModeMenuListener(),
new NoProgressListener(),
new PlayerInteractAtEntityEventListener(villager, event -> this.sendToCraftAttack(event.getPlayer())),
new DismissInventoryOpenFromHolder(villager)
);
}
}
@@ -0,0 +1,127 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.ResetCursor;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.Container;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.util.BoundingBox;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
/**
* Copies regions from the template world over the play world. Only blocks that differ are written,
* without physics. Block entities (chests, signs, banners) are always copied, so changed contents are reset too.
* The work is spread over ticks with a fixed block budget.
*/
final class ArenaResetter {
private record Job(Region region, ResetCursor cursor, Runnable onDone) {
}
private final World play;
private final World template;
private final int blocksPerTick;
private final Queue<Job> jobs = new ArrayDeque<>();
private Job current;
ArenaResetter(ArenaWorlds worlds, int blocksPerTick) {
this.play = worlds.play();
this.template = worlds.template();
this.blocksPerTick = blocksPerTick;
}
void reset(Region region, Runnable onDone) {
this.jobs.add(new Job(region, new ResetCursor(region), onDone));
}
boolean isBusy() {
return this.current != null || !this.jobs.isEmpty();
}
void cancel() {
this.jobs.clear();
this.current = null;
}
/**
* Called every server tick.
*/
void tick() {
int budget = this.blocksPerTick;
while(budget > 0) {
if(this.current == null) {
this.current = this.jobs.poll();
if(this.current == null) return;
this.prepare(this.current.region);
}
budget -= this.current.cursor.advance(budget, this::copyBlock);
if(!this.current.cursor.isDone()) return;
this.finish(this.current.region);
Job done = this.current;
this.current = null;
try {
done.onDone.run();
} catch(Exception e) {
Main.logger().warning("Arena: error after reset: " + e.getMessage());
}
}
}
private void prepare(Region region) {
this.removeEntities(region);
// empty containers first, otherwise replacing them would drop their contents
for(BlockState state : this.blockEntities(this.play, region, false)) {
if(state instanceof Container container) container.getInventory().clear();
}
}
private void copyBlock(int x, int y, int z) {
BlockData source = this.template.getBlockAt(x, y, z).getBlockData();
Block target = this.play.getBlockAt(x, y, z);
if(!target.getBlockData().equals(source)) target.setBlockData(source, false);
}
private void finish(Region region) {
for(BlockState state : this.blockEntities(this.template, region, true)) {
state.copy(new Location(this.play, state.getX(), state.getY(), state.getZ())).update(true, false);
}
this.removeEntities(region);
}
private List<BlockState> blockEntities(World world, Region region, boolean snapshot) {
List<BlockState> states = new ArrayList<>();
for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) {
for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) {
Chunk chunk = world.getChunkAt(cx, cz);
for(BlockState state : chunk.getTileEntities(snapshot)) {
if(region.contains(state.getX(), state.getY(), state.getZ())) states.add(state);
}
}
}
return states;
}
private void removeEntities(Region region) {
BoundingBox box = new BoundingBox(
region.minX(), region.minY(), region.minZ(),
region.maxX() + 1, region.maxY() + 1, region.maxZ() + 1
);
for(Entity entity : this.play.getNearbyEntities(box)) {
if(entity instanceof Player) continue;
if(entity.getScoreboardTags().contains(Displays.MANAGED_TAG)) continue;
entity.remove();
}
}
}
@@ -0,0 +1,94 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.config.ConfigUtil;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
/**
* Positions and limits from the {@code arena} config section. All positions refer to the play world,
* no matter which world was stored.
*/
record ArenaSettings(
@Nullable Location tribuneSpawn,
@Nullable Region syncRegion,
@Nullable Region[] pads,
List<Slot> arenas,
int fightSeconds,
int countdownSeconds,
int resetBlocksPerTick,
String returnServer
) {
static final String[] PAD_KEYS = {"red", "blue"};
record Slot(String name, @Nullable Region region, @Nullable Location[] spawns, @Nullable Location display) {
boolean isComplete() {
return this.region != null && this.spawns[0] != null && this.spawns[1] != null;
}
Location displayLocation(World world) {
if(this.display != null) return this.display;
return new Location(world, this.region.centerX(), this.region.maxY() + 1.5, this.region.centerZ());
}
}
static ArenaSettings read(ConfigurationSection config, World world) {
Region[] pads = new Region[PAD_KEYS.length];
for(int i = 0; i < PAD_KEYS.length; i++) {
pads[i] = readRegion(config.getConfigurationSection("pads." + PAD_KEYS[i]));
}
List<Slot> arenas = new ArrayList<>();
ConfigurationSection arenaSection = config.getConfigurationSection("arenas");
if(arenaSection != null) {
for(String name : arenaSection.getKeys(false)) {
ConfigurationSection slot = arenaSection.getConfigurationSection(name);
if(slot == null) continue;
arenas.add(new Slot(
name,
readRegion(slot.getConfigurationSection("region")),
new Location[]{
readLocation(slot.getConfigurationSection("spawn1"), world),
readLocation(slot.getConfigurationSection("spawn2"), world)
},
readLocation(slot.getConfigurationSection("display"), world)
));
}
}
return new ArenaSettings(
readLocation(config.getConfigurationSection("tribuneSpawn"), world),
readRegion(config.getConfigurationSection("syncRegion")),
pads,
arenas,
Math.max(1, config.getInt("fightMinutes", 10)) * 60,
Math.max(1, config.getInt("countdownSeconds", 5)),
Math.max(1000, config.getInt("resetBlocksPerTick", 20000)),
config.getString("connect-server-name", "craftattack")
);
}
boolean padsComplete() {
return this.pads[0] != null && this.pads[1] != null;
}
private static @Nullable Region readRegion(@Nullable ConfigurationSection section) {
if(section == null) return null;
List<Integer> a = section.getIntegerList("pos1");
List<Integer> b = section.getIntegerList("pos2");
if(a.size() != 3 || b.size() != 3) return null;
return Region.of(a.get(0), a.get(1), a.get(2), b.get(0), b.get(1), b.get(2));
}
private static @Nullable Location readLocation(@Nullable ConfigurationSection section, World world) {
if(section == null) return null;
Location location = ConfigUtil.Position.paseLocation(section);
location.setWorld(world);
return location;
}
}
@@ -0,0 +1,116 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.config.ConfigUtil;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import org.bukkit.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* Setting up tribune, pads and arenas at the own position. Works in the play world and in the template.
* Changes take effect with {@code /arenasetup reload}.
*/
class ArenaSetupCommand extends ApplianceCommand.PlayerChecked<Arena> {
private static final String USAGE = String.join("\n",
"/arenasetup tribune",
"/arenasetup villager",
"/arenasetup pad <red|blue> <pos1|pos2>",
"/arenasetup arena <name> <pos1|pos2|spawn1|spawn2|display|remove>",
"/arenasetup syncregion <pos1|pos2>",
"/arenasetup sync",
"/arenasetup reload"
);
ArenaSetupCommand() {
super("arenasetup");
}
@Override
public @Nullable String permission() {
return "admin";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length == 0) throw new Error("\n" + USAGE);
Location location = this.getPlayer().getLocation().clone();
location.setWorld(this.getAppliance().worlds().play());
ConfigurationSection config = this.getAppliance().localConfig();
switch(args[0]) {
case "tribune" -> this.writeLocation(config, "tribuneSpawn", location);
case "villager" -> this.getAppliance().moveVillager(location);
case "pad" -> {
if(args.length < 3 || !List.of(ArenaSettings.PAD_KEYS).contains(args[1])) throw new Error(USAGE);
this.writeCorner(config, "pads." + args[1], args[2], location);
}
case "arena" -> {
if(args.length < 3) throw new Error(USAGE);
String path = "arenas." + args[1];
switch(args[2]) {
case "pos1", "pos2" -> this.writeCorner(config, path + ".region", args[2], location);
case "spawn1", "spawn2", "display" -> this.writeLocation(config, path + "." + args[2], location);
case "remove" -> config.set(path, null);
default -> throw new Error(USAGE);
}
}
case "syncregion" -> {
if(args.length < 2) throw new Error(USAGE);
this.writeCorner(config, "syncRegion", args[1], location);
}
case "sync" -> {
if(this.getAppliance().isBusy()) throw new Error("Es läuft gerade ein Kampf, Countdown oder Reset");
this.getAppliance().syncAll();
sender.sendMessage(Style.message("Die Spielwelt wird aus der Vorlage übernommen."));
return;
}
case "reload" -> {
this.getAppliance().reload();
sender.sendMessage(Style.message("Einstellungen übernommen. Die Arenen werden aus der Vorlage aufgebaut."));
return;
}
default -> throw new Error("\n" + USAGE);
}
Configuration.saveChanges();
sender.sendMessage(Style.message("Gespeichert. Mit /arenasetup reload übernehmen."));
}
private void writeCorner(ConfigurationSection config, String path, String corner, Location location) {
if(!corner.equals("pos1") && !corner.equals("pos2")) throw new Error(USAGE);
config.set(path + "." + corner, List.of(location.getBlockX(), location.getBlockY(), location.getBlockZ()));
}
private void writeLocation(ConfigurationSection config, String path, Location location) {
ConfigurationSection section = Optional.ofNullable(config.getConfigurationSection(path)).orElseGet(() -> config.createSection(path));
ConfigUtil.Position.writeLocation(section, location);
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
List<String> options = switch(args.length) {
case 1 -> List.of("tribune", "villager", "pad", "arena", "syncregion", "sync", "reload");
case 2 -> switch(args[0]) {
case "pad" -> Arrays.asList(ArenaSettings.PAD_KEYS);
case "syncregion" -> List.of("pos1", "pos2");
case "arena" -> this.getAppliance().arenaNames();
default -> List.of();
};
case 3 -> switch(args[0]) {
case "pad" -> List.of("pos1", "pos2");
case "arena" -> List.of("pos1", "pos2", "spawn1", "spawn2", "display", "remove");
default -> List.of();
};
default -> List.of();
};
return this.tabCompleteReducer(options, args);
}
}
@@ -0,0 +1,45 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.World;
import org.bukkit.entity.TextDisplay;
import org.jetbrains.annotations.Nullable;
/**
* One arena: free, in a fight or being reset.
*/
final class ArenaSlot {
enum State {
FREE,
FIGHT,
RESET
}
final ArenaSettings.Slot settings;
State state = State.RESET;
@Nullable Fight fight;
private final TextDisplay display;
ArenaSlot(ArenaSettings.Slot settings, World world) {
this.settings = settings;
// seen from the tribune across half an arena, so twice the size
this.display = Displays.spawn(settings.displayLocation(world), 2f);
}
void updateDisplay() {
Component title = Component.text("Arena " + this.settings.name(), NamedTextColor.GOLD);
Component status = switch(this.state) {
case FREE -> Component.text("Frei", NamedTextColor.GREEN);
case RESET -> Component.text("Wird zurückgesetzt", NamedTextColor.YELLOW);
case FIGHT -> this.fight == null
? Component.text("Kampf", NamedTextColor.RED)
: this.fight.headline().appendNewline().append(Component.text(Style.time(this.fight.secondsLeft()), NamedTextColor.WHITE));
};
this.display.text(title.appendNewline().append(status));
}
void remove() {
this.display.remove();
}
}
@@ -0,0 +1,90 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import org.bukkit.Bukkit;
import org.bukkit.GameRules;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.Plugin;
import java.util.Objects;
/**
* The play world and its template. Players only fight in the play world; admins build in the template,
* which is copied over the play world on every reset.
*/
final class ArenaWorlds {
static final String PLAY = "arena";
static final String TEMPLATE = "arena_template";
private static final class VoidGenerator extends ChunkGenerator {
}
private final World play;
private final World template;
ArenaWorlds() {
this.play = load(PLAY);
this.template = load(TEMPLATE);
for(World world : new World[]{this.play, this.template}) {
world.setGameRule(GameRules.ADVANCE_TIME, false);
world.setGameRule(GameRules.ADVANCE_WEATHER, false);
world.setGameRule(GameRules.SPAWN_MOBS, false);
world.setGameRule(GameRules.SPAWN_MONSTERS, false);
world.setGameRule(GameRules.SPAWN_PHANTOMS, false);
world.setGameRule(GameRules.SPAWN_PATROLS, false);
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, false);
world.setGameRule(GameRules.SPAWN_WARDENS, false);
world.setGameRule(GameRules.RAIDS, false);
world.setGameRule(GameRules.SHOW_DEATH_MESSAGES, false);
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, false);
world.setStorm(false);
world.setThundering(false);
world.setTime(6000);
}
this.play.setGameRule(GameRules.IMMEDIATE_RESPAWN, true);
this.play.setGameRule(GameRules.KEEP_INVENTORY, true);
this.template.setGameRule(GameRules.RANDOM_TICK_SPEED, 0);
this.template.setGameRule(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER, 0);
}
private static World load(String name) {
World world = Bukkit.getWorld(name);
if(world != null) return world;
return Objects.requireNonNull(
new WorldCreator(name).generator(new VoidGenerator()).generateStructures(false).createWorld(),
"Could not create world " + name
);
}
World play() {
return this.play;
}
World template() {
return this.template;
}
boolean isPlay(World world) {
return this.play.equals(world);
}
/**
* Keeps all chunks of the region loaded in both worlds, so resets never wait for chunk loading.
*/
void keepLoaded(Region region, Plugin plugin) {
for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) {
for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) {
this.play.addPluginChunkTicket(cx, cz, plugin);
this.template.addPluginChunkTicket(cx, cz, plugin);
}
}
}
void releaseChunks(Plugin plugin) {
this.play.removePluginChunkTickets(plugin);
this.template.removePluginChunkTickets(plugin);
}
}
@@ -0,0 +1,26 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Switches between the play world and the template at the same position.
*/
class BuildCommand extends ApplianceCommand.PlayerChecked<Arena> {
BuildCommand() {
super("build");
}
@Override
public @Nullable String permission() {
return "arena.build";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
this.getAppliance().toggleBuild(this.getPlayer());
}
}
@@ -0,0 +1,17 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
class CraftAttackCommand extends ApplianceCommand.PlayerChecked<Arena> {
CraftAttackCommand() {
super("craftattack");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
this.getAppliance().sendToCraftAttack(this.getPlayer());
}
}
@@ -0,0 +1,41 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.entity.Display;
import org.bukkit.entity.TextDisplay;
import org.bukkit.util.Transformation;
/**
* Floating texts above pads and arenas. They are not saved with the world and come back with every start.
*/
final class Displays {
static final String MANAGED_TAG = "arena_managed";
private Displays() {
}
static TextDisplay spawn(Location location) {
return spawn(location, 1f);
}
static TextDisplay spawn(Location location, float scale) {
// displays are not saved, they would vanish with an unloaded chunk
location.getWorld().addPluginChunkTicket(location.getBlockX() >> 4, location.getBlockZ() >> 4, Main.instance());
return location.getWorld().spawn(location, TextDisplay.class, display -> {
display.setPersistent(false);
display.setBillboard(Display.Billboard.CENTER);
display.setAlignment(TextDisplay.TextAlignment.CENTER);
display.setShadowed(true);
display.setBackgroundColor(Color.fromARGB(110, 0, 0, 0));
display.setLineWidth(220);
display.addScoreboardTag(MANAGED_TAG);
if(scale != 1f) {
Transformation transformation = display.getTransformation();
transformation.getScale().set(scale);
display.setTransformation(transformation);
}
});
}
}
@@ -0,0 +1,405 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.Main;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit;
import org.bukkit.Color;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* A running fight in one arena between team 0 (red pad, spawn 1) and team 1 (blue pad, spawn 2).
* A team loses once none of its players is left in the arena.
*/
final class Fight {
private static final int CELEBRATION_TICKS = 60;
private static final double SPECTATOR_RANGE = 32;
private static final double SPAWN_SPREAD = 2;
private final Arena arena;
private final ArenaSlot slot;
private final List<List<UUID>> teams;
private final Map<UUID, String> names = new HashMap<>();
private final List<Set<UUID>> alive;
private final FightMode mode;
private final Map<UUID, UUID> kits;
private final BossBar bossBar;
private final Set<UUID> bossBarViewers = new HashSet<>();
private int secondsLeft;
private boolean over;
private @Nullable BukkitTask timer;
Fight(Arena arena, ArenaSlot slot, List<List<Player>> teams, FightMode mode, Map<UUID, UUID> kits) {
this.arena = arena;
this.slot = slot;
this.teams = teams.stream().map(team -> team.stream().map(Player::getUniqueId).toList()).toList();
teams.forEach(team -> team.forEach(player -> this.names.put(player.getUniqueId(), player.getName())));
this.alive = this.teams.stream().map(team -> (Set<UUID>) new LinkedHashSet<>(team)).toList();
this.mode = mode;
this.kits = kits;
this.secondsLeft = arena.settings().fightSeconds();
this.bossBar = BossBar.bossBar(Component.empty(), 1f, BossBar.Color.RED, BossBar.Overlay.NOTCHED_10);
}
void start() {
this.slot.state = ArenaSlot.State.FIGHT;
this.slot.fight = this;
for(int team = 0; team < 2; team++) {
List<UUID> members = this.teams.get(team);
for(int i = 0; i < members.size(); i++) {
Player player = Bukkit.getPlayer(members.get(i));
if(player == null) continue;
this.arena.setState(player, Arena.PlayerState.FIGHT);
this.arena.teams().join(player, Teams.fight(team));
player.closeInventory();
player.teleport(this.spawnFor(team, i, members.size()));
Arena.restore(player, GameMode.SURVIVAL);
InventoryStore.apply(player, this.kits.getOrDefault(player.getUniqueId(), player.getUniqueId()));
player.showTitle(Title.title(
Component.text("Kampf!", NamedTextColor.GOLD),
Component.text("gegen ", NamedTextColor.GRAY).append(this.teamNames(1 - team)),
Title.Times.times(Duration.ZERO, Duration.ofMillis(1500), Duration.ofMillis(500))
));
Style.play(player, Sound.EVENT_RAID_HORN, 1.2f);
}
}
// players who left during the countdown never arrived
for(int team = 0; team < 2; team++) this.alive.get(team).removeIf(uuid -> Bukkit.getPlayer(uuid) == null);
Bukkit.broadcast(Style.message(
this.teamNames(0).append(Component.text(" gegen ", NamedTextColor.GRAY)).append(this.teamNames(1))
.append(Component.text(" in Arena " + this.slot.settings.name() + ". Modus: " + this.mode.displayName(this.isTeamFight()), NamedTextColor.GRAY))
));
this.updateBossBar();
this.timer = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tickSecond, 20, 20);
if(this.alive.get(0).isEmpty() || this.alive.get(1).isEmpty()) this.checkEnd();
}
/**
* Team members stand in a small circle around the spawn, facing the same way.
*/
private Location spawnFor(int team, int index, int count) {
Location center = this.slot.settings.spawns()[team];
if(count == 1) return center;
double angle = 2 * Math.PI * index / count;
Location spot = center.clone().add(Math.cos(angle) * SPAWN_SPREAD, 0, Math.sin(angle) * SPAWN_SPREAD);
boolean free = spot.getBlock().isPassable() && spot.clone().add(0, 1, 0).getBlock().isPassable()
&& !spot.clone().subtract(0, 1, 0).getBlock().isPassable();
return free ? spot : center;
}
private boolean isTeamFight() {
return this.teams.get(0).size() > 1 || this.teams.get(1).size() > 1;
}
private Component teamNames(int team) {
return Component.join(
JoinConfiguration.separator(Component.text(", ", NamedTextColor.GRAY)),
this.teams.get(team).stream().map(uuid -> Component.text(this.names.get(uuid), Style.PAD_TEXT_COLORS[team])).toList()
);
}
/**
* Short form for bossbar and displays: names in a duel, "Rot 2/3" in a team fight.
*/
Component headline() {
if(!this.isTeamFight()) {
return this.teamNames(0).append(Component.text(" gegen ", NamedTextColor.GRAY)).append(this.teamNames(1));
}
return Component.text("Rot " + this.alive.get(0).size() + "/" + this.teams.get(0).size(), Style.PAD_TEXT_COLORS[0])
.append(Component.text(" gegen ", NamedTextColor.GRAY))
.append(Component.text("Blau " + this.alive.get(1).size() + "/" + this.teams.get(1).size(), Style.PAD_TEXT_COLORS[1]));
}
int secondsLeft() {
return this.secondsLeft;
}
boolean isOver() {
return this.over;
}
boolean involves(UUID player) {
return this.teams.get(0).contains(player) || this.teams.get(1).contains(player);
}
boolean isAlive(UUID player) {
return this.alive.get(0).contains(player) || this.alive.get(1).contains(player);
}
boolean sameTeam(UUID a, UUID b) {
int team = this.teamOf(a);
return team != -1 && team == this.teamOf(b);
}
Region region() {
return this.slot.settings.region();
}
ArenaSlot slot() {
return this.slot;
}
private void tickSecond() {
if(this.over) return;
if(--this.secondsLeft <= 0) {
this.timeUp();
return;
}
if(this.secondsLeft == 60 || this.secondsLeft == 10) {
this.alivePlayers().forEach(player -> {
player.sendActionBar(Component.text("Noch " + this.secondsLeft + " Sekunden", NamedTextColor.YELLOW));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.8f);
});
}
this.updateBossBar();
}
/**
* More survivors win, then more hearts left, otherwise a draw.
*/
private void timeUp() {
int red = this.alive.get(0).size();
int blue = this.alive.get(1).size();
if(red != blue) {
this.finish(red > blue ? 0 : 1, Component.text("(Zeit abgelaufen, mehr Überlebende)", NamedTextColor.GRAY));
return;
}
double redHealth = this.health(0);
double blueHealth = this.health(1);
if(Math.abs(redHealth - blueHealth) >= 0.5) {
this.finish(redHealth > blueHealth ? 0 : 1, Component.text("(Zeit abgelaufen, mehr Herzen übrig)", NamedTextColor.GRAY));
return;
}
this.finish(-1, Component.text("Die Zeit ist abgelaufen.", NamedTextColor.GRAY));
}
private double health(int team) {
return this.alive.get(team).stream()
.map(Bukkit::getPlayer)
.filter(player -> player != null)
.mapToDouble(player -> player.getHealth() + player.getAbsorptionAmount())
.sum();
}
private void updateBossBar() {
this.bossBar.name(this.headline().append(Component.text(" " + Style.time(this.secondsLeft), NamedTextColor.WHITE)));
this.bossBar.progress(Math.clamp((float) this.secondsLeft / this.arena.settings().fightSeconds(), 0f, 1f));
Set<UUID> viewers = new HashSet<>();
for(Player player : this.arena.worlds().play().getPlayers()) {
if(this.isAlive(player.getUniqueId()) || this.isSpectating(player)) viewers.add(player.getUniqueId());
}
for(UUID uuid : this.bossBarViewers) {
Player player = Bukkit.getPlayer(uuid);
if(player != null && !viewers.contains(uuid)) player.hideBossBar(this.bossBar);
}
for(UUID uuid : viewers) {
Player player = Bukkit.getPlayer(uuid);
if(player != null) player.showBossBar(this.bossBar);
}
this.bossBarViewers.clear();
this.bossBarViewers.addAll(viewers);
}
private boolean isSpectating(Player player) {
if(this.arena.state(player) != Arena.PlayerState.TRIBUNE && this.arena.state(player) != Arena.PlayerState.PAD) return false;
Region region = this.slot.settings.region();
double dx = Math.max(0, Math.max(region.minX() - player.getX(), player.getX() - (region.maxX() + 1)));
double dz = Math.max(0, Math.max(region.minZ() - player.getZ(), player.getZ() - (region.maxZ() + 1)));
return Math.hypot(dx, dz) <= SPECTATOR_RANGE;
}
/**
* The player took lethal damage: out of the fight and back to the tribune.
*/
void defeated(Player player) {
this.knockOut(player, Component.empty(), "ist ausgeschieden");
}
/**
* The player got out of the box, for example by glitching through a wall with an ender pearl.
*/
void leftBox(Player player) {
this.knockOut(player, Component.text("Du hast die Arena verlassen", NamedTextColor.GRAY), "hat die Arena verlassen");
}
private void knockOut(Player player, Component subtitle, String what) {
if(this.over || !this.eliminate(player.getUniqueId())) return;
player.showTitle(Title.title(Component.text("Ausgeschieden", NamedTextColor.RED), subtitle));
Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f);
this.arena.toTribune(player);
this.announceElimination(player.getUniqueId(), what);
this.checkEnd();
}
/**
* The player left the server during the fight.
*/
void forfeit(UUID quitter) {
if(this.over || !this.eliminate(quitter)) return;
this.announceElimination(quitter, "hat die Arena verlassen");
this.checkEnd();
}
private boolean eliminate(UUID player) {
int team = this.teamOf(player);
return team != -1 && this.alive.get(team).remove(player);
}
private void announceElimination(UUID player, String what) {
if(!this.isTeamFight()) return;
int team = this.teamOf(player);
Component message = Component.text(this.names.get(player), Style.PAD_TEXT_COLORS[team])
.append(Component.text(" " + what + ". ", NamedTextColor.GRAY))
.append(this.headline());
this.alivePlayers().forEach(fighter -> fighter.sendActionBar(message));
this.updateBossBar();
}
private void checkEnd() {
if(this.over) return;
boolean redOut = this.alive.get(0).isEmpty();
boolean blueOut = this.alive.get(1).isEmpty();
if(redOut && blueOut) this.finish(-1, Component.text("Niemand ist mehr übrig.", NamedTextColor.GRAY));
else if(redOut) this.finish(1, this.detail(1));
else if(blueOut) this.finish(0, this.detail(0));
}
private Component detail(int winner) {
String hearts = Style.hearts(this.health(winner));
String survivors = this.isTeamFight() ? this.alive.get(winner).size() + " übrig, " : "";
return Component.text("(" + this.mode.displayName(this.isTeamFight()) + ", " + survivors + hearts + " ❤)", NamedTextColor.GRAY);
}
/**
* Ends the fight without a winner and without celebration, for example on shutdown.
*/
void abort() {
if(this.over) return;
this.over = true;
this.stopTimer();
this.hideBossBar();
this.alivePlayers().forEach(this.arena::toTribune);
this.slot.fight = null;
}
private void finish(int winner, Component detail) {
this.over = true;
this.stopTimer();
if(winner == -1) {
Bukkit.broadcast(Style.message(
this.teamNames(0).append(Component.text(" und ", NamedTextColor.GRAY)).append(this.teamNames(1))
.append(Component.text(" trennen sich unentschieden. ", NamedTextColor.GRAY)).append(detail)
));
this.alivePlayers().forEach(player -> {
player.showTitle(Title.title(Component.text("Unentschieden", NamedTextColor.YELLOW), detail));
Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f);
this.arena.toTribune(player);
});
this.afterCelebration();
return;
}
int loser = 1 - winner;
boolean plural = this.teams.get(winner).size() > 1;
Bukkit.broadcast(Style.message(
this.teamNames(winner)
.append(Component.text(plural ? " gewinnen gegen " : " hat gegen ", NamedTextColor.GRAY))
.append(this.teamNames(loser))
.append(Component.text(plural ? " " : " gewonnen ", NamedTextColor.GRAY))
.append(detail)
));
for(UUID uuid : this.teams.get(loser)) {
Player player = Bukkit.getPlayer(uuid);
if(player == null) continue;
player.showTitle(Title.title(Component.text("Verloren", NamedTextColor.RED), Component.empty()));
if(this.alive.get(loser).contains(uuid)) this.arena.toTribune(player);
}
for(UUID uuid : this.teams.get(winner)) {
Player player = Bukkit.getPlayer(uuid);
if(player == null) continue;
player.showTitle(Title.title(
Component.text("Gewonnen!", NamedTextColor.GOLD),
Component.text("gegen ", NamedTextColor.GRAY).append(this.teamNames(loser))
));
Style.play(player, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f);
if(this.alive.get(winner).contains(uuid)) this.celebrate(player.getLocation(), Style.PAD_PARTICLE_COLORS[winner]);
}
this.afterCelebration();
}
private void celebrate(Location location, Color color) {
Location top = location.clone().add(0, 2.2, 0);
Particle.FIREWORK.builder().location(top).count(40).offset(0.6, 0.6, 0.6).extra(0.15).receivers(64).spawn();
Particle.DUST.builder().location(top).count(60).offset(1, 1, 1).data(new Particle.DustOptions(color, 1.6f)).receivers(64).spawn();
Style.play(top, Sound.ENTITY_FIREWORK_ROCKET_TWINKLE, 2f, 1f);
Style.play(top, Sound.ENTITY_FIREWORK_ROCKET_LARGE_BLAST, 2f, 1f);
}
private void hideBossBar() {
this.bossBarViewers.forEach(uuid -> {
Player player = Bukkit.getPlayer(uuid);
if(player != null) player.hideBossBar(this.bossBar);
});
this.bossBarViewers.clear();
}
private void afterCelebration() {
this.hideBossBar();
Bukkit.getScheduler().runTaskLater(Main.instance(), () -> {
for(Player player : this.alivePlayers()) {
if(this.arena.fightOf(player) == this && this.arena.state(player) == Arena.PlayerState.FIGHT) this.arena.toTribune(player);
}
this.slot.fight = null;
this.arena.resetSlot(this.slot);
}, CELEBRATION_TICKS);
}
private void stopTimer() {
if(this.timer != null) this.timer.cancel();
this.timer = null;
}
private int teamOf(UUID player) {
if(this.teams.get(0).contains(player)) return 0;
if(this.teams.get(1).contains(player)) return 1;
return -1;
}
private List<Player> alivePlayers() {
List<Player> players = new ArrayList<>();
for(Set<UUID> team : this.alive) {
for(UUID uuid : team) {
Player player = Bukkit.getPlayer(uuid);
if(player != null) players.add(player);
}
}
return players;
}
}
@@ -0,0 +1,111 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import io.papermc.paper.datacomponent.DataComponentTypes;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
/**
* Lethal damage ends the fight instead of killing the player. Totems still work as usual.
*/
class FightListener extends ApplianceListener<Arena> {
private boolean isFighting(Player player) {
Fight fight = this.getAppliance().fightOf(player);
return fight != null && !fight.isOver() && fight.isAlive(player.getUniqueId());
}
/**
* Only fighters hit each other, and never their own team directly. Explosions still hurt everyone.
*/
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onDamageByPlayer(EntityDamageByEntityEvent event) {
if(!(event.getEntity() instanceof Player victim)) return;
Player attacker = switch(event.getDamager()) {
case Player player -> player;
case Projectile projectile when projectile.getShooter() instanceof Player shooter -> shooter;
default -> null;
};
if(attacker == null || attacker == victim) return;
Fight fight = this.getAppliance().fightOf(attacker);
if(!this.isFighting(attacker) || fight.sameTeam(attacker.getUniqueId(), victim.getUniqueId())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if(!(event.getEntity() instanceof Player player)) return;
if(this.getAppliance().state(player) != Arena.PlayerState.FIGHT) return;
Fight fight = this.getAppliance().fightOf(player);
if(fight == null || fight.isOver()) {
event.setCancelled(true);
return;
}
if(event.getFinalDamage() < player.getHealth()) return;
if(this.hasDeathProtection(player.getInventory().getItemInMainHand())
|| this.hasDeathProtection(player.getInventory().getItemInOffHand())) return;
event.setCancelled(true);
fight.defeated(player);
}
private boolean hasDeathProtection(ItemStack item) {
return !item.isEmpty() && item.hasData(DataComponentTypes.DEATH_PROTECTION);
}
/**
* Safety net if a death gets through anyway: nothing drops and the fight ends.
*/
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.setKeepInventory(true);
event.getDrops().clear();
event.setKeepLevel(true);
event.setDroppedExp(0);
event.deathMessage(null);
Fight fight = this.getAppliance().fightOf(event.getPlayer());
if(fight != null) fight.defeated(event.getPlayer());
}
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
Bukkit.getScheduler().runTask(Main.instance(), () -> this.getAppliance().toTribune(event.getPlayer()));
}
/**
* Ender pearls and chorus fruits must not leave the arena.
*/
@EventHandler(ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
if(event.getCause() == PlayerTeleportEvent.TeleportCause.PLUGIN || event.getCause() == PlayerTeleportEvent.TeleportCause.COMMAND) return;
if(this.getAppliance().state(event.getPlayer()) != Arena.PlayerState.FIGHT) return;
Region region = this.getAppliance().arenaRegionOf(event.getPlayer());
if(region == null) return;
if(event.getTo().getWorld() != event.getFrom().getWorld() || !region.contains(event.getTo().getX(), event.getTo().getY(), event.getTo().getZ())) {
event.setCancelled(true);
}
}
/**
* Whoever ends up outside the box anyway (ender pearl through a wall or corner) loses.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if(!event.hasChangedBlock()) return;
Player player = event.getPlayer();
if(this.getAppliance().state(player) != Arena.PlayerState.FIGHT || !this.isFighting(player)) return;
Fight fight = this.getAppliance().fightOf(player);
if(event.getTo().getWorld() == this.getAppliance().worlds().play() && fight.region().contains(event.getTo().getX(), event.getTo().getY(), event.getTo().getZ())) return;
fight.leftBox(player);
}
}
@@ -0,0 +1,43 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Inventory copies sent by the main server. Kept in memory only: after a restart of the arena server
* players arrive with an empty inventory until they come over again.
*/
final class InventoryStore {
static final int MAX_BYTES = 16 * 1024 * 1024;
private static final Map<UUID, byte[]> snapshots = new ConcurrentHashMap<>();
private InventoryStore() {
}
static void put(UUID player, byte[] serializedItems) {
if(serializedItems.length > MAX_BYTES) throw new IllegalArgumentException("Inventory too large");
snapshots.put(player, serializedItems);
}
/**
* @return a fresh copy of the player's inventory, empty if none was sent
*/
static ItemStack[] get(UUID player) {
byte[] bytes = snapshots.get(player);
if(bytes == null) return new ItemStack[0];
return ItemStack.deserializeItemsFromBytes(bytes);
}
static void apply(Player player, UUID source) {
ItemStack[] items = get(source);
int size = player.getInventory().getSize();
ItemStack[] contents = new ItemStack[size];
System.arraycopy(items, 0, contents, 0, Math.min(items.length, size));
player.getInventory().setContents(contents);
player.setItemOnCursor(null);
}
}
@@ -0,0 +1,410 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.PadLobby;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.util.world.InteractSounds;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/**
* The two start pads in the world: who stands where, pushing others away from taken pads,
* inventories, countdown, texts, particles and sounds. The rules themselves live in {@link PadLobby}.
*/
final class Lobby implements PadLobby.Listener {
/** How far around a taken pad other players get pushed away. */
private static final double PUSH_MARGIN = 0.4;
/** How long a full lobby waits for the challenger to close the options. */
private static final int CHOOSING_SECONDS = 20;
/** Whoever keeps the options open this long without clicking anything gets sent off the pad. */
private static final int MENU_IDLE_TICKS = 60 * 20;
private final Arena arena;
private final Region[] regions;
private final PadLobby pads;
private final TextDisplay[] displays = new TextDisplay[PadLobby.PADS];
private final Map<UUID, Long> lastPushSound = new HashMap<>();
private long ticks;
private @Nullable UUID menuViewer;
private int menuIdleTicks;
Lobby(Arena arena, Region[] regions) {
this.arena = arena;
this.regions = regions;
this.pads = new PadLobby(this, arena.settings().countdownSeconds(), CHOOSING_SECONDS);
for(int i = 0; i < PadLobby.PADS; i++) {
Region region = regions[i];
this.displays[i] = Displays.spawn(new Location(arena.worlds().play(), region.centerX(), region.maxY() + 2.6, region.centerZ()));
}
}
void tick() {
this.ticks++;
for(Player player : this.arena.worlds().play().getPlayers()) {
Arena.PlayerState state = this.arena.state(player);
if(state == Arena.PlayerState.PAD) this.checkLeave(player);
else if(state == Arena.PlayerState.TRIBUNE) this.checkEnter(player);
}
this.checkMenuIdle();
this.pads.tick(this.arena.hasFreeSlot());
if(this.ticks % 3 == 0) this.drawParticles();
if(this.ticks % 5 == 0) this.updateDisplays();
}
private void checkMenuIdle() {
if(this.menuViewer == null || ++this.menuIdleTicks < MENU_IDLE_TICKS) return;
Player player = Bukkit.getPlayer(this.menuViewer);
this.menuViewer = null;
if(player == null || !ModeMenu.isOpen(player)) return;
this.arena.toTribune(player);
player.sendMessage(Style.message("Du hast eine Minute lang nichts ausgewählt und wurdest vom Feld geschickt."));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f);
}
private void checkLeave(Player player) {
int pad = this.pads.padOf(player.getUniqueId());
if(pad != -1 && this.isOn(player, pad, 0)) return;
this.leave(player);
}
private void checkEnter(Player player) {
for(int pad = 0; pad < PadLobby.PADS; pad++) {
if(this.pads.isPadFull(pad)) {
if(this.isOn(player, pad, PUSH_MARGIN)) this.pushAway(player, pad);
continue;
}
if(!this.isOn(player, pad, 0)) continue;
if(!this.pads.enter(pad, player.getUniqueId())) continue;
this.arena.setState(player, Arena.PlayerState.PAD);
this.arena.teams().join(player, Teams.pad(pad));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.4f);
if(this.pads.isWaitingForChallenger()) {
player.sendActionBar(this.choosingText());
} else if(!player.getUniqueId().equals(this.pads.challenger())) {
player.sendActionBar(Component.text(Style.PAD_NAMES[pad] + " " + this.pads.players(pad).size() + "/" + this.pads.size(pad)
+ ", Modus: " + this.modeName(), Style.PAD_TEXT_COLORS[pad]));
}
return;
}
}
/**
* Removes the player from the pad, if standing on one.
*/
void leave(Player player) {
if(this.pads.padOf(player.getUniqueId()) == -1) return;
this.pads.leave(player.getUniqueId());
if(ModeMenu.isOpen(player)) player.closeInventory();
this.arena.teams().leave(player);
this.arena.setState(player, Arena.PlayerState.TRIBUNE);
}
void handleMenuClick(Player player, ModeMenu menu, ModeMenu.Click click) {
int ownSize = menu.ownSize;
int opponentSize = menu.opponentSize;
FightMode mode = menu.mode;
this.menuIdleTicks = 0;
switch(click) {
case ModeMenu.OwnSize(int size) -> ownSize = size;
case ModeMenu.OpponentSize(int size) -> opponentSize = size;
case ModeMenu.Mode(FightMode chosen) -> mode = chosen;
case ModeMenu.Done() -> {
InteractSounds.of(player).success();
player.closeInventory();
player.sendActionBar(Component.text(this.pads.size(this.pads.challengerPad()) + " gegen " + this.pads.size(1 - this.pads.challengerPad())
+ ", Modus: " + this.modeName() + ". Rechtsklick öffnet die Auswahl wieder.", NamedTextColor.GOLD));
return;
}
}
// switching to teams drops the opponent's inventory mode
if(!mode.isAllowed(ownSize, opponentSize)) mode = FightMode.OWN;
int ownPad = this.pads.challengerPad();
String problem = switch(this.pads.configure(player.getUniqueId(), ownSize, opponentSize, mode)) {
case OK -> null;
case NOT_ALLOWED -> "Der Countdown läuft schon";
case OWN_TOO_SMALL -> "Auf deinem Feld stehen schon " + this.pads.players(ownPad).size();
case OPPONENT_TOO_SMALL -> "Auf dem anderen Feld stehen schon " + this.pads.players(1 - ownPad).size();
case INVALID -> "Das geht so nicht";
};
if(problem != null) {
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f);
player.sendActionBar(Component.text(problem, NamedTextColor.RED));
if(!this.pads.canConfigure(player.getUniqueId())) player.closeInventory();
return;
}
menu.ownSize = ownSize;
menu.opponentSize = opponentSize;
menu.mode = mode;
menu.draw();
InteractSounds.of(player).click();
}
/**
* Right click on the pad: the challenger opens the options again.
*/
boolean openMenu(Player player) {
if(!this.pads.canConfigure(player.getUniqueId()) || ModeMenu.isOpen(player)) return false;
int pad = this.pads.challengerPad();
InteractSounds.of(player).open();
ModeMenu.open(player, pad, this.pads.size(pad), this.pads.size(1 - pad), this.pads.mode());
this.menuViewer = player.getUniqueId();
this.menuIdleTicks = 0;
this.pads.setChoosing(player.getUniqueId(), true);
return true;
}
/**
* The challenger closed the options: a full lobby starts counting down now.
*/
void menuClosed(Player player) {
if(player.getUniqueId().equals(this.menuViewer)) this.menuViewer = null;
this.pads.setChoosing(player.getUniqueId(), false);
}
private Component choosingText() {
Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger());
String name = challenger == null ? "Der Herausforderer" : challenger.getName();
return Component.text(name + " wählt noch Teams und Modus", NamedTextColor.YELLOW);
}
private boolean isTeamFight() {
return this.pads.size(0) > 1 || this.pads.size(1) > 1;
}
private String modeName() {
return this.pads.mode().displayName(this.isTeamFight());
}
boolean isIdle() {
return this.pads.isIdle();
}
void shutdown() {
for(int pad = 0; pad < PadLobby.PADS; pad++) this.playersOn(pad).forEach(this::leave);
for(TextDisplay display : this.displays) display.remove();
}
private boolean isOn(Player player, int pad, double margin) {
Region region = this.regions[pad];
return player.getX() >= region.minX() - margin && player.getX() < region.maxX() + 1 + margin
&& player.getZ() >= region.minZ() - margin && player.getZ() < region.maxZ() + 1 + margin
&& player.getY() >= region.minY() - 0.5 && player.getY() < region.maxY() + 2;
}
private void pushAway(Player player, int pad) {
Region region = this.regions[pad];
Vector direction = new Vector(player.getX() - region.centerX(), 0, player.getZ() - region.centerZ());
if(direction.lengthSquared() < 0.01) direction = new Vector(1, 0, 0);
player.setVelocity(direction.normalize().multiply(0.6).setY(0.25));
long now = System.currentTimeMillis();
if(now - this.lastPushSound.getOrDefault(player.getUniqueId(), 0L) < 1000) return;
this.lastPushSound.put(player.getUniqueId(), now);
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f);
player.sendActionBar(Component.text("Dieses Feld ist voll", NamedTextColor.RED));
}
@Override
public void challengerSelected(UUID challenger, boolean first) {
Player player = Bukkit.getPlayer(challenger);
if(player == null) return;
if(!first) {
player.sendMessage(Style.message("Du führst jetzt die Herausforderung an. Rechtsklick auf dem Feld öffnet die Auswahl."));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f);
return;
}
player.sendMessage(Style.message("Du forderst heraus. Wähle Teamgröße und Modus, Rechtsklick auf dem Feld öffnet die Auswahl wieder. Wer sich auf die Felder stellt, macht mit."));
this.openMenu(player);
}
@Override
public void waitingForChallenger() {
UUID challenger = this.pads.challenger();
Player player = challenger == null ? null : Bukkit.getPlayer(challenger);
if(player != null && player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu menu) {
menu.everyoneThere = true;
menu.draw();
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f);
}
for(int pad = 0; pad < PadLobby.PADS; pad++) {
this.playersOn(pad).stream()
.filter(occupant -> !occupant.getUniqueId().equals(challenger))
.forEach(occupant -> occupant.sendActionBar(this.choosingText()));
}
}
@Override
public void countdownStarted() {
UUID challenger = this.pads.challenger();
Player player = challenger == null ? null : Bukkit.getPlayer(challenger);
if(player != null && ModeMenu.isOpen(player)) player.closeInventory();
}
@Override
public void kitAssigned(UUID uuid, UUID kitOwner) {
Player player = Bukkit.getPlayer(uuid);
if(player == null) return;
InventoryStore.apply(player, kitOwner);
if(uuid.equals(kitOwner)) return;
Player owner = Bukkit.getPlayer(kitOwner);
player.sendMessage(Style.message("Du kämpfst mit dem Inventar von " + (owner == null ? "jemand anderem" : owner.getName()) + ". Verlasse das Feld, um abzubrechen."));
Style.play(player, Sound.ITEM_ARMOR_EQUIP_NETHERITE, 1f);
}
@Override
public void countdown(int secondsLeft) {
for(int pad = 0; pad < PadLobby.PADS; pad++) {
Component opponents = this.namesOn(1 - pad);
for(Player player : this.playersOn(pad)) {
player.showTitle(Title.title(
Component.text(secondsLeft, NamedTextColor.GOLD),
Component.text("Kampf gegen ", NamedTextColor.GRAY).append(opponents),
Title.Times.times(Duration.ZERO, Duration.ofMillis(1100), Duration.ZERO)
));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 0.6f + (this.arena.settings().countdownSeconds() - secondsLeft) * 0.2f);
}
}
}
@Override
public void countdownCancelled() {
for(int pad = 0; pad < PadLobby.PADS; pad++) {
for(Player player : this.playersOn(pad)) {
player.clearTitle();
player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f);
}
}
}
@Override
public void start(List<List<UUID>> teams, UUID challenger, FightMode mode, Map<UUID, UUID> kits) {
List<List<Player>> players = teams.stream()
.map(team -> team.stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList())
.toList();
if(players.get(0).isEmpty() || players.get(1).isEmpty()) {
players.forEach(team -> team.forEach(this.arena::toTribune));
return;
}
this.arena.startFight(players, mode, kits);
}
private List<Player> playersOn(int pad) {
return this.pads.players(pad).stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList();
}
private Component namesOn(int pad) {
return Component.join(
JoinConfiguration.separator(Component.text(", ", NamedTextColor.GRAY)),
this.playersOn(pad).stream().map(player -> Component.text(player.getName(), Style.PAD_TEXT_COLORS[pad])).toList()
);
}
private void updateDisplays() {
boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot();
Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger());
int challengerPad = this.pads.challengerPad();
String modeText = "Modus: " + this.modeName();
for(int pad = 0; pad < PadLobby.PADS; pad++) {
List<Player> players = this.playersOn(pad);
Component title = Component.text(Style.PAD_NAMES[pad], Style.PAD_TEXT_COLORS[pad]);
if(challenger != null) title = title.append(Component.text(" " + players.size() + "/" + this.pads.size(pad), NamedTextColor.WHITE));
Component body;
if(waiting) {
body = this.namesOn(pad).appendNewline()
.append(Component.text("Alle Arenen belegt, gleich geht es los", NamedTextColor.GRAY));
} else if(this.pads.isWaitingForChallenger() && challenger != null) {
body = this.namesOn(pad).appendNewline()
.append(Component.text(challenger.getName() + " wählt noch", NamedTextColor.YELLOW));
} else if(this.pads.isCountingDown()) {
body = this.namesOn(pad).appendNewline()
.append(Component.text("Kampf beginnt in " + this.pads.secondsLeft(), NamedTextColor.GOLD));
} else if(challenger == null) {
body = !this.arena.hasFreeSlot()
? Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline()
.append(Component.text("Du kannst dich trotzdem anstellen", NamedTextColor.WHITE))
: Component.text("Stell dich hierhin,", NamedTextColor.WHITE).appendNewline()
.append(Component.text("um herauszufordern", NamedTextColor.WHITE));
} else if(pad == challengerPad) {
body = this.namesOn(pad).appendNewline()
.append(Component.text(players.size() == 1 ? "fordert heraus" : "fordern heraus", NamedTextColor.GRAY)).appendNewline()
.append(Component.text(modeText, NamedTextColor.WHITE));
} else {
Component who = players.isEmpty()
? Component.text("Stell dich hierhin, um gegen", NamedTextColor.WHITE).appendNewline()
.append(Component.text(challenger.getName(), Style.PAD_TEXT_COLORS[challengerPad]))
.append(Component.text(" zu kämpfen", NamedTextColor.WHITE))
: this.namesOn(pad).appendNewline().append(Component.text("nehmen an", NamedTextColor.GRAY));
body = who.appendNewline().append(Component.text(modeText, NamedTextColor.GRAY));
}
this.displays[pad].text(title.appendNewline().append(body));
}
}
private void drawParticles() {
boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot();
boolean countdown = this.pads.isCountingDown() && !waiting;
for(int pad = 0; pad < PadLobby.PADS; pad++) {
Region region = this.regions[pad];
Color color = waiting ? Style.WAITING_PARTICLE_COLOR : Style.PAD_PARTICLE_COLORS[pad];
boolean full = this.pads.isPadFull(pad);
double spacing = countdown ? 0.25 : full ? 0.4 : 0.6;
this.drawOutline(region, new Particle.DustOptions(color, full ? 1.2f : 0.9f), spacing);
double speed = countdown ? 0.6 : 0.3;
for(Player occupant : this.playersOn(pad)) {
for(int strand = 0; strand < 2; strand++) {
double angle = this.ticks * speed + strand * Math.PI;
double height = (this.ticks % 40) / 20.0;
Location point = occupant.getLocation().add(Math.cos(angle) * 0.7, height, Math.sin(angle) * 0.7);
Particle.DUST.builder().location(point).data(new Particle.DustOptions(color, 1f)).receivers(48).spawn();
}
}
}
}
private void drawOutline(Region region, Particle.DustOptions dust, double spacing) {
double y = region.minY() + 0.1;
double minX = region.minX();
double maxX = region.maxX() + 1;
double minZ = region.minZ();
double maxZ = region.maxZ() + 1;
Location location = new Location(this.arena.worlds().play(), 0, y, 0);
for(double x = minX; x <= maxX; x += spacing) {
this.dust(location.set(x, y, minZ), dust);
this.dust(location.set(x, y, maxZ), dust);
}
for(double z = minZ; z <= maxZ; z += spacing) {
this.dust(location.set(minX, y, z), dust);
this.dust(location.set(maxX, y, z), dust);
}
}
private void dust(Location location, Particle.DustOptions dust) {
Particle.DUST.builder().location(location).data(dust).receivers(48).spawn();
}
}
@@ -0,0 +1,99 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.EntityToggleGlideEvent;
import org.bukkit.event.entity.FoodLevelChangeEvent;
import org.bukkit.event.entity.ProjectileLaunchEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
/**
* Outside of a fight the inventory can be looked at, but nothing can be moved, dropped or used.
* Nobody takes damage or gets hungry on the tribune.
*/
class LockListener extends ApplianceListener<Arena> {
private boolean isLocked(Object entity) {
if(!(entity instanceof Player player)) return false;
Arena.PlayerState state = this.getAppliance().state(player);
return state == Arena.PlayerState.TRIBUNE || state == Arena.PlayerState.PAD;
}
private void cancelIfLocked(Object entity, Cancellable event) {
if(this.isLocked(entity)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.LOW)
public void onClick(InventoryClickEvent event) {
this.cancelIfLocked(event.getWhoClicked(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onDrag(InventoryDragEvent event) {
this.cancelIfLocked(event.getWhoClicked(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onDrop(PlayerDropItemEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onSwapHands(PlayerSwapHandItemsEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onInteract(PlayerInteractEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onConsume(PlayerItemConsumeEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onPickup(EntityPickupItemEvent event) {
this.cancelIfLocked(event.getEntity(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onLaunch(ProjectileLaunchEvent event) {
this.cancelIfLocked(event.getEntity().getShooter(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onGlide(EntityToggleGlideEvent event) {
if(event.isGliding()) this.cancelIfLocked(event.getEntity(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onHunger(FoodLevelChangeEvent event) {
this.cancelIfLocked(event.getEntity(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onDamage(EntityDamageEvent event) {
this.cancelIfLocked(event.getEntity(), event);
}
/**
* Ender chests would keep items between fights.
*/
@EventHandler
public void onOpen(InventoryOpenEvent event) {
if(event.getInventory().getType() == InventoryType.ENDER_CHEST) event.setCancelled(true);
}
}
@@ -0,0 +1,150 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.PadLobby;
import eu.mhsl.craftattack.spawn.core.util.inventory.ItemBuilder;
import eu.mhsl.craftattack.spawn.core.util.inventory.PlaceholderItems;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
import org.jetbrains.annotations.NotNull;
/**
* Chest menu in which the challenger picks team sizes and mode. Every click applies at once.
* Works for Bedrock players as well.
*/
final class ModeMenu implements InventoryHolder {
private static final int OWN_ROW = 10;
private static final int OPPONENT_ROW = 19;
private static final int[] MODE_SLOTS = {29, 31, 33};
private static final FightMode[] MODES = {FightMode.OWN, FightMode.CHALLENGER, FightMode.OPPONENT};
private static final int DONE_SLOT = 40;
sealed interface Click {
}
record OwnSize(int size) implements Click {
}
record OpponentSize(int size) implements Click {
}
record Mode(FightMode mode) implements Click {
}
record Done() implements Click {
}
private final Inventory inventory;
private final Player challenger;
private final int ownPad;
int ownSize;
int opponentSize;
FightMode mode;
/** Both pads are full, only the challenger closing the menu is missing. */
boolean everyoneThere;
private ModeMenu(Player challenger, int ownPad, int ownSize, int opponentSize, FightMode mode) {
this.challenger = challenger;
this.ownPad = ownPad;
this.ownSize = ownSize;
this.opponentSize = opponentSize;
this.mode = mode;
this.inventory = Bukkit.createInventory(this, 45, Component.text("Teams und Kampfmodus"));
this.draw();
}
static void open(Player challenger, int ownPad, int ownSize, int opponentSize, FightMode mode) {
challenger.openInventory(new ModeMenu(challenger, ownPad, ownSize, opponentSize, mode).getInventory());
}
static boolean isOpen(Player player) {
return player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu;
}
Click clickAt(int slot) {
if(slot > OWN_ROW && slot <= OWN_ROW + PadLobby.MAX_TEAM_SIZE) return new OwnSize(slot - OWN_ROW);
if(slot > OPPONENT_ROW && slot <= OPPONENT_ROW + PadLobby.MAX_TEAM_SIZE) return new OpponentSize(slot - OPPONENT_ROW);
for(int i = 0; i < MODE_SLOTS.length; i++) {
if(MODE_SLOTS[i] == slot) return new Mode(MODES[i]);
}
if(slot == DONE_SLOT) return new Done();
return null;
}
void draw() {
for(int i = 0; i < this.inventory.getSize(); i++) this.inventory.setItem(i, PlaceholderItems.grayStainedGlassPane);
int opponentPad = 1 - this.ownPad;
boolean teams = this.ownSize > 1 || this.opponentSize > 1;
this.inventory.setItem(OWN_ROW, this.label(Material.PLAYER_HEAD, "Dein Team", this.ownPad, "So viele stehen mit dir auf dem " + Style.PAD_NAMES[this.ownPad] + ".")
.withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(this.challenger)).build());
this.inventory.setItem(OPPONENT_ROW, this.label(Material.SKELETON_SKULL, "Gegner", opponentPad, "So viele stellen sich auf das " + Style.PAD_NAMES[opponentPad] + ".").build());
for(int size = 1; size <= PadLobby.MAX_TEAM_SIZE; size++) {
this.inventory.setItem(OWN_ROW + size, this.sizeItem(size, this.ownPad, size == this.ownSize));
this.inventory.setItem(OPPONENT_ROW + size, this.sizeItem(size, opponentPad, size == this.opponentSize));
}
for(int i = 0; i < MODES.length; i++) {
FightMode mode = MODES[i];
this.inventory.setItem(MODE_SLOTS[i], this.modeItem(mode, teams, mode == this.mode, mode.isAllowed(this.ownSize, this.opponentSize)));
}
ItemBuilder done = ItemBuilder.of(Material.LIME_CONCRETE)
.displayName(Component.text(this.everyoneThere ? "Fertig, Kampf starten" : "Fertig", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false))
.lore(this.ownSize + " gegen " + this.opponentSize + ", " + this.mode.displayName(teams) + ". "
+ (this.everyoneThere ? "Alle sind da, der Countdown startet, sobald du fertig bist." : "Jetzt fehlen nur noch die anderen auf den Feldern."));
if(this.everyoneThere) done.glint();
this.inventory.setItem(DONE_SLOT, done.build());
}
private ItemBuilder label(Material material, String name, int pad, String text) {
return ItemBuilder.of(material)
.displayName(Component.text(name, Style.PAD_TEXT_COLORS[pad]).decoration(TextDecoration.ITALIC, false))
.lore(text);
}
private ItemStack sizeItem(int size, int pad, boolean selected) {
ItemBuilder builder = ItemBuilder.of(pad == 0 ? Material.RED_CONCRETE : Material.BLUE_CONCRETE)
.amount(size)
.displayName(Component.text(size + " Spieler", Style.PAD_TEXT_COLORS[pad]).decoration(TextDecoration.ITALIC, false));
builder.lore(selected ? "Ausgewählt" : "Klicken zum Auswählen", selected ? NamedTextColor.GREEN : NamedTextColor.YELLOW);
if(selected) builder.glint();
return builder.build();
}
private ItemStack modeItem(FightMode mode, boolean teams, boolean selected, boolean allowed) {
ItemBuilder builder = switch(mode) {
case OWN -> ItemBuilder.of(Material.IRON_SWORD)
.lore("Jeder kämpft mit seinem eigenen Inventar.");
case CHALLENGER -> ItemBuilder.of(Material.PLAYER_HEAD)
.withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(this.challenger))
.lore(teams ? "Alle kämpfen mit deinem Inventar." : "Ihr kämpft beide mit deinem Inventar.");
case OPPONENT -> ItemBuilder.of(allowed ? Material.CHEST : Material.BARRIER)
.lore(allowed
? "Ihr kämpft beide mit dem Inventar von dem, der sich auf das andere Feld stellt."
: "Geht nur bei 1 gegen 1.");
};
builder.displayName(Component.text(mode.displayName(teams), allowed ? NamedTextColor.GOLD : NamedTextColor.DARK_GRAY).decoration(TextDecoration.ITALIC, false));
if(!allowed) return builder.build();
builder.appendLore(Component.empty());
if(selected) {
builder.glint().appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
} else {
builder.appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false));
}
return builder.build();
}
@Override
public @NotNull Inventory getInventory() {
return this.inventory;
}
}
@@ -0,0 +1,41 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
class ModeMenuListener extends ApplianceListener<Arena> {
@EventHandler
public void onClick(InventoryClickEvent event) {
if(!(event.getView().getTopInventory().getHolder(false) instanceof ModeMenu menu)) return;
event.setCancelled(true);
if(!(event.getWhoClicked() instanceof Player player)) return;
if(event.getRawSlot() >= event.getView().getTopInventory().getSize()) return;
ModeMenu.Click click = menu.clickAt(event.getRawSlot());
Lobby lobby = this.getAppliance().lobby();
if(click != null && lobby != null) lobby.handleMenuClick(player, menu, click);
}
@EventHandler
public void onClose(InventoryCloseEvent event) {
if(!(event.getInventory().getHolder(false) instanceof ModeMenu)) return;
if(!(event.getPlayer() instanceof Player player)) return;
Lobby lobby = this.getAppliance().lobby();
if(lobby != null) lobby.menuClosed(player);
}
@EventHandler
public void onRightClick(PlayerInteractEvent event) {
if(event.getHand() != EquipmentSlot.HAND) return;
if(event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
if(this.getAppliance().state(event.getPlayer()) != Arena.PlayerState.PAD) return;
Lobby lobby = this.getAppliance().lobby();
if(lobby != null && lobby.openMenu(event.getPlayer())) event.setCancelled(true);
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import com.destroystokyo.paper.event.player.PlayerAdvancementCriterionGrantEvent;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerRecipeDiscoverEvent;
/**
* Nothing done in the arena counts as progress: no advancements, no recipe unlocks.
*/
class NoProgressListener extends ApplianceListener<Arena> {
@EventHandler
public void onCriterion(PlayerAdvancementCriterionGrantEvent event) {
event.setCancelled(true);
}
@EventHandler
public void onRecipe(PlayerRecipeDiscoverEvent event) {
event.setCancelled(true);
}
}
@@ -0,0 +1,22 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
class PlayerConnectionListener extends ApplianceListener<Arena> {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
event.getPlayer().getEnderChest().clear();
this.getAppliance().toTribune(event.getPlayer());
event.getPlayer().sendMessage(Style.message(
"Willkommen in der Arena! Stell dich auf ein Startfeld, um jemanden herauszufordern. Zurück geht es beim Villager oder mit /craftattack."
));
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
this.getAppliance().handleQuit(event.getPlayer());
}
}
@@ -0,0 +1,55 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import java.util.Locale;
/**
* Colors, texts and sounds of the arena, so everything looks and sounds the same everywhere.
* Player facing texts use no dashes.
*/
final class Style {
static final Component PREFIX = Component.text("[Arena] ", NamedTextColor.GOLD);
static final NamedTextColor[] PAD_TEXT_COLORS = {NamedTextColor.RED, NamedTextColor.BLUE};
static final Color[] PAD_PARTICLE_COLORS = {Color.fromRGB(255, 70, 70), Color.fromRGB(80, 120, 255)};
static final String[] PAD_NAMES = {"Rotes Feld", "Blaues Feld"};
static final Color WAITING_PARTICLE_COLOR = Color.fromRGB(150, 150, 150);
private Style() {
}
static Component message(String text) {
return PREFIX.append(Component.text(text, NamedTextColor.GRAY));
}
static Component message(Component text) {
return PREFIX.append(text.colorIfAbsent(NamedTextColor.GRAY));
}
static Component name(String name, TextColor color) {
return Component.text(name, color);
}
static String hearts(double health) {
return String.format(Locale.GERMAN, "%.1f", health / 2);
}
static String time(int seconds) {
return String.format("%d:%02d", seconds / 60, seconds % 60);
}
static void play(Player player, Sound sound, float pitch) {
player.playSound(player, sound, 1f, pitch);
}
static void play(Location location, Sound sound, float volume, float pitch) {
location.getWorld().playSound(location, sound, volume, pitch);
}
}
@@ -0,0 +1,48 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;
/**
* Scoreboard teams for the colored names. Players on a pad cannot be pushed (no collision),
* on the tribune and in a fight collision stays on.
*/
final class Teams {
private static final String[] NAMES = {"arena_pad_red", "arena_pad_blue", "arena_fight_red", "arena_fight_blue"};
private final Team[] teams = new Team[NAMES.length];
Teams() {
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
for(int i = 0; i < NAMES.length; i++) {
Team team = scoreboard.getTeam(NAMES[i]);
if(team == null) team = scoreboard.registerNewTeam(NAMES[i]);
team.color(Style.PAD_TEXT_COLORS[i % 2]);
team.setOption(Team.Option.COLLISION_RULE, i < 2 ? Team.OptionStatus.NEVER : Team.OptionStatus.ALWAYS);
this.teams[i] = team;
}
}
static int pad(int pad) {
return pad;
}
static int fight(int side) {
return 2 + side;
}
void join(Player player, int team) {
this.leave(player);
this.teams[team].addEntry(player.getName());
}
void leave(Player player) {
for(Team team : this.teams) team.removeEntry(player.getName());
}
void clear() {
for(Team team : this.teams) team.getEntries().forEach(team::removeEntry);
}
}
@@ -0,0 +1,145 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockBurnEvent;
import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.block.BlockFadeEvent;
import org.bukkit.event.block.BlockFormEvent;
import org.bukkit.event.block.BlockFromToEvent;
import org.bukkit.event.block.BlockIgniteEvent;
import org.bukkit.event.block.BlockPistonExtendEvent;
import org.bukkit.event.block.BlockPistonRetractEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.block.BlockSpreadEvent;
import org.bukkit.event.block.LeavesDecayEvent;
import org.bukkit.event.entity.EntityChangeBlockEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.hanging.HangingPlaceEvent;
import org.bukkit.event.player.PlayerBucketEmptyEvent;
import org.bukkit.event.player.PlayerBucketFillEvent;
import org.bukkit.event.world.StructureGrowEvent;
import java.util.List;
/**
* In the play world blocks may only change inside the arenas. Everything else (tribune, pads, the space
* between arenas) stays exactly as in the template.
*/
class WorldProtectionListener extends ApplianceListener<Arena> {
private boolean isProtected(Block block) {
return this.isProtected(block.getLocation());
}
private boolean isProtected(Location location) {
if(!this.getAppliance().worlds().isPlay(location.getWorld())) return false;
return !this.getAppliance().isInsideArena(location);
}
private void cancelIfProtected(Block block, Cancellable event) {
if(this.isProtected(block)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBucketEmpty(PlayerBucketEmptyEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBucketFill(PlayerBucketFillEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onFlow(BlockFromToEvent event) {
this.cancelIfProtected(event.getToBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onSpread(BlockSpreadEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onForm(BlockFormEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onFade(BlockFadeEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onIgnite(BlockIgniteEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBurn(BlockBurnEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onLeavesDecay(LeavesDecayEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onEntityChangeBlock(EntityChangeBlockEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onHangingPlace(HangingPlaceEvent event) {
this.cancelIfProtected(event.getBlock().getRelative(event.getBlockFace()), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onGrow(StructureGrowEvent event) {
if(!this.getAppliance().worlds().isPlay(event.getWorld())) return;
event.getBlocks().removeIf(state -> this.isProtected(state.getLocation()));
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPistonExtend(BlockPistonExtendEvent event) {
if(this.anyProtected(event.getBlock(), event.getBlocks(), event.getDirection().getModX(), event.getDirection().getModY(), event.getDirection().getModZ()))
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPistonRetract(BlockPistonRetractEvent event) {
if(this.anyProtected(event.getBlock(), event.getBlocks(), event.getDirection().getModX(), event.getDirection().getModY(), event.getDirection().getModZ()))
event.setCancelled(true);
}
private boolean anyProtected(Block piston, List<Block> moved, int dx, int dy, int dz) {
if(this.isProtected(piston) || this.isProtected(piston.getRelative(dx, dy, dz))) return true;
return moved.stream().anyMatch(block -> this.isProtected(block) || this.isProtected(block.getRelative(dx, dy, dz)));
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onEntityExplode(EntityExplodeEvent event) {
event.blockList().removeIf(this::isProtected);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBlockExplode(BlockExplodeEvent event) {
event.blockList().removeIf(this::isProtected);
}
}
@@ -0,0 +1,25 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
/**
* Which inventory the fighters use. The challenger chooses it.
*/
public enum FightMode {
OWN,
CHALLENGER,
OPPONENT;
/**
* With more than one player on a side, "the opponent's inventory" would be ambiguous.
*/
public boolean isAllowed(int ownSize, int opponentSize) {
return this != OPPONENT || (ownSize == 1 && opponentSize == 1);
}
public String displayName(boolean teams) {
return switch(this) {
case OWN -> "Jeder sein eigenes";
case CHALLENGER -> teams ? "Alle mit meinem" : "Beide mit meinem";
case OPPONENT -> "Beide mit dem des Gegners";
};
}
}
@@ -0,0 +1,282 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* State of the two start pads. The first player on a pad becomes the challenger and picks team sizes and mode.
* Everyone else joins a team by stepping on a pad. Once both pads hold exactly the chosen number of players
* and the challenger is done choosing (or took too long), the countdown runs and then the fight starts.
*/
public class PadLobby {
public static final int PADS = 2;
public static final int MAX_TEAM_SIZE = 4;
public interface Listener {
/** The player is now the challenger and may change the options until the countdown runs. */
void challengerSelected(UUID challenger, boolean first);
/** The player should now hold the inventory of {@code kitOwner} (the own one to revert). */
void kitAssigned(UUID player, UUID kitOwner);
/** Both pads are exactly full, but the challenger still has the options open. */
void waitingForChallenger();
/** Both pads are exactly full, the options are fixed now. */
void countdownStarted();
void countdown(int secondsLeft);
void countdownCancelled();
/**
* @param teams players per pad
* @param kits whose inventory each player fights with
*/
void start(List<List<UUID>> teams, UUID challenger, FightMode mode, Map<UUID, UUID> kits);
}
private final Listener listener;
private final int countdownTicks;
private final int choosingTicks;
private final List<List<UUID>> pads = List.of(new ArrayList<>(), new ArrayList<>());
/** Everyone on a pad, in the order they stepped on. The first one is the challenger. */
private final List<UUID> order = new ArrayList<>();
private final Map<UUID, UUID> kits = new HashMap<>();
private final int[] sizes = {1, 1};
private FightMode mode = FightMode.OWN;
private int ticksLeft = -1;
private boolean choosing;
/** How much longer everyone waits for the challenger to finish choosing; -1 while nobody waits. */
private int choosingTicksLeft = -1;
/**
* @param choosingSeconds how long a full lobby waits for the challenger to close the options
*/
public PadLobby(Listener listener, int countdownSeconds, int choosingSeconds) {
this.listener = listener;
this.countdownTicks = countdownSeconds * 20;
this.choosingTicks = choosingSeconds * 20;
}
/**
* @return false if the pad is already full or the player stands on a pad
*/
public boolean enter(int pad, UUID player) {
if(this.padOf(player) != -1) return false;
boolean first = this.order.isEmpty();
if(!first && this.pads.get(pad).size() >= this.sizes[pad]) return false;
this.pads.get(pad).add(player);
this.order.add(player);
this.kits.put(player, player);
if(first) {
this.sizes[0] = 1;
this.sizes[1] = 1;
this.mode = FightMode.OWN;
this.listener.challengerSelected(player, true);
return true;
}
this.refreshKits();
this.startCountdownIfReady();
return true;
}
public void leave(UUID player) {
int pad = this.padOf(player);
if(pad == -1) return;
boolean wasFull = this.isFull();
boolean wasChallenger = player.equals(this.challenger());
this.pads.get(pad).remove(player);
this.order.remove(player);
this.kits.remove(player);
this.listener.kitAssigned(player, player);
if(wasFull) {
if(this.isCountingDown()) this.listener.countdownCancelled();
this.ticksLeft = -1;
this.choosingTicksLeft = -1;
}
if(wasChallenger) this.choosing = false;
this.refreshKits();
// the longest waiting player takes over; team sizes and mode stay as chosen
if(wasChallenger && !this.order.isEmpty()) {
this.listener.challengerSelected(this.order.getFirst(), false);
}
}
public enum ConfigureResult {
OK,
NOT_ALLOWED,
OWN_TOO_SMALL,
OPPONENT_TOO_SMALL,
INVALID
}
/**
* Only the challenger can change the options, and only until the countdown runs.
* Teams cannot be smaller than the players already standing there.
*
* @param ownSize players on the challenger's pad
* @param opponentSize players on the other pad
*/
public ConfigureResult configure(UUID player, int ownSize, int opponentSize, FightMode mode) {
if(!this.canConfigure(player)) return ConfigureResult.NOT_ALLOWED;
if(ownSize < 1 || ownSize > MAX_TEAM_SIZE || opponentSize < 1 || opponentSize > MAX_TEAM_SIZE) return ConfigureResult.INVALID;
if(!mode.isAllowed(ownSize, opponentSize)) return ConfigureResult.INVALID;
int own = this.challengerPad();
if(ownSize < this.pads.get(own).size()) return ConfigureResult.OWN_TOO_SMALL;
if(opponentSize < this.pads.get(1 - own).size()) return ConfigureResult.OPPONENT_TOO_SMALL;
this.sizes[own] = ownSize;
this.sizes[1 - own] = opponentSize;
this.mode = mode;
this.refreshKits();
this.startCountdownIfReady();
return ConfigureResult.OK;
}
/**
* The challenger opened or closed the options. While they are open, a full lobby waits instead of counting down.
*/
public void setChoosing(UUID player, boolean choosing) {
if(!this.canConfigure(player) || this.choosing == choosing) return;
this.choosing = choosing;
this.startCountdownIfReady();
}
public boolean isWaitingForChallenger() {
return this.choosingTicksLeft >= 0;
}
private void startCountdownIfReady() {
if(this.isCountingDown()) return;
if(!this.isFull()) {
this.choosingTicksLeft = -1;
return;
}
if(this.choosing) {
if(this.choosingTicksLeft >= 0) return;
this.choosingTicksLeft = this.choosingTicks;
this.listener.waitingForChallenger();
return;
}
this.choosingTicksLeft = -1;
this.ticksLeft = this.countdownTicks;
this.listener.countdownStarted();
}
public boolean canConfigure(UUID player) {
return player.equals(this.challenger()) && !this.isCountingDown();
}
/**
* Called every server tick. The countdown only runs while an arena is free.
*/
public void tick(boolean arenaAvailable) {
if(this.choosingTicksLeft >= 0 && this.choosingTicksLeft-- == 0) {
this.choosing = false;
this.startCountdownIfReady();
}
if(this.ticksLeft < 0) return;
if(!arenaAvailable) {
this.ticksLeft = this.countdownTicks;
return;
}
if(this.ticksLeft % 20 == 0 && this.ticksLeft > 0) this.listener.countdown(this.ticksLeft / 20);
if(this.ticksLeft-- > 0) return;
List<List<UUID>> teams = List.of(List.copyOf(this.pads.get(0)), List.copyOf(this.pads.get(1)));
UUID challenger = this.challenger();
FightMode mode = this.mode;
Map<UUID, UUID> kits = Map.copyOf(this.kits);
this.pads.forEach(List::clear);
this.order.clear();
this.kits.clear();
this.sizes[0] = 1;
this.sizes[1] = 1;
this.mode = FightMode.OWN;
this.ticksLeft = -1;
this.choosing = false;
this.listener.start(teams, challenger, mode, kits);
}
/**
* Works out whose inventory everyone should hold and reports only the changes.
*/
private void refreshKits() {
UUID challenger = this.challenger();
if(challenger == null) return;
int challengerPad = this.challengerPad();
UUID firstOpponent = this.pads.get(1 - challengerPad).isEmpty() ? null : this.pads.get(1 - challengerPad).getFirst();
for(UUID player : this.order) {
UUID wanted = switch(this.mode) {
case OWN -> player;
case CHALLENGER -> challenger;
case OPPONENT -> player.equals(challenger) && firstOpponent != null ? firstOpponent : player;
};
if(wanted.equals(this.kits.get(player))) continue;
this.kits.put(player, wanted);
this.listener.kitAssigned(player, wanted);
}
}
public int padOf(UUID player) {
for(int i = 0; i < PADS; i++) {
if(this.pads.get(i).contains(player)) return i;
}
return -1;
}
public List<UUID> players(int pad) {
return List.copyOf(this.pads.get(pad));
}
public int size(int pad) {
return this.sizes[pad];
}
public boolean isPadFull(int pad) {
return !this.order.isEmpty() && this.pads.get(pad).size() >= this.sizes[pad];
}
public @Nullable UUID challenger() {
return this.order.isEmpty() ? null : this.order.getFirst();
}
public int challengerPad() {
UUID challenger = this.challenger();
return challenger == null ? -1 : this.padOf(challenger);
}
public FightMode mode() {
return this.mode;
}
public boolean isFull() {
return this.pads.get(0).size() == this.sizes[0] && this.pads.get(1).size() == this.sizes[1];
}
public boolean isCountingDown() {
return this.ticksLeft >= 0;
}
public boolean isIdle() {
return this.order.isEmpty();
}
/** Seconds until the start, rounded up; -1 without countdown. */
public int secondsLeft() {
return this.ticksLeft < 0 ? -1 : (this.ticksLeft + 19) / 20;
}
}
@@ -0,0 +1,58 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
/**
* Axis-aligned box of whole blocks, both corners inclusive.
*/
public record Region(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
public static Region of(int x1, int y1, int z1, int x2, int y2, int z2) {
return new Region(
Math.min(x1, x2), Math.min(y1, y2), Math.min(z1, z2),
Math.max(x1, x2), Math.max(y1, y2), Math.max(z1, z2)
);
}
public boolean contains(int x, int y, int z) {
return x >= this.minX && x <= this.maxX
&& y >= this.minY && y <= this.maxY
&& z >= this.minZ && z <= this.maxZ;
}
public boolean contains(double x, double y, double z) {
return this.contains((int) Math.floor(x), (int) Math.floor(y), (int) Math.floor(z));
}
public Region expand(int horizontal, int up) {
return new Region(
this.minX - horizontal, this.minY, this.minZ - horizontal,
this.maxX + horizontal, this.maxY + up, this.maxZ + horizontal
);
}
public long volume() {
return (long) (this.maxX - this.minX + 1) * (this.maxY - this.minY + 1) * (this.maxZ - this.minZ + 1);
}
public double centerX() {
return (this.minX + this.maxX + 1) / 2.0;
}
public double centerZ() {
return (this.minZ + this.maxZ + 1) / 2.0;
}
public int minChunkX() {
return this.minX >> 4;
}
public int maxChunkX() {
return this.maxX >> 4;
}
public int minChunkZ() {
return this.minZ >> 4;
}
public int maxChunkZ() {
return this.maxZ >> 4;
}
}
@@ -0,0 +1,52 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
/**
* Walks through every block of a region in portions, so a reset can be spread over several ticks.
*/
public class ResetCursor {
@FunctionalInterface
public interface BlockVisitor {
void visit(int x, int y, int z);
}
private final Region region;
private int x;
private int y;
private int z;
private boolean done;
public ResetCursor(Region region) {
this.region = region;
this.x = region.minX();
this.y = region.minY();
this.z = region.minZ();
}
/**
* Visits up to {@code budget} blocks, continuing where the last call stopped.
*
* @return number of visited blocks
*/
public int advance(int budget, BlockVisitor visitor) {
int visited = 0;
while(!this.done && visited < budget) {
visitor.visit(this.x, this.y, this.z);
visited++;
this.step();
}
return visited;
}
public boolean isDone() {
return this.done;
}
private void step() {
if(++this.y <= this.region.maxY()) return;
this.y = this.region.minY();
if(++this.z <= this.region.maxZ()) return;
this.z = this.region.minZ();
if(++this.x <= this.region.maxX()) return;
this.done = true;
}
}
@@ -0,0 +1,22 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class FightModeTest {
@Test
void opponentInventoryOnlyInOneVersusOne() {
assertTrue(FightMode.OPPONENT.isAllowed(1, 1));
assertFalse(FightMode.OPPONENT.isAllowed(1, 2));
assertFalse(FightMode.OPPONENT.isAllowed(3, 1));
assertTrue(FightMode.CHALLENGER.isAllowed(4, 3));
assertTrue(FightMode.OWN.isAllowed(2, 4));
}
@Test
void namesForTeams() {
assertEquals("Beide mit meinem", FightMode.CHALLENGER.displayName(false));
assertEquals("Alle mit meinem", FightMode.CHALLENGER.displayName(true));
}
}
@@ -0,0 +1,302 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
class PadLobbyTest {
private final UUID a = UUID.randomUUID();
private final UUID b = UUID.randomUUID();
private final UUID c = UUID.randomUUID();
private final UUID d = UUID.randomUUID();
private final UUID e = UUID.randomUUID();
private final List<String> events = new ArrayList<>();
private Map<UUID, UUID> startedKits;
private PadLobby lobby;
@BeforeEach
void setUp() {
this.lobby = new PadLobby(new PadLobby.Listener() {
public void challengerSelected(UUID challenger, boolean first) {
PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger) + (first ? " first" : ""));
}
public void waitingForChallenger() {
PadLobbyTest.this.events.add("waiting");
}
public void countdownStarted() {
PadLobbyTest.this.events.add("countdown started");
}
public void kitAssigned(UUID player, UUID kitOwner) {
PadLobbyTest.this.events.add("kit " + PadLobbyTest.this.name(player) + "<-" + PadLobbyTest.this.name(kitOwner));
}
public void countdown(int secondsLeft) {
PadLobbyTest.this.events.add("countdown " + secondsLeft);
}
public void countdownCancelled() {
PadLobbyTest.this.events.add("cancelled");
}
public void start(List<List<UUID>> teams, UUID challenger, FightMode mode, Map<UUID, UUID> kits) {
PadLobbyTest.this.startedKits = kits;
PadLobbyTest.this.events.add("start " + PadLobbyTest.this.names(teams.get(0)) + " vs " + PadLobbyTest.this.names(teams.get(1))
+ " by " + PadLobbyTest.this.name(challenger) + " " + mode);
}
}, 2, 3);
}
private String name(UUID uuid) {
if(uuid.equals(this.a)) return "a";
if(uuid.equals(this.b)) return "b";
if(uuid.equals(this.c)) return "c";
if(uuid.equals(this.d)) return "d";
return "e";
}
private String names(List<UUID> uuids) {
return uuids.stream().map(this::name).collect(Collectors.joining());
}
private void run(int ticks) {
for(int i = 0; i < ticks; i++) this.lobby.tick(true);
}
@Test
void firstOnPadBecomesChallengerAndMayConfigure() {
assertTrue(this.lobby.enter(1, this.a));
assertEquals(List.of("challenger a first"), this.events);
assertEquals(this.a, this.lobby.challenger());
assertTrue(this.lobby.canConfigure(this.a));
assertFalse(this.lobby.isCountingDown());
}
@Test
void oneVersusOneStartsAfterCountdown() {
this.lobby.enter(0, this.a);
assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER));
this.lobby.enter(1, this.b);
assertEquals(PadLobby.ConfigureResult.NOT_ALLOWED, this.lobby.configure(this.a, 1, 1, FightMode.OWN), "options are fixed during the countdown");
this.run(41);
assertEquals(List.of(
"challenger a first",
"kit b<-a",
"countdown started",
"countdown 2",
"countdown 1",
"start a vs b by a CHALLENGER"
), this.events);
assertEquals(Map.of(this.a, this.a, this.b, this.a), this.startedKits);
assertTrue(this.lobby.isIdle());
}
@Test
void fullPadIsRefusedAndPlayerOnlyTakesOnePad() {
this.lobby.enter(0, this.a);
this.lobby.enter(1, this.b);
assertFalse(this.lobby.enter(1, this.c), "1v1: the blue pad is full");
assertFalse(this.lobby.enter(0, this.b), "a player can only stand on one pad");
}
@Test
void unevenTeamsStartWhenBothPadsAreExactlyFull() {
this.lobby.enter(0, this.a);
assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 3, FightMode.OWN));
assertTrue(this.lobby.enter(0, this.b));
assertFalse(this.lobby.enter(0, this.c), "red is full with 2");
this.lobby.enter(1, this.c);
this.lobby.enter(1, this.d);
assertFalse(this.lobby.isCountingDown());
this.lobby.enter(1, this.e);
assertTrue(this.lobby.isCountingDown());
this.run(41);
assertEquals("start ab vs cde by a OWN", this.events.getLast());
}
@Test
void challengerKitGoesToEveryone() {
this.lobby.enter(0, this.a);
this.lobby.configure(this.a, 2, 2, FightMode.CHALLENGER);
this.lobby.enter(0, this.b);
this.lobby.enter(1, this.c);
this.lobby.enter(1, this.d);
assertEquals(List.of("challenger a first", "kit b<-a", "kit c<-a", "kit d<-a", "countdown started"), this.events);
}
@Test
void opponentModeOnlyForOneVersusOne() {
this.lobby.enter(0, this.a);
assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 1, FightMode.OPPONENT));
assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.OPPONENT));
this.lobby.enter(1, this.b);
assertTrue(this.events.contains("kit a<-b"));
this.lobby.leave(this.b);
assertTrue(this.events.contains("kit a<-a"), "challenger gets the own inventory back");
assertTrue(this.events.contains("cancelled"));
}
@Test
void onlyChallengerMayConfigure() {
this.lobby.enter(0, this.a);
assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.b, 2, 2, FightMode.OWN));
assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 5, 1, FightMode.OWN));
assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 0, 1, FightMode.OWN));
}
@Test
void countdownWaitsForFreeArena() {
this.lobby.enter(0, this.a);
this.lobby.enter(1, this.b);
for(int i = 0; i < 100; i++) this.lobby.tick(false);
assertFalse(this.events.contains("countdown 2"));
assertEquals(2, this.lobby.secondsLeft());
this.run(41);
assertEquals("start a vs b by a OWN", this.events.getLast());
}
@Test
void leavingCancelsCountdownAndRevertsKit() {
this.lobby.enter(0, this.a);
this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER);
this.lobby.enter(1, this.b);
this.events.clear();
this.lobby.leave(this.b);
assertEquals(List.of("kit b<-b", "cancelled"), this.events);
assertEquals(this.a, this.lobby.challenger());
assertEquals(FightMode.CHALLENGER, this.lobby.mode());
assertFalse(this.lobby.isCountingDown());
}
@Test
void longestWaitingTakesOverAndKeepsTeamSizes() {
this.lobby.enter(0, this.a);
this.lobby.configure(this.a, 2, 2, FightMode.CHALLENGER);
this.lobby.enter(1, this.b);
this.lobby.enter(0, this.c);
this.events.clear();
this.lobby.leave(this.a);
assertEquals(List.of("kit a<-a", "kit b<-b", "kit c<-b", "challenger b"), this.events);
assertEquals(this.b, this.lobby.challenger());
assertTrue(this.lobby.canConfigure(this.b), "the new challenger may change the options");
assertEquals(2, this.lobby.size(0));
assertEquals(2, this.lobby.size(1));
}
@Test
void handOverWhenAlone() {
this.lobby.enter(0, this.a);
this.lobby.enter(1, this.b);
this.lobby.leave(this.a);
assertEquals("challenger b", this.events.getLast());
assertTrue(this.lobby.canConfigure(this.b));
}
@Test
void challengerMayChangeOptionsWhileOthersWait() {
this.lobby.enter(0, this.a);
this.lobby.configure(this.a, 2, 2, FightMode.OWN);
this.lobby.enter(0, this.b);
this.lobby.enter(1, this.c);
assertEquals(PadLobby.ConfigureResult.OWN_TOO_SMALL, this.lobby.configure(this.a, 1, 2, FightMode.OWN),
"two already stand on the own pad");
assertEquals(2, this.lobby.size(0));
this.events.clear();
assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 1, FightMode.CHALLENGER));
assertEquals(List.of("kit b<-a", "kit c<-a", "countdown started"), this.events, "shrinking to the players there starts the countdown");
assertTrue(this.lobby.isCountingDown());
}
@Test
void opponentSideCannotShrinkBelowPlayers() {
this.lobby.enter(0, this.a);
this.lobby.configure(this.a, 1, 3, FightMode.OWN);
this.lobby.enter(1, this.b);
this.lobby.enter(1, this.c);
assertEquals(PadLobby.ConfigureResult.OPPONENT_TOO_SMALL, this.lobby.configure(this.a, 1, 1, FightMode.OWN));
assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 4, FightMode.OWN));
}
@Test
void lastPlayerLeavingResetsLobby() {
this.lobby.enter(0, this.a);
this.lobby.leave(this.a);
assertTrue(this.lobby.isIdle());
assertNull(this.lobby.challenger());
this.lobby.leave(this.b);
assertEquals(List.of("challenger a first", "kit a<-a"), this.events);
}
@Test
void fullLobbyWaitsWhileChallengerChooses() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
assertEquals("waiting", this.events.getLast());
assertFalse(this.lobby.isCountingDown());
assertTrue(this.lobby.isWaitingForChallenger());
assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER), "the menu stays usable");
this.lobby.setChoosing(this.a, false);
assertEquals("countdown started", this.events.getLast());
assertFalse(this.lobby.isWaitingForChallenger());
}
@Test
void choosingTimesOut() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
this.run(60);
assertFalse(this.lobby.isCountingDown());
this.run(1);
assertTrue(this.lobby.isCountingDown());
this.lobby.setChoosing(this.a, false);
assertEquals(1, this.events.stream().filter("countdown started"::equals).count());
}
@Test
void onlyChallengerChoosingBlocks() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.b, true);
this.lobby.enter(1, this.b);
assertTrue(this.lobby.isCountingDown());
}
@Test
void opponentLeavingWhileWaitingIsQuiet() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
this.lobby.leave(this.b);
assertFalse(this.events.contains("cancelled"));
assertFalse(this.lobby.isWaitingForChallenger());
}
@Test
void newChallengerIsNotChoosing() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
this.lobby.leave(this.a);
this.lobby.enter(0, this.c);
assertTrue(this.lobby.isCountingDown());
}
}
@@ -0,0 +1,52 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class RegionTest {
@Test
void cornersAreNormalized() {
assertEquals(new Region(-2, 1, -5, 3, 4, 6), Region.of(3, 4, -5, -2, 1, 6));
}
@Test
void containsIsInclusiveAndFloorsNegativeCoordinates() {
Region region = Region.of(-2, 0, -2, 1, 3, 1);
assertTrue(region.contains(-2, 0, -2));
assertTrue(region.contains(1, 3, 1));
assertFalse(region.contains(2, 0, 0));
assertTrue(region.contains(-1.5, 0.0, 1.99));
assertFalse(region.contains(-2.01, 0.0, 0.0));
assertFalse(region.contains(1.0, 4.0, 0.0));
}
@Test
void volumeAndChunks() {
Region region = Region.of(-1, 0, 15, 16, 9, 32);
assertEquals(18L * 10 * 18, region.volume());
assertEquals(-1, region.minChunkX());
assertEquals(1, region.maxChunkX());
assertEquals(0, region.minChunkZ());
assertEquals(2, region.maxChunkZ());
}
@Test
void resetCursorVisitsEveryBlockOnceInPortions() {
Region region = Region.of(0, 0, 0, 4, 3, 2);
ResetCursor cursor = new ResetCursor(region);
Set<String> visited = new HashSet<>();
int calls = 0;
while(!cursor.isDone()) {
int count = cursor.advance(7, (x, y, z) -> assertTrue(visited.add(x + "," + y + "," + z)));
assertTrue(count <= 7);
calls++;
}
assertEquals(region.volume(), visited.size());
assertEquals((int) Math.ceil(region.volume() / 7.0), calls);
assertEquals(0, cursor.advance(7, (x, y, z) -> fail()));
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version "8.3.5"
id 'com.gradleup.shadow' version "9.6.1"
}
allprojects {
@@ -29,7 +29,7 @@ subprojects {
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(25)
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
dependencies {
implementation project(':core')
compileOnly 'io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -11,17 +11,20 @@ public class CraftAttackReportRepository extends ReportRepository {
public ReqResp<PlayerReports> queryReports(UUID player) {
return this.get(
"report",
(parameters) -> parameters.addParameter("uuid", player.toString()),
"users/%s/reports".formatted(player.toString()),
PlayerReports.class
);
}
public ReqResp<ReportUrl> createReport(ReportCreationInfo data) {
return this.post(
"report",
"reports",
data,
ReportUrl.class
);
}
public ReqResp<AllReports> queryAllReports() {
return this.get("reports", AllReports.class);
}
}
@@ -21,22 +21,32 @@ public abstract class ReportRepository extends HttpRepository {
public record ReportUrl(@NotNull String url) {
}
public enum Status {
open,
closed,
}
public record PlayerReports(
List<Report> from_self,
Object to_self
List<Report> to_self
) {
public record Report(
@Nullable Reporter reported,
@NotNull String subject,
boolean draft,
@NotNull String status,
@Nullable UUID reported,
@NotNull String reason,
@Nullable Long created,
@Nullable Status status,
@NotNull String url
) {
public record Reporter(
@NotNull String username,
@NotNull String uuid
) {
}
}
) { }
}
public record AllReports(List<Report> reports) {
public record Report(
@NotNull UUID reporter,
@Nullable UUID reported,
@NotNull String reason,
@Nullable Long created,
@Nullable Status status,
@NotNull String url
) { }
}
}
@@ -14,7 +14,7 @@ import org.jetbrains.annotations.Nullable;
import java.util.UUID;
public class AdminMarker extends Appliance implements DisplayName.Colored {
public final static String adminPermission = "admin";
public final static String adminPermission = "adminmarker";
@Override
public @Nullable TextColor getNameColor(Player player) {
@@ -1,66 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import net.kyori.adventure.sound.Sound;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
public class ChatMention extends Appliance {
private static List<String> playerNames;
public String formatPlayer(String name) {
return "@" + name;
}
public List<String> getPlayerNames() {
return playerNames;
}
public void refreshPlayers() {
Bukkit.getScheduler().runTaskAsynchronously(
Main.instance(),
() -> playerNames = Arrays.stream(Bukkit.getOfflinePlayers())
.map(OfflinePlayer::getName)
.filter(Objects::nonNull)
.toList()
);
}
public void notifyPlayers(List<String> playerNames) {
playerNames.stream()
.distinct()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.filter(player -> Settings.instance()
.getSetting(player, Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class)
.notifyOnMention()
)
.forEach(player -> player.playSound(
Sound.sound(
org.bukkit.Sound.ENTITY_EXPERIENCE_ORB_PICKUP,
Sound.Source.PLAYER,
1.0f,
1.0f
)
));
}
@Override
public void onEnable() {
Settings.instance().declareSetting(ChatMentionSetting.class);
this.refreshPlayers();
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new ChatMentionListener());
}
}
@@ -1,56 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages.ChatMessages;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil;
import io.papermc.paper.event.player.AsyncChatDecorateEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import java.util.ArrayList;
import java.util.List;
class ChatMentionListener extends ApplianceListener<ChatMention> {
@SuppressWarnings("UnstableApiUsage")
@EventHandler
public void coloringEvent(AsyncChatDecorateEvent event) {
String message = PlainTextComponentSerializer.plainText().serialize(event.result());
List<String> words = List.of(message.split(" "));
List<String> mentioned = new ArrayList<>();
ChatMentionSetting.ChatMentionConfig config = Settings.instance()
.getSetting(event.player(), Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class);
ChatMessages chatMessages = Main.instance().getAppliance(ChatMessages.class);
Component result = words.stream()
.map(word -> {
String wordWithoutAnnotation = word.replace("@", "");
boolean isPlayer = this.getAppliance().getPlayerNames().contains(wordWithoutAnnotation);
if(isPlayer && config.applyMentions()) {
mentioned.add(wordWithoutAnnotation);
Component mention = Component.text(
this.getAppliance().formatPlayer(wordWithoutAnnotation),
NamedTextColor.GOLD
);
return chatMessages.addReportActions(mention, wordWithoutAnnotation);
} else {
return Component.text(word);
}
})
.reduce(ComponentUtil::appendWithSpace)
.orElseThrow();
this.getAppliance().notifyPlayers(mentioned);
event.result(result);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
this.getAppliance().refreshPlayers();
}
}
@@ -1,36 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class ChatMessages extends Appliance {
@Override
public void onEnable() {
Settings.instance().declareSetting(ShowJoinAndLeaveMessagesSetting.class);
}
public Component getReportablePlayerName(Player player) {
return this.addReportActions(player.displayName(), player.getName());
}
public Component addReportActions(Component message, String username) {
return message
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesen Spieler zu reporten").color(NamedTextColor.GOLD)))
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.SUGGEST_COMMAND, String.format("/report %s ", username)));
}
@Override
@NotNull
protected List<Listener> listeners() {
return List.of(new ChatMessagesListener());
}
}
@@ -1,74 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.util.IteratorUtil;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Color;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.Optional;
class ChatMessagesListener extends ApplianceListener<ChatMessages> {
@EventHandler
public void onPlayerChatEvent(AsyncChatEvent event) {
event.renderer(
(source, sourceDisplayName, message, viewer) ->
Component.text("")
.append(this.getAppliance().getReportablePlayerName(source))
.append(Component.text(" > ").color(TextColor.color(Color.GRAY.asRGB())))
.append(message).color(TextColor.color(Color.SILVER.asRGB()))
);
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlayerJoin(PlayerJoinEvent event) {
boolean wasHidden = event.joinMessage() == null;
event.joinMessage(null);
if(wasHidden) return;
IteratorUtil.onlinePlayers(player -> {
if(!Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class)) return;
player.sendMessage(
Component
.text(">>> ").color(NamedTextColor.GREEN)
.append(this.getAppliance().getReportablePlayerName(event.getPlayer()))
);
});
}
@EventHandler
public void onPlayerLeave(PlayerQuitEvent event) {
boolean wasHidden = event.quitMessage() == null;
event.quitMessage(null);
if(wasHidden) return;
IteratorUtil.onlinePlayers(player -> {
if(!Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class)) return;
player.sendMessage(
Component
.text("<<< ").color(NamedTextColor.RED)
.append(this.getAppliance().getReportablePlayerName(event.getPlayer()))
);
});
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.deathMessage(
Component
.text("☠ ")
.append(
Optional
.ofNullable(event.deathMessage())
.orElse(Component.text(event.getPlayer().getName()))
)
.color(TextColor.color(Color.SILVER.asRGB()))
);
}
}
@@ -2,10 +2,12 @@ package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.displayName;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat.NetworkChat;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentBuilder;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
@@ -49,15 +51,27 @@ public class DisplayName extends Appliance {
.map(appliance -> (Prefixed) appliance)
.toList();
ComponentBuilder<TextComponent, TextComponent.Builder> playerName = Component.text();
prefixes.stream()
List<Component> prefixComponents = prefixes.stream()
.map(prefixed -> prefixed.getNamePrefix(player))
.filter(Objects::nonNull)
.forEach(prefix -> playerName
.append(prefix)
.append(ComponentUtil.clearedSpace())
.toList();
ComponentBuilder<TextComponent, TextComponent.Builder> playerName = Component.text();
prefixComponents.forEach(prefix -> playerName
.append(prefix)
.append(ComponentUtil.clearedSpace())
);
// Den Chat-Namen baut Velocity selbst (Farbe, Bedrock), es braucht von hier nur die Prefixe
Main.instance().getAppliances().stream()
.filter(NetworkChat.class::isInstance)
.map(NetworkChat.class::cast)
.findFirst()
.ifPresent(networkChat -> networkChat.setPrefix(
player,
prefixComponents.isEmpty() ? null : Component.join(JoinConfiguration.separator(ComponentUtil.clearedSpace()), prefixComponents)
));
if(Floodgate.isBedrock(player)) {
playerName
.append(
@@ -19,7 +19,7 @@ public class HelpCommand extends ApplianceCommand<Help> {
Component.text("Willkommen auf Craftattack!", NamedTextColor.GOLD)
.appendNewline()
.append(Component.text("Wenn du hilfe benötigst kannst du dich jederzeit an einen Admin wenden." +
" Weitere Informationen zu Funktionen und Befehlen erhältst du zudem im Turm am Spawn.", NamedTextColor.GRAY))
" Weitere Informationen zu Funktionen und Befehlen erhältst du zudem am Spawn.", NamedTextColor.GRAY))
);
}
}
@@ -19,7 +19,8 @@ public class InfoBarSetting extends MultiBoolSetting<InfoBarSetting.InfoBarConfi
public record InfoBarConfiguration(
@DisplayName("Millisekunden pro Tick") boolean mspt,
@DisplayName("Spieler online") boolean playerCounter,
@DisplayName("Ticks pro Sekunde") boolean tps
@DisplayName("Ticks pro Sekunde") boolean tps,
@DisplayName("TPS, MSPT & Spieler") boolean tpsMsptPlayerCounter
) {}
@Override
@@ -39,13 +40,11 @@ public class InfoBarSetting extends MultiBoolSetting<InfoBarSetting.InfoBarConfi
@Override
protected InfoBarConfiguration defaultValue() {
return new InfoBarConfiguration(false, false, false);
return new InfoBarConfiguration(false, false, false, false);
}
@Override
public Class<?> dataType() {
return InfoBarConfiguration.class;
}
}
@@ -1,67 +1,41 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars.TpsMsptPlayerCounterBar;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars.MsptBar;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars.PlayerCounterBar;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars.TpsBar;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
public class InfoBars extends Appliance {
private final NamespacedKey infoBarKey = new NamespacedKey(Main.instance(), "infobars");
private final List<Bar> infoBars = List.of(
new TpsBar(),
new MsptBar(),
new PlayerCounterBar()
new PlayerCounterBar(),
new TpsMsptPlayerCounterBar()
);
public void showAllEnabled(Player player) {
this.getEnabledBars(player).forEach(bar -> this.show(player, bar));
InfoBarSetting.InfoBarConfiguration config = Settings.instance().getSetting(player, Settings.Key.InfoBars, InfoBarSetting.InfoBarConfiguration.class);
this.setVisible(player, MsptBar.name, config.mspt());
this.setVisible(player, PlayerCounterBar.name, config.playerCounter());
this.setVisible(player, TpsBar.name, config.tps());
this.setVisible(player, TpsMsptPlayerCounterBar.name, config.tpsMsptPlayerCounter());
}
public void hideAllEnabled(Player player) {
this.getEnabledBars(player).forEach(bar -> this.hide(player, bar));
this.setEnabledBars(player, List.of());
}
public void show(Player player, String bar) {
public void setVisible(Player player, String bar, boolean visible) {
this.validateBarName(bar);
List<String> existingBars = new ArrayList<>(this.getEnabledBars(player));
existingBars.add(bar);
player.showBossBar(this.getBarByName(bar).getBossBar());
this.setEnabledBars(player, existingBars);
}
public void hide(Player player, String bar) {
this.validateBarName(bar);
List<String> existingBars = new ArrayList<>(this.getEnabledBars(player));
existingBars.remove(bar);
player.hideBossBar(this.getBarByName(bar).getBossBar());
this.setEnabledBars(player, existingBars);
}
private List<String> getEnabledBars(Player player) {
PersistentDataContainer container = player.getPersistentDataContainer();
if(!container.has(this.infoBarKey)) return List.of();
return container.get(this.infoBarKey, PersistentDataType.LIST.strings());
}
private void setEnabledBars(Player player, List<String> bars) {
Bukkit.getScheduler().runTask(
Main.instance(),
() -> player.getPersistentDataContainer().set(this.infoBarKey, PersistentDataType.LIST.strings(), bars)
);
if(visible) {
player.showBossBar(this.getBarByName(bar).getBossBar());
} else {
player.hideBossBar(this.getBarByName(bar).getBossBar());
}
}
private Bar getBarByName(String name) {
@@ -79,13 +53,7 @@ public class InfoBars extends Appliance {
@Override
public void onEnable() {
Settings.instance().declareSetting(InfoBarSetting.class);
Settings.instance().addChangeListener(InfoBarSetting.class, player -> {
this.hideAllEnabled(player);
InfoBarSetting.InfoBarConfiguration config = Settings.instance().getSetting(player, Settings.Key.InfoBars, InfoBarSetting.InfoBarConfiguration.class);
if(config.mspt()) this.show(player, MsptBar.name);
if(config.playerCounter()) this.show(player, PlayerCounterBar.name);
if(config.tps()) this.show(player, TpsBar.name);
});
Settings.instance().addChangeListener(InfoBarSetting.class, this::showAllEnabled);
}
@Override
@@ -0,0 +1,103 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.Bar;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.playerlimit.PlayerLimit;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.util.statistics.ServerMonitor;
import eu.mhsl.craftattack.spawn.core.util.text.ColorUtil;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Bukkit;
import java.time.Duration;
public class TpsMsptPlayerCounterBar extends Bar {
public static String name = "serverStats";
@Override
protected Duration refresh() {
return Duration.ofSeconds(3);
}
@Override
protected String name() {
return name;
}
@Override
protected Component title() {
float tps = this.currentTps();
float mspt = this.currentMspt();
int players = this.currentPlayerCount();
Component separator = Component.text(" | ", NamedTextColor.DARK_GRAY);
TextColor color = ColorUtil.mapGreenToRed(players, 0, this.getMaxPlayerCount(), true);
return Component.text()
.append(Component.text("TPS: ", NamedTextColor.GRAY))
.append(Component.text(String.format("%.2f", tps), ColorUtil.tpsColor(tps)))
.append(separator)
.append(Component.text("MSPT: ", NamedTextColor.GRAY))
.append(Component.text(String.format("%.2f", mspt), ColorUtil.msptColor(mspt)))
.append(separator)
.append(Component.text("Spieler: ", NamedTextColor.GRAY))
.append(Component.text(players, color))
.build();
}
@Override
protected float progress() {
return Math.clamp(this.currentTps() / 20f, 0f, 1f);
}
@Override
protected BossBar.Color color() {
int severity = Math.max(
this.tpsSeverity(),
Math.max(this.msptSeverity(), this.playerSeverity())
);
return switch (severity) {
case 2 -> BossBar.Color.RED;
case 1 -> BossBar.Color.YELLOW;
default -> BossBar.Color.GREEN;
};
}
@Override
protected BossBar.Overlay overlay() {
return BossBar.Overlay.PROGRESS;
}
private int tpsSeverity() {
float tps = this.currentTps();
return tps >= 18 ? 0 : tps >= 15 ? 1 : 2;
}
private int msptSeverity() {
return this.currentMspt() <= 50 ? 0 : 2;
}
private int playerSeverity() {
int freeSlots = this.getMaxPlayerCount() - this.currentPlayerCount();
return freeSlots <= 0 ? 2 : freeSlots < 5 ? 1 : 0;
}
private float currentTps() {
return (float) Bukkit.getTPS()[0];
}
private float currentMspt() {
return ServerMonitor.getServerMSPT();
}
private int currentPlayerCount() {
return Bukkit.getOnlinePlayers().size();
}
private int getMaxPlayerCount() {
return Main.instance().getAppliance(PlayerLimit.class).getLimit();
}
}
@@ -1,4 +1,4 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention;
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory;
@@ -29,7 +29,7 @@ public class ChatMentionSetting extends MultiBoolSetting<ChatMentionSetting.Chat
@Override
protected String description() {
return "Erwähnungen werden automatisch im Chat angewandt und der Empfänger erhält einen Signalton";
return "Wirst du im Chat erwähnt, wird dein Name hervorgehoben und du hörst einen Signalton";
}
@Override
@@ -0,0 +1,101 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import com.google.common.io.ByteArrayDataInput;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Chat läuft über Velocity (Plugin chat-bridge): Der öffentliche Chat, /msg, /r, /adminchat, /mute und
* Join/Leave-Meldungen gelten im ganzen Netzwerk. Hier wird der Chat nur verworfen, Velocity bekommt
* die Rang-Prefixe für den Namen, und die Chat-Einstellungen werden mit Velocity abgeglichen.
* <p>
* Kanal {@value #channel}, Nachrichten (UTF-Typ, dann Daten):
* <ul>
* <li>→ Proxy {@code prefix}: Adventure-JSON oder leer</li>
* <li>→ Proxy {@code settings-hello} beim Join, {@code settings-set} nach einer Änderung: Join/Leave,
* Erwähnungen hervorheben, Erwähnungston (je boolean). Velocity speichert sie; kennt es schon
* Werte, antwortet es auf das Hello mit {@code settings}.</li>
* <li>← Proxy {@code settings}: gespeicherte Werte, werden hier übernommen</li>
* </ul>
*/
public class NetworkChat extends Appliance {
public static final String channel = "craftattack:chat";
private final Map<UUID, Component> prefixes = new ConcurrentHashMap<>();
@Override
public void onEnable() {
Bukkit.getMessenger().registerOutgoingPluginChannel(Main.instance(), channel);
Bukkit.getMessenger().registerIncomingPluginChannel(Main.instance(), channel, (channel, player, message) -> this.receive(player, message));
Settings.instance().declareSetting(ShowJoinAndLeaveMessagesSetting.class);
Settings.instance().declareSetting(ChatMentionSetting.class);
Settings.instance().addChangeListener(ShowJoinAndLeaveMessagesSetting.class, player -> this.sendSettings(player, "settings-set"));
Settings.instance().addChangeListener(ChatMentionSetting.class, player -> this.sendSettings(player, "settings-set"));
}
public void setPrefix(Player player, @Nullable Component prefix) {
if(prefix == null) this.prefixes.remove(player.getUniqueId());
else this.prefixes.put(player.getUniqueId(), prefix);
this.sendPrefix(player);
}
void sendPrefix(Player player) {
Component prefix = this.prefixes.get(player.getUniqueId());
ByteArrayDataOutput output = ByteStreams.newDataOutput();
output.writeUTF("prefix");
output.writeUTF(prefix == null ? "" : GsonComponentSerializer.gson().serialize(prefix));
this.send(player, output);
}
void sendSettings(Player player, String type) {
ChatMentionSetting.ChatMentionConfig mentions = Settings.instance()
.getSetting(player, Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class);
ByteArrayDataOutput output = ByteStreams.newDataOutput();
output.writeUTF(type);
output.writeBoolean(Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class));
output.writeBoolean(mentions.applyMentions());
output.writeBoolean(mentions.notifyOnMention());
this.send(player, output);
}
private void receive(Player player, byte[] message) {
ByteArrayDataInput input = ByteStreams.newDataInput(message);
if(!input.readUTF().equals("settings")) return;
boolean joinAndLeave = input.readBoolean();
boolean applyMentions = input.readBoolean();
boolean notifyOnMention = input.readBoolean();
Settings.instance().setSetting(player, Settings.Key.ShowJoinAndLeaveMessages, joinAndLeave);
Settings.instance().setSetting(player, Settings.Key.ChatMentions, new ChatMentionSetting.ChatMentionConfig(applyMentions, notifyOnMention));
}
private void send(Player player, ByteArrayDataOutput output) {
// Velocity meldet den Kanal erst kurz nach dem Join an, dann folgt PlayerRegisterChannelEvent
if(!player.getListeningPluginChannels().contains(channel)) return;
player.sendPluginMessage(Main.instance(), channel, output.toByteArray());
}
void forget(Player player) {
this.prefixes.remove(player.getUniqueId());
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new NetworkChatListener());
}
}
@@ -0,0 +1,74 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Color;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRegisterChannelEvent;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
class NetworkChatListener extends ApplianceListener<NetworkChat> {
// Vanilla-Befehle, die am Proxy vorbei in den lokalen Chat schreiben; /msg, /tell, /w fängt Velocity ab
private static final Set<String> localChatCommands = Set.of(
"me", "minecraft:me", "teammsg", "tm", "minecraft:teammsg", "minecraft:tm",
"minecraft:msg", "minecraft:tell", "minecraft:w"
);
@EventHandler(priority = EventPriority.HIGHEST)
public void onChat(AsyncChatEvent event) {
// Velocity hat die Nachricht schon an alle verteilt
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onCommand(PlayerCommandPreprocessEvent event) {
String label = event.getMessage().substring(1).split(" ", 2)[0].toLowerCase(Locale.ROOT);
if(!localChatCommands.contains(label)) return;
event.setCancelled(true);
event.getPlayer().sendMessage(Component.text("Dieser Befehl ist hier nicht verfügbar.", NamedTextColor.RED));
}
@EventHandler
public void onRegisterChannel(PlayerRegisterChannelEvent event) {
if(!event.getChannel().equals(NetworkChat.channel)) return;
this.getAppliance().sendPrefix(event.getPlayer());
this.getAppliance().sendSettings(event.getPlayer(), "settings-hello");
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onJoin(PlayerJoinEvent event) {
// Join- und Leave-Meldungen schreibt Velocity, beim Serverwechsel gar keine
event.joinMessage(null);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onQuit(PlayerQuitEvent event) {
event.quitMessage(null);
this.getAppliance().forget(event.getPlayer());
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.deathMessage(
Component
.text("☠ ")
.append(
Optional
.ofNullable(event.deathMessage())
.orElse(Component.text(event.getPlayer().getName()))
)
.color(TextColor.color(Color.SILVER.asRGB()))
);
}
}
@@ -1,4 +1,4 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages;
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory;
@@ -1,194 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages.ChatMessages;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands.PrivateMessageCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands.PrivateReplyCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentBuilder;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.stream.Collectors;
public class PrivateMessage extends Appliance {
public final int targetChangeTimeoutSeconds = 30;
public final int conversationTimeoutMinutes = 30;
private record Conversation(UUID target, Long lastSet) {
}
private final Map<Player, List<Conversation>> replyMapping = new WeakHashMap<>();
public void reply(Player sender, String message) {
this.replyMapping.computeIfAbsent(sender, player -> new ArrayList<>());
List<Conversation> replyList = this.replyMapping.get(sender);
List<Conversation> tooOldConversations = replyList.stream()
.filter(conversation -> conversation.lastSet < System.currentTimeMillis() - (this.conversationTimeoutMinutes * 60 * 1000))
.toList();
replyList.removeAll(tooOldConversations);
if(replyList.isEmpty()) throw new ApplianceCommand.Error("Du führst aktuell keine Konversation.");
Component privatePrefix = Component.text("[Privat] ", NamedTextColor.LIGHT_PURPLE);
Conversation youngestEntry = replyList.stream()
.max(Comparator.comparingLong(o -> o.lastSet))
.orElse(replyList.getLast());
if(message.isBlank()) {
Player currentTargetPlayer = Bukkit.getPlayer(youngestEntry.target());
Component currentTargetComponent = currentTargetPlayer != null
? Main.instance().getAppliance(ChatMessages.class).getReportablePlayerName(currentTargetPlayer)
: Component.text("niemandem.");
sender.sendMessage(
privatePrefix
.append(Component.text("Du schreibst aktuell mit ", NamedTextColor.GRAY))
.append(currentTargetComponent)
);
return;
}
List<Conversation> oldConversations = replyList.stream()
.filter(conversation -> conversation.lastSet < System.currentTimeMillis() - (this.targetChangeTimeoutSeconds * 1000))
.toList();
if(oldConversations.contains(youngestEntry) || replyList.size() == 1) {
Player target = Bukkit.getPlayer(youngestEntry.target());
if(target == null)
throw new ApplianceCommand.Error("Der Spieler " + Bukkit.getOfflinePlayer(youngestEntry.target()).getName() + " ist nicht mehr verfügbar.");
replyList.clear();
this.sendWhisper(sender, new ResolvedPmUserArguments(target, message));
return;
}
ComponentBuilder<TextComponent, TextComponent.Builder> component = Component.text();
component.append(
Component.newline()
.append(privatePrefix)
.append(Component.text("Das Ziel für /r hat sich bei dir in den letzten ", NamedTextColor.RED))
.append(Component.text(String.valueOf(this.targetChangeTimeoutSeconds), NamedTextColor.RED))
.append(Component.text(" Sekunden geändert. Wer soll deine Nachricht erhalten? ", NamedTextColor.RED))
.appendNewline()
.appendNewline()
);
if(!oldConversations.isEmpty()) {
Conversation youngestOldConversation = oldConversations.stream()
.max(Comparator.comparingLong(o -> o.lastSet))
.orElse(oldConversations.getLast());
replyList.removeAll(oldConversations);
replyList.add(youngestOldConversation);
}
List<String> playerNames = replyList.stream()
.map(conversation -> Bukkit.getOfflinePlayer(conversation.target()).getName())
.distinct()
.toList();
playerNames.forEach(playerName -> component.append(
Component.text("[")
.append(Component.text(playerName, NamedTextColor.GOLD))
.append(Component.text("]"))
.clickEvent(ClickEvent.runCommand(String.format("/msg %s %s", playerName, message)))
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesem Spieler zu schreiben.").color(NamedTextColor.GOLD))))
.append(Component.text(" "))
);
component.appendNewline();
sender.sendMessage(component.build());
}
public void sendWhisper(Player sender, ResolvedPmUserArguments userArguments) {
Conversation newReceiverConversation = new Conversation(
sender.getUniqueId(),
System.currentTimeMillis()
);
if(this.replyMapping.get(userArguments.receiver) != null) {
List<Conversation> oldEntries = this.replyMapping.get(userArguments.receiver).stream()
.filter(conversation -> conversation.target() == sender.getUniqueId())
.toList();
this.replyMapping.get(userArguments.receiver).removeAll(oldEntries);
} else {
this.replyMapping.put(
userArguments.receiver,
new ArrayList<>()
);
}
this.replyMapping.get(userArguments.receiver).add(newReceiverConversation);
List<Conversation> senderConversationList = new ArrayList<>();
senderConversationList.add(
new Conversation(
userArguments.receiver.getUniqueId(),
System.currentTimeMillis()
)
);
this.replyMapping.put(
sender,
senderConversationList
);
ChatMessages chatMessages = Main.instance().getAppliance(ChatMessages.class);
Component privatePrefix = Component.text("[Privat] ", NamedTextColor.LIGHT_PURPLE);
sender.sendMessage(
Component.text()
.append(privatePrefix.clickEvent(ClickEvent.suggestCommand(String.format("/msg %s ", userArguments.receiver.getName()))))
.append(sender.displayName())
.append(Component.text(" zu ", NamedTextColor.GRAY))
.append(chatMessages.getReportablePlayerName(userArguments.receiver))
.append(Component.text(" > ", NamedTextColor.GRAY))
.append(Component.text(userArguments.message))
);
userArguments.receiver.sendMessage(
Component.text()
.append(privatePrefix.clickEvent(ClickEvent.suggestCommand(String.format("/msg %s ", sender.getName()))))
.append(chatMessages.getReportablePlayerName(sender))
.append(Component.text(" zu ", NamedTextColor.GRAY))
.append(userArguments.receiver.displayName())
.append(Component.text(" > ", NamedTextColor.GRAY))
.append(Component.text(userArguments.message))
);
}
public record ResolvedPmUserArguments(Player receiver, String message) {
}
public ResolvedPmUserArguments resolveImplicit(String[] args) {
if(args.length < 2)
throw new ApplianceCommand.Error("Es muss ein Spieler sowie eine Nachricht angegeben werden.");
List<String> arguments = List.of(args);
Player targetPlayer = Bukkit.getPlayer(arguments.getFirst());
if(targetPlayer == null)
throw new ApplianceCommand.Error(String.format("Der Spieler %s konnte nicht gefunden werden.", arguments.getFirst()));
String message = arguments.stream().skip(1).collect(Collectors.joining(" "));
return new ResolvedPmUserArguments(targetPlayer, message);
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(
new PrivateMessageCommand(),
new PrivateReplyCommand()
);
}
}
@@ -1,18 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.PrivateMessage;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
public class PrivateMessageCommand extends ApplianceCommand.PlayerChecked<PrivateMessage> {
public PrivateMessageCommand() {
super("msg");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
this.getAppliance().sendWhisper(this.getPlayer(), this.getAppliance().resolveImplicit(args));
}
}
@@ -1,18 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.PrivateMessage;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
public class PrivateReplyCommand extends ApplianceCommand.PlayerChecked<PrivateMessage> {
public PrivateReplyCommand() {
super("r");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
this.getAppliance().reply(this.getPlayer(), String.join(" ", args));
}
}
@@ -1,11 +1,14 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.report;
import eu.mhsl.craftattack.spawn.common.api.repositories.ReportRepository;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.report.listeners.ReportCreatedListener;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.report.listeners.ReportJoinListener;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.client.ReqResp;
import eu.mhsl.craftattack.spawn.common.api.repositories.CraftAttackReportRepository;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentBuilder;
import net.kyori.adventure.text.TextComponent;
@@ -15,11 +18,14 @@ import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
public class Report extends Appliance {
public static Component helpText() {
@@ -78,8 +84,8 @@ public class Report extends Appliance {
.appendNewline()
.append(
Component
.text(createdReport.data().url(), NamedTextColor.GRAY) // URL mit Weltkugel-Emoji
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, createdReport.data().url()))
.text(createdReport.data().url(), NamedTextColor.GRAY)
.clickEvent(ClickEvent.openUrl(createdReport.data().url()))
)
.appendNewline()
.appendNewline()
@@ -128,44 +134,80 @@ public class Report extends Appliance {
return;
}
List<ReportRepository.PlayerReports.Report> reports = userReports
.data()
.from_self()
.stream()
.filter(report -> !report.draft())
.toList()
.reversed();
Function<List<ReportRepository.PlayerReports.Report>, List<ReportRepository.PlayerReports.Report>> filterClosed = reports -> reports.stream()
.filter(report -> Objects.equals(report.status(), ReportRepository.Status.closed))
.toList();
if(reports.isEmpty()) {
issuer.sendMessage(
Component.text()
.append(Component.text("Du hast noch niemanden reportet.", NamedTextColor.RED))
.appendNewline()
.append(Component.text("Um jemanden zu melden, nutze /report", NamedTextColor.GRAY))
List<ReportRepository.PlayerReports.Report> reportsToOthers = filterClosed.apply(userReports.data().from_self()).reversed();
List<ReportRepository.PlayerReports.Report> reportsToSelf = filterClosed.apply(userReports.data().to_self()).reversed();
ComponentBuilder<TextComponent, TextComponent.Builder> component = Component.text()
.append(Component.text(
!reportsToSelf.isEmpty()
? "Du wurdest insgesamt %d mal von anderen Spielern gemeldet.".formatted(reportsToSelf.size())
: "Du wurdest von keinem anderen Spieler gemeldet.",
NamedTextColor.GOLD)
);
component.appendNewline();
component.append(Component.text("Von dir erstellte Reports: ", NamedTextColor.GOLD));
reportsToOthers.forEach(report -> {
Component button = Component.text("[\uD83D\uDC41/\uD83D\uDD8A]")
.clickEvent(ClickEvent.openUrl(report.url()))
.hoverEvent(HoverEvent.showText(ComponentUtil.clickLink(report.url())));
Component reportedDisplayName = report.reported() != null
? Component.text(Optional.ofNullable(Bukkit.getOfflinePlayer(report.reported()).getName()).orElse(report.reported().toString()), NamedTextColor.WHITE)
: Component.text("Unbekannt", NamedTextColor.YELLOW);
component
.appendNewline()
.append(Component.text(" \u27A1 ", NamedTextColor.GRAY))
.append(button)
.append(Component.text(" du gegen ", NamedTextColor.GRAY))
.append(reportedDisplayName)
.append(Component.text(String.format(": %s", report.reason()), NamedTextColor.GRAY));
});
if(reportsToOthers.isEmpty()) {
component
.appendNewline()
.append(Component.text("Du hast noch niemanden reportet.", NamedTextColor.RED))
.appendNewline()
.append(Component.text("Um jemanden zu melden, nutze /report", NamedTextColor.GRAY));
}
issuer.sendMessage(component.build());
}
public void sendReportsInfo(Player player) {
ReqResp<ReportRepository.AllReports> allReportsResponse = this.queryRepository(CraftAttackReportRepository.class).queryAllReports();
if(allReportsResponse.status() != 200) {
Main.logger().warning("Failed to request Reports: " + allReportsResponse.status());
return;
}
ComponentBuilder<TextComponent, TextComponent.Builder> component = Component.text()
.append(Component.newline())
.append(Component.text("Von dir erstellte Reports: ", NamedTextColor.GOLD))
.appendNewline();
List<ReportRepository.AllReports.Report> allOpenReports = allReportsResponse.data().reports().stream()
.filter(report -> report.status() == null && report.created() != null)
.toList();
reports.forEach(report -> {
component
.append(Component.text(" - ", NamedTextColor.WHITE))
.append(
report.reported() != null
? Component.text(report.reported().username(), NamedTextColor.WHITE)
: Component.text("Unbekannt", NamedTextColor.YELLOW)
)
.append(Component.text(String.format(": %s", report.subject()), NamedTextColor.GRAY))
.clickEvent(ClickEvent.openUrl(report.url()))
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um den Report einzusehen.", NamedTextColor.GOLD)));
component.appendNewline();
});
if(allOpenReports.isEmpty()) return;
issuer.sendMessage(component.build());
player.sendMessage(
Component.text("Hey, es gibt noch ", NamedTextColor.GOLD)
.append(Component.text(allOpenReports.size(), NamedTextColor.RED))
.append(Component.text(" unbearbeitete Reports!", NamedTextColor.GOLD))
);
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(
new ReportCreatedListener(),
new ReportJoinListener()
);
}
@Override
@@ -13,6 +13,7 @@ import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -46,7 +47,7 @@ class ReportCommand extends ApplianceCommand.PlayerChecked<Report> {
response = Stream.concat(
Bukkit.getOnlinePlayers().stream().map(Player::getName),
Arrays.stream(Bukkit.getOfflinePlayers()).map(OfflinePlayer::getName)
).toList();
).filter(Objects::nonNull).distinct().toList();
}
if(args.length == 2) {
@@ -0,0 +1,27 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.report.listeners;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.report.Report;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.core.event.ReportCreatedEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.event.EventHandler;
public class ReportCreatedListener extends ApplianceListener<Report> {
@EventHandler
public void onReport(ReportCreatedEvent event) {
OfflinePlayer reporter = Bukkit.getOfflinePlayer(event.getReport().reporter());
OfflinePlayer reported = Bukkit.getOfflinePlayer(event.getReport().reported());
Component message = Component.text(
"\uD83D\uDD14 Neuer Report von %s gegen %s: %s".formatted(reporter.getName(), reported.getName(), event.getReport().reason()),
NamedTextColor.YELLOW
);
Bukkit.getOnlinePlayers().stream()
.filter(player -> player.hasPermission("admin"))
.forEach(player -> player.sendMessage(message));
}
}
@@ -0,0 +1,19 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.report.listeners;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.report.Report;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
public class ReportJoinListener extends ApplianceListener<Report> {
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
if(!event.getPlayer().hasPermission("admin")) return;
Bukkit.getScheduler().runTaskAsynchronously(
Main.instance(),
() -> this.getAppliance().sendReportsInfo(event.getPlayer())
);
}
}
@@ -112,6 +112,18 @@ public class Settings extends Appliance {
return clazz.cast(setting.state());
}
/** Setzt einen Wert von außen (z. B. vom Proxy), ohne die Change-Listener auszulösen. */
public <T> void setSetting(Player player, Key key, T value) {
Setting<?> setting = this.getSettings(player).stream()
.filter(s -> Objects.equals(s.getKey(), key))
.findFirst()
.orElseThrow();
if(!setting.dataType().isInstance(value))
throw new IllegalStateException("Tried to set Setting with Datatype " + value.getClass().getSimpleName() + " but expected " + setting.dataType().getSimpleName());
setting.restore(player, value);
}
public void openSettings(Player player) {
List<Setting<?>> settings = this.getSettings(player);
Inventory inventory = Bukkit.createInventory(null, this.calculateInvSize(settings), Component.text("Einstellungen"));
@@ -128,12 +140,12 @@ public class Settings extends Appliance {
if(categorizedSettings.isEmpty()) return;
for(int i = 0; i < categorizedSettings.size(); i++) {
int slot = row.get() * 9 + i % 9;
inventory.setItem(slot, categorizedSettings.get(i).buildItem());
if(i % 9 == 8) {
if(i % 9 == 0 && i != 0) {
row.incrementAndGet();
}
int slot = row.get() * 9 + i % 9;
inventory.setItem(slot, categorizedSettings.get(i).buildItem());
}
row.incrementAndGet();
});
@@ -143,12 +155,12 @@ public class Settings extends Appliance {
.toList();
for(int i = 0; i < uncategorizedSettings.size(); i++) {
int slot = row.get() * 9 + i % 9;
inventory.setItem(slot, uncategorizedSettings.get(i).buildItem());
if(i % 9 == 8) {
if(i % 9 == 0 && i != 0) {
row.incrementAndGet();
}
int slot = row.get() * 9 + i % 9;
inventory.setItem(slot, uncategorizedSettings.get(i).buildItem());
}
player.openInventory(inventory);
@@ -25,7 +25,7 @@ public class SettingsShortcutSetting extends BoolSetting implements CategorizedS
@Override
protected Boolean defaultValue() {
return false;
return true;
}
@Override
@@ -62,6 +62,12 @@ public abstract class Setting<TDataType> {
Settings.instance().invokeChangeListener(p, this.getClass());
}
@SuppressWarnings("unchecked")
public void restore(Player p, Object value) {
this.state = (TDataType) value;
this.toStorage(p.getPersistentDataContainer(), this.state);
}
public ItemStack buildItem() {
ItemStack stack = new ItemStack(this.icon(), 1);
stack.setItemMeta(this.buildMeta(stack.getItemMeta()));
@@ -0,0 +1,45 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiBoatFreecam;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.acInform.AcInform;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Bukkit;
import org.bukkit.entity.Boat;
@SuppressWarnings("unused")
@Appliance.Flags(enabled = false)
public class AntiBoatFreecam extends Appliance {
private static final float MAX_YAW_OFFSET = 106.0f;
public AntiBoatFreecam() {
Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(),
() -> Bukkit.getOnlinePlayers().forEach(player -> {
if(!(player.getVehicle() instanceof Boat boat)) return;
if(!boat.getPassengers().getFirst().equals(player)) return;
float playerYaw = player.getYaw();
float boatYaw = boat.getYaw();
float yawDelta = wrapDegrees(playerYaw - boatYaw);
if(Math.abs(yawDelta) <= MAX_YAW_OFFSET) return;
Main.instance().getAppliance(AcInform.class).slowedNotifyAdmins(
"internal",
player.getName(),
"illegalBoatLookYaw",
yawDelta,
3000
);
}),
1L,
1L
);
}
private static float wrapDegrees(float deg) {
deg = deg % 360f;
if (deg >= 180f) deg -= 360f;
if (deg < -180f) deg += 360f;
return deg;
}
}
@@ -0,0 +1,66 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiFormattedBook;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import net.kyori.adventure.text.*;
import net.kyori.adventure.text.format.Style;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.event.Listener;
import org.bukkit.inventory.meta.BookMeta;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Objects;
public class AntiFormattedBook extends Appliance {
private static final char SECTION = '\u00A7';
public boolean containsFormatting(BookMeta meta) {
if (this.hasFormattingDeep(meta.title())) return true;
if (this.hasFormattingDeep(meta.author())) return true;
for (Component c : meta.pages()) {
if (this.hasFormattingDeep(c)) return true;
}
return false;
}
private boolean hasFormattingDeep(@Nullable Component component) {
if(component == null) return false;
if (this.hastFormatting(component)) return true;
if (component instanceof TextComponent tc && tc.content().indexOf(SECTION) >= 0) return true;
if (component instanceof NBTComponent<?> nbt) {
if (nbt.separator() != null && this.hasFormattingDeep(nbt.separator())) return true;
}
for (Component child : component.children()) {
if (this.hasFormattingDeep(child)) return true;
}
return false;
}
private boolean hastFormatting(Component component) {
Style style = component.style();
TextColor color = style.color();
if (color != null) return true;
if (style.font() != null) return true;
if (style.insertion() != null && !Objects.requireNonNull(style.insertion()).isEmpty()) return true;
for (var decoration : style.decorations().entrySet()) {
if (decoration.getValue() == TextDecoration.State.TRUE) return true;
}
return style.hoverEvent() != null || style.clickEvent() != null;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(
new BookEditListener()
);
}
}
@@ -0,0 +1,36 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiFormattedBook;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.acInform.AcInform;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import net.kyori.adventure.text.Component;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerEditBookEvent;
import org.bukkit.inventory.meta.BookMeta;
import java.util.List;
class BookEditListener extends ApplianceListener<AntiFormattedBook> {
@EventHandler
public void onBookEdit(PlayerEditBookEvent event) {
Player player = event.getPlayer();
BookMeta meta = event.getNewBookMeta();
if (this.getAppliance().containsFormatting(meta)) {
Main.instance().getAppliance(AcInform.class).notifyAdmins(
"internal",
player.getName(),
"illegalBookFormatting",
1f
);
BookMeta sanitized = meta.clone();
sanitized.title(null);
sanitized.author(null);
//noinspection ResultOfMethodCallIgnored
sanitized.pages(List.of(Component.empty()));
event.setNewBookMeta(sanitized);
}
}
}
@@ -0,0 +1,78 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiIllegalBundlePicker;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.acInform.AcInform;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BundleMeta;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.stream.Collectors;
public class AntiIllegalBundlePicker extends Appliance {
private static final int visibleSlotsInBundle = 9;
public void trackBundle(InventoryClickEvent event) {
ItemStack bundle = Objects.requireNonNull(event.getCurrentItem());
final int rawSlot = event.getRawSlot();
final Player player = (Player) event.getWhoClicked();
final InventoryView view = event.getView();
final List<ItemStack> before = this.getBundleContents(bundle);
Bukkit.getScheduler().runTask(Main.instance(), () -> {
ItemStack afterStack = view.getItem(rawSlot);
if(afterStack == null || afterStack.getType() != Material.BUNDLE) return;
List<ItemStack> after = this.getBundleContents(afterStack);
int removedSlotIndex = this.findRemoved(before, after);
if(removedSlotIndex >= visibleSlotsInBundle) {
Main.instance().getAppliance(AcInform.class).notifyAdmins(
"internal",
player.getName(),
"illegalBundlePick",
(float) removedSlotIndex
);
}
});
}
private int findRemoved(@NotNull List<ItemStack> before, @NotNull List<ItemStack> after) {
for (int i = 0; i < Math.max(before.size(), after.size()); i++) {
ItemStack a = i < after.size() ? after.get(i) : null;
ItemStack b = i < before.size() ? before.get(i) : null;
if (b == null && a == null) continue;
if (b == null) throw new IllegalStateException("Size of bundle was smaller before pickup Action");
if (a == null) return i;
if (!a.isSimilar(b)) return i;
if (a.getAmount() != b.getAmount()) return i;
}
throw new IllegalStateException("Failed to find picked Item in bundle");
}
private List<ItemStack> getBundleContents(@NotNull ItemStack bundle) {
if (bundle.getType() != Material.BUNDLE)
throw new IllegalStateException("ItemStack is not a bundle");
BundleMeta meta = (BundleMeta) bundle.getItemMeta();
return meta.getItems().stream()
.map(ItemStack::clone)
.collect(Collectors.toCollection(ArrayList::new));
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(
new OnBundlePickListener()
);
}
}
@@ -0,0 +1,18 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiIllegalBundlePicker;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.Material;
import org.bukkit.event.EventHandler;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.ItemStack;
class OnBundlePickListener extends ApplianceListener<AntiIllegalBundlePicker> {
@EventHandler
public void onBundlePick(InventoryClickEvent event) {
if(!event.getAction().equals(InventoryAction.PICKUP_FROM_BUNDLE)) return;
final ItemStack bundle = event.getCurrentItem();
if (bundle == null || bundle.getType() != Material.BUNDLE) return;
this.getAppliance().trackBundle(event);
}
}
@@ -0,0 +1,16 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiIllegalSignCharacters;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class AntiIllegalSignCharacters extends Appliance {
@Override
protected @NotNull List<Listener> listeners() {
return List.of(
new SignEditListener()
);
}
}
@@ -0,0 +1,77 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiIllegalSignCharacters;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.SignChangeEvent;
import java.text.Normalizer;
import java.util.Set;
class SignEditListener extends ApplianceListener<AntiIllegalSignCharacters> {
private static final Set<Integer> ALLOWED_CHARS = Set.of(
(int)' ', (int)'.', (int)',', (int)';', (int)':', (int)'!', (int)'?',
(int)'"', (int)'\'',
(int)'(', (int)')', (int)'[', (int)']', (int)'{', (int)'}',
(int)'-', (int)'_', (int)'+', (int)'=', (int)'/', (int)'\\',
(int)'@', (int)'#', (int)'$', (int)'%', (int)'&', (int)'*',
(int)'<', (int)'>', (int)'|',
(int)'~', (int)'`', (int)'^'
);
private static final Set<Integer> ALLOWED_EXTRA = Set.of(
(int)'Ä', (int)'Ö', (int)'Ü', (int)'ä', (int)'ö', (int)'ü', (int)'ß',
(int)'€', (int)'°', (int)'µ'
);
@EventHandler
public void onSignEdit(SignChangeEvent event) {
for (int i = 0; i < 4; i++) {
Component line = event.line(i);
if (line == null) continue;
String plainString = PlainTextComponentSerializer.plainText().serialize(line);
plainString = Normalizer.normalize(plainString, Normalizer.Form.NFC);
String cleaned = filterAllowed(plainString);
event.line(i, Component.text(cleaned));
}
}
private static String filterAllowed(String s) {
StringBuilder out = new StringBuilder(s.length());
for (int off = 0; off < s.length(); ) {
int cp = s.codePointAt(off);
off += Character.charCount(cp);
if (isForbidden(cp)) continue;
if (Character.isLetterOrDigit(cp)) {
out.appendCodePoint(cp);
continue;
}
if (ALLOWED_CHARS.contains(cp) || ALLOWED_EXTRA.contains(cp)) {
out.appendCodePoint(cp);
}
}
return out.toString();
}
private static boolean isForbidden(int cp) {
// Surrogates / invalid
if (cp >= 0xD800 && cp <= 0xDFFF) return true;
// Private Use Area (Mod/Pack-Icons/Placeholder)
if (cp >= 0xE000 && cp <= 0xF8FF) return true;
// Zero-width and control characters
if (cp == 0x200B || cp == 0x200C || cp == 0x200D || cp == 0xFEFF) return true;
// BiDi-Steuerzeichen
if (cp >= 0x202A && cp <= 0x202E) return true;
if (cp >= 0x2066 && cp <= 0x2069) return true;
return cp == '§';
}
}
@@ -0,0 +1,39 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiInventoryMove;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import net.kyori.adventure.util.Ticks;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Appliance.Flags(enabled = false)
public class AntiInventoryMove extends Appliance {
private static final long errorTimeMargin = Ticks.SINGLE_TICK_DURATION_MS * 2;
private final Map<UUID, Long> invOpen = new ConcurrentHashMap<>();
public void setInvOpen(Player player, boolean open) {
if(open)
this.invOpen.put(player.getUniqueId(), System.currentTimeMillis());
else
this.invOpen.remove(player.getUniqueId());
}
public boolean hasInventoryOpen(Player player) {
if(!this.invOpen.containsKey(player.getUniqueId())) return false;
return this.invOpen.get(player.getUniqueId()) < System.currentTimeMillis() - errorTimeMargin;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(
new InventoryTrackerListener(),
new InInventoryMoveListener()
);
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiInventoryMove;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.acInform.AcInform;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerInputEvent;
class InInventoryMoveListener extends ApplianceListener<AntiInventoryMove> {
@EventHandler
public void onInput(PlayerInputEvent event) {
if(!this.getAppliance().hasInventoryOpen(event.getPlayer())) return;
Main.instance().getAppliance(AcInform.class).slowedNotifyAdmins(
"internal",
event.getPlayer().getName(),
"inInventoryMove",
-1f,
3000
);
}
}
@@ -0,0 +1,23 @@
package eu.mhsl.craftattack.spawn.common.appliances.security.antiInventoryMove;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
class InventoryTrackerListener extends ApplianceListener<AntiInventoryMove> {
@EventHandler(priority = EventPriority.MONITOR)
public void onOpen(InventoryOpenEvent event) {
if(!(event.getPlayer() instanceof Player player)) return;
if(event.isCancelled()) return;
this.getAppliance().setInvOpen(player, true);
}
@EventHandler
public void onClose(InventoryCloseEvent event) {
if(!(event.getPlayer() instanceof Player player)) return;
this.getAppliance().setInvOpen(player, false);
}
}
@@ -11,14 +11,20 @@ import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class AcInform extends Appliance {
private final Map<String, Map<String, Long>> violationSlowdowns = new ConcurrentHashMap<>();
public void processCommand(@NotNull String[] args) {
String anticheatName = null;
String playerName = null;
String checkName = null;
Float violationCount = null;
int notifyEvery = 0;
for(int i = 0; i < args.length; i++) {
if(!args[i].startsWith("--")) continue;
@@ -36,13 +42,32 @@ public class AcInform extends Appliance {
case "--playerName" -> playerName = value;
case "--check" -> checkName = value;
case "--violationCount" -> violationCount = value.isEmpty() ? null : Float.valueOf(value);
case "--notifyEvery" -> notifyEvery = Integer.parseInt(value);
}
}
if(notifyEvery == 0) {
this.notifyAdmins(anticheatName, playerName, checkName, violationCount);
} else {
this.slowedNotifyAdmins(anticheatName, playerName, checkName, violationCount, notifyEvery);
}
}
public void slowedNotifyAdmins(@Nullable String anticheatName, @Nullable String playerName, @Nullable String checkName, @Nullable Float violationCount, int notifyEvery) {
this.violationSlowdowns.putIfAbsent(playerName, new HashMap<>());
var slowdowns = this.violationSlowdowns.get(playerName);
if(slowdowns.containsKey(checkName)) {
if(slowdowns.get(checkName) > System.currentTimeMillis() - notifyEvery) return;
}
this.notifyAdmins(anticheatName, playerName, checkName, violationCount);
}
public void notifyAdmins(@Nullable String anticheatName, @Nullable String playerName, @Nullable String checkName, @Nullable Float violationCount) {
this.violationSlowdowns.putIfAbsent(playerName, new HashMap<>());
this.violationSlowdowns.get(playerName).put(checkName, System.currentTimeMillis());
ComponentBuilder<TextComponent, TextComponent.Builder> component = Component.text();
NamedTextColor textColor = NamedTextColor.GRAY;
@@ -85,28 +110,42 @@ public class AcInform extends Appliance {
Component.newline()
.append(Component.text("⊥ ", NamedTextColor.GRAY))
.append(Component.text("[", NamedTextColor.GRAY))
.append(Component.text("Report", NamedTextColor.GOLD))
.append(Component.text("\uD83D\uDCD6", NamedTextColor.GOLD))
.append(Component.text("]", NamedTextColor.GRAY))
.clickEvent(ClickEvent.suggestCommand(String.format("/report %s anticheat %s flagged %s", playerName, anticheatName, checkName)))
);
component.append(
Component.text(" [", NamedTextColor.GRAY)
.append(Component.text("Kick", NamedTextColor.GOLD))
.append(Component.text("\u23F1", NamedTextColor.GOLD))
.append(Component.text("]", NamedTextColor.GRAY))
.clickEvent(ClickEvent.suggestCommand(String.format("/kick %s", playerName)))
);
component.append(
Component.text(" [", NamedTextColor.GRAY)
.append(Component.text("Panic Ban", NamedTextColor.GOLD))
.append(Component.text("\uD83E\uDDB6", NamedTextColor.GOLD))
.append(Component.text("]", NamedTextColor.GRAY))
.clickEvent(ClickEvent.suggestCommand(String.format("/kickunsuspected %s", playerName)))
);
component.append(
Component.text(" [", NamedTextColor.GRAY)
.append(Component.text("\u2623", NamedTextColor.GOLD))
.append(Component.text("]", NamedTextColor.GRAY))
.clickEvent(ClickEvent.suggestCommand(String.format("/kickcrash %s", playerName)))
);
component.append(
Component.text(" [", NamedTextColor.GRAY)
.append(Component.text("\uD83D\uDD12", NamedTextColor.GOLD))
.append(Component.text("]", NamedTextColor.GRAY))
.clickEvent(ClickEvent.suggestCommand(String.format("/panicban %s", playerName)))
);
component.append(
Component.text(" [", NamedTextColor.GRAY)
.append(Component.text("Spectate/Teleport", NamedTextColor.GOLD))
.append(Component.text("\uD83D\uDC41", NamedTextColor.GOLD))
.append(Component.text("]", NamedTextColor.GRAY))
.clickEvent(ClickEvent.suggestCommand(String.format("/grim spectate %s", playerName)))
);
@@ -1,37 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.adminChat;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class AdminChat extends Appliance {
public void sendMessage(Player sender, String message) {
Component privatePrefix = Component
.text("[Admin] ", NamedTextColor.LIGHT_PURPLE)
.clickEvent(ClickEvent.suggestCommand(AdminChatCommand.commandName));
Bukkit.getOnlinePlayers().stream()
.filter(player -> player.hasPermission("admin"))
.forEach(player -> {
Component formattedMessage = Component.text()
.append(privatePrefix)
.append(sender.displayName())
.append(Component.text(" > ", NamedTextColor.DARK_GRAY))
.append(Component.text(message))
.build();
player.sendMessage(formattedMessage);
});
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new AdminChatCommand());
}
}
@@ -1,22 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.adminChat;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
class AdminChatCommand extends ApplianceCommand.PlayerChecked<AdminChat> {
public static final String commandName = "adminchat";
public AdminChatCommand() {
super(commandName);
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(!sender.hasPermission("admin")) return;
String message = String.join(" ", args);
this.getAppliance().sendMessage(this.getPlayer(), message);
}
}
@@ -1,44 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public class ChatMute extends Appliance {
private static final String namespace = ChatMute.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey mutedUntilKey = new NamespacedKey(namespace, "mutedUntilMillis".toLowerCase());
public void mutePlayer(Player player, int durationHours) {
PersistentDataContainer container = player.getPersistentDataContainer();
long mutedUntil = System.currentTimeMillis() + (long) durationHours * 60 * 60 * 1000;
container.set(ChatMute.mutedUntilKey, PersistentDataType.LONG, mutedUntil);
}
public @Nullable Long muteStatus(Player player) {
PersistentDataContainer container = player.getPersistentDataContainer();
if(!container.has(mutedUntilKey)) return null;
long mutedUntil = Objects.requireNonNull(container.get(mutedUntilKey, PersistentDataType.LONG));
if(mutedUntil < System.currentTimeMillis()) return null;
return mutedUntil;
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new MuteCommand());
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new ChatMuteListener());
}
}
@@ -1,25 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.core.util.text.DataSizeConverter;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.event.EventHandler;
import org.jetbrains.annotations.Nullable;
class ChatMuteListener extends ApplianceListener<ChatMute> {
@EventHandler
public void onChat(AsyncChatEvent event) {
@Nullable Long muteDuration = this.getAppliance().muteStatus(event.getPlayer());
if(muteDuration == null) return;
event.setCancelled(true);
event.getPlayer().sendMessage(Component.text(
String.format(
"Du bist für %s gestummt!",
DataSizeConverter.formatSecondsToHumanReadable((int) ((muteDuration - System.currentTimeMillis()) / 1000))
),
NamedTextColor.RED
));
}
}
@@ -1,35 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Objects;
class MuteCommand extends ApplianceCommand<ChatMute> {
public MuteCommand() {
super("mute");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length < 2) throw new Error("Syntax: mute <player> <duration in hours>");
Player player = Objects.requireNonNull(Bukkit.getPlayer(args[0]));
int durationInHours = Integer.parseInt(args[1]);
this.getAppliance().mutePlayer(player, durationInHours);
sender.sendMessage(String.format("%s wurde für %d Stunden gestummt!", player.getName(), durationInHours));
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length == 2) {
return List.of("1", "2", "4", "8", "24", "48");
}
return null;
}
}
@@ -0,0 +1,177 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.deviceFingerprinting;
import com.google.common.reflect.TypeToken;
import com.google.gson.Gson;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import net.kyori.adventure.resource.ResourcePackInfo;
import net.kyori.adventure.resource.ResourcePackRequest;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
import org.jetbrains.annotations.NotNull;
import spark.Response;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.*;
@Appliance.Flags(enabled = false)
public class DeviceFingerprinting extends Appliance {
public record PackInfo(@NotNull String url, @NotNull UUID uuid, @NotNull String hash) {
private static final String failingUrl = "http://127.0.0.1:0";
public PackInfo asFailing() {
return new PackInfo(failingUrl, this.uuid, this.hash);
}
}
public enum PackStatus {
UNCACHED,
CACHED,
INVALID;
public static PackStatus fromBukkitStatus(PlayerResourcePackStatusEvent.Status status) {
return switch(status) {
case DISCARDED -> CACHED;
case FAILED_DOWNLOAD -> UNCACHED;
default -> INVALID;
};
}
}
public enum PlayerStatus {
PREPARATION,
TESTING,
FINISHED,
NEW
}
private List<PackInfo> packs;
private final Map<Player, FingerprintData> fingerprints = new WeakHashMap<>();
private final UUID basePackId = UUID.randomUUID();
@Override
public void onEnable() {
this.packs = this.readPacksFromConfig();
}
public void startFingerprinting(Player player) {
this.fingerprints.put(player, FingerprintData.create(player));
Main.logger().info(String.format("Sending base ressource-pack with id '%s' to '%s'%n", this.basePackId, player.getName()));
this.sendPack(player, new PackInfo("http://localhost:8080/api/devicefingerprinting/base.zip", this.basePackId, "3296e8bdd30b4f7cffd11c780a1dc70da2948e71"));
}
public void onPackUpdate(Player player, UUID packId, PlayerResourcePackStatusEvent.Status status) {
if(!this.fingerprints.containsKey(player)) return;
FingerprintData playerFingerprint = this.fingerprints.get(player);
if(!playerFingerprint.isInTestingOrPreparation()) return;
if(packId.equals(this.basePackId)) {
Main.logger().info(String.format("Base pack for '%s' updated: '%s'", player.getName(), status));
if(status != PlayerResourcePackStatusEvent.Status.ACCEPTED) return;
Main.logger().info(String.format("Base pack loaded successfully, sending now all packs to '%s'...", player.getName()));
playerFingerprint.setTesting();
this.packs.forEach(pack -> this.sendPack(player, pack.asFailing()));
return;
}
PackInfo pack = this.packs.stream()
.filter(packInfo -> Objects.equals(packInfo.uuid, packId))
.findAny()
.orElse(null);
if(pack == null) return;
int packIndex = this.packs.indexOf(pack);
List<PackStatus> pendingPacks = playerFingerprint.getPendingPacks();
PackStatus newPackStatus = PackStatus.fromBukkitStatus(status);
if(newPackStatus == PackStatus.INVALID) return;
pendingPacks.set(packIndex, newPackStatus);
playerFingerprint.updateFingerprint();
if(Objects.requireNonNull(playerFingerprint.getStatus()) == PlayerStatus.NEW) {
Main.logger().info(String.format("Sending fingerprint packs to Player '%s', as it is a unseen Player!", player.getName()));
this.sendNewFingerprint(player, Objects.requireNonNull(playerFingerprint.getFingerPrint()));
}
}
private void sendNewFingerprint(Player player, long fingerprintId) {
for (int i = 0; i < this.packs.size(); i++) {
if ((fingerprintId & (1L << i)) != 0) {
PackInfo pack = this.packs.get(i);
this.sendPack(player, pack);
}
}
}
public void sendPack(Player player, PackInfo pack) {
player.sendResourcePacks(
ResourcePackRequest.resourcePackRequest()
.required(true)
.packs(ResourcePackInfo.resourcePackInfo(pack.uuid, URI.create(pack.url), pack.hash))
);
}
private List<DeviceFingerprinting.PackInfo> readPacksFromConfig() {
try (InputStreamReader reader = new InputStreamReader(Objects.requireNonNull(Main.class.getResourceAsStream("/deviceFingerprinting/packs.json")))) {
Type packListType = new TypeToken<List<DeviceFingerprinting.PackInfo>>(){}.getType();
List<DeviceFingerprinting.PackInfo> packs = new Gson().fromJson(reader, packListType);
if (packs.isEmpty()) throw new IllegalStateException("No resource packs found in packs.json.");
return packs;
} catch (IOException e) {
throw new IllegalStateException("Failed to parse packs.json.", e);
}
}
@Override
public void httpApi(HttpServer.ApiBuilder apiBuilder) {
apiBuilder.rawGet(
"base.zip",
(request, response) -> this.servePack("base.zip", response)
);
for(int i = 0; i < this.packs.size(); i++) {
int packIndex = i;
apiBuilder.rawGet(
String.format("packs/%d", i),
(request, response) -> this.servePack(String.valueOf(packIndex), response)
);
}
}
private Object servePack(String name, Response response) {
try {
String resourcePath = String.format("/deviceFingerprinting/packs/%s", name);
var inputStream = Main.class.getResourceAsStream(resourcePath);
if (inputStream == null) {
throw new IllegalStateException("Pack file not found: " + resourcePath);
}
response.header("Content-Type", "application/zip");
response.header("Content-Disposition", String.format("attachment; filename=\"pack-%s.zip\"", name));
var outputStream = response.raw().getOutputStream();
inputStream.transferTo(outputStream);
outputStream.close();
return HttpServer.nothing;
} catch (IOException e) {
throw new RuntimeException(String.format("Failed to serve pack '%s'", name), e);
}
}
public List<PackInfo> getPacks() {
return this.packs;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(
new PlayerJoinListener()
);
}
}
@@ -0,0 +1,91 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.deviceFingerprinting;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
class FingerprintData {
public final Player player;
private DeviceFingerprinting.PlayerStatus status;
private @Nullable Long fingerPrint;
private final List<DeviceFingerprinting.PackStatus> pendingPacks;
int packCount = Main.instance().getAppliance(DeviceFingerprinting.class).getPacks().size();
private FingerprintData(Player player) {
this.player = player;
this.status = DeviceFingerprinting.PlayerStatus.PREPARATION;
this.fingerPrint = null;
this.pendingPacks = Arrays.asList(new DeviceFingerprinting.PackStatus[this.packCount]);
}
public static FingerprintData create(Player player) {
return new FingerprintData(player);
}
public void setTesting() {
this.status = DeviceFingerprinting.PlayerStatus.TESTING;
}
public void updateFingerprint() {
long fingerPrint = 0;
for (int i = 0; i < this.pendingPacks.size(); i++) {
var status = this.pendingPacks.get(i);
if(status == null) return;
switch (status) {
case CACHED:
fingerPrint |= 1L << i;
break;
case UNCACHED:
break;
default:
return;
}
}
if(fingerPrint == 0) {
this.status = DeviceFingerprinting.PlayerStatus.NEW;
this.fingerPrint = this.createNewFingerprint();
Main.logger().info(String.format("Player %s's was marked as a new Player!", this.player.getName()));
} else {
this.status = DeviceFingerprinting.PlayerStatus.FINISHED;
this.fingerPrint = fingerPrint;
}
Main.logger().info(String.format("Player %s's fingerprint is '%s'", this.player.getName(), fingerPrint));
}
private long createNewFingerprint() {
long id = 0;
Random random = new Random();
for (int i = 0; i < this.packCount / 2; i++) {
while (true) {
int bitIndex = random.nextInt(this.packCount);
if ((id & (1L << bitIndex)) == 0) {
id |= 1L << bitIndex;
break;
}
}
}
return id;
}
public List<DeviceFingerprinting.PackStatus> getPendingPacks() {
return this.pendingPacks;
}
public DeviceFingerprinting.PlayerStatus getStatus() {
return this.status;
}
public @Nullable Long getFingerPrint() {
return this.fingerPrint;
}
public boolean isInTestingOrPreparation() {
return this.status == DeviceFingerprinting.PlayerStatus.TESTING || this.status == DeviceFingerprinting.PlayerStatus.PREPARATION;
}
}
@@ -0,0 +1,22 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.deviceFingerprinting;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
class PlayerJoinListener extends ApplianceListener<DeviceFingerprinting> {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
this.getAppliance().startFingerprinting(event.getPlayer());
}
@EventHandler
public void onResourcePackEvent(PlayerResourcePackStatusEvent event) {
this.getAppliance().onPackUpdate(
event.getPlayer(),
event.getID(),
event.getStatus()
);
}
}
@@ -0,0 +1,63 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.Effect;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.MessageGroup;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.SoundSettings;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
/** Shared message/sound delivery. Group names and color are supplied by the owning appliance. */
public final class DimensionOpeningEffects {
private final String effectsGroup;
private final String denialGroup;
private final TextColor color;
private @Nullable DimensionScheduler scheduler;
public DimensionOpeningEffects(String effectsGroup, String denialGroup, TextColor color) {
this.effectsGroup = effectsGroup;
this.denialGroup = denialGroup;
this.color = color;
}
public void configure(DimensionScheduler scheduler) {
this.scheduler = scheduler;
}
public List<Effect> getConfiguredEffects() {
return this.scheduler == null ? List.of() : this.scheduler.getEffects(this.effectsGroup);
}
public void denyActivation(Player player) {
if(this.scheduler == null) return;
MessageGroup group = this.scheduler.getMessageGroup(this.denialGroup);
List<String> messages = group.texts();
if(messages.isEmpty()) return;
String message = messages.get(ThreadLocalRandom.current().nextInt(messages.size()));
send(player, DimensionScheduleText.message(message, this.color), group.sound().map(SoundSettings::asSound));
}
public void playOpening() {
for(Effect effect : this.getConfiguredEffects()) {
if(effect.minutesBeforeOpening() == 0) this.play(effect);
}
}
public void play(Effect effect) {
Component message = DimensionScheduleText.message(effect.message(), this.color);
Optional<Sound> sound = effect.sound().map(SoundSettings::asSound);
Bukkit.getOnlinePlayers().forEach(player -> send(player, message, sound));
}
private static void send(Player player, Component message, Optional<Sound> sound) {
player.sendMessage(message);
sound.ifPresent(value -> player.playSound(value, Sound.Emitter.self()));
}
}
@@ -0,0 +1,49 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.InvalidConfigurationException;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
class DimensionScheduleCommand extends ApplianceCommand<DimensionScheduler> {
public DimensionScheduleCommand() {
super("dimensionSchedule");
}
@Override
public @NotNull String permission() {
return "spawn.command.dimensionschedule";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp()) throw new Error("Dieser Befehl ist nur für die Konsole und OPs verfügbar.");
if(args.length != 1 || !args[0].equalsIgnoreCase("reload")) {
throw new Error("Verwendung: /dimensionSchedule reload");
}
try {
this.getAppliance().reload();
} catch(IOException | InvalidConfigurationException | IllegalArgumentException e) {
throw new Error("dimension-schedule.yml konnte nicht neu eingelesen werden: " + e.getMessage());
}
Instant now = Instant.now();
sender.sendMessage(DimensionScheduleText.message("Die Öffnungszeiten, Texte und Effekte wurden neu eingelesen."));
sender.sendMessage(DimensionScheduleText.message("End-Termin: "
+ DimensionScheduleText.appointment(this.getAppliance().getTime("end-open-at"), now)));
sender.sendMessage(DimensionScheduleText.message("Nether-Termin: "
+ DimensionScheduleText.appointment(this.getAppliance().getTime("nether-open-at"), now),
DimensionScheduleText.NETHER_RED));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp() || args.length != 1) return List.of();
return this.tabCompleteReducer(List.of("reload"), args);
}
}
@@ -0,0 +1,53 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Optional;
/** Shared appearance and German time formatting for dimension messages and commands. */
public final class DimensionScheduleText {
public static final TextColor END_GREEN = TextColor.color(0x80A71A);
public static final TextColor NETHER_RED = TextColor.color(0xB83D32);
private static final DateTimeFormatter DATE = DateTimeFormatter
.ofPattern("dd.MM.yyyy 'um' HH:mm:ss 'Uhr' z", Locale.GERMAN)
.withZone(ZoneId.of("Europe/Berlin"));
private DimensionScheduleText() {
}
public static Component message(String text) {
return message(text, END_GREEN);
}
public static Component message(String text, TextColor color) {
return Component.text(text, color);
}
/** Round up partial minutes so a pending opening never reads as zero minutes away. */
public static String remaining(Instant openingAt, Instant now) {
Duration duration = Duration.between(now, openingAt);
if(duration.isNegative() || duration.isZero()) return "0 Tagen, 0 Stunden und 0 Minuten";
long minutes = duration.toMinutes();
if(!duration.minusMinutes(minutes).isZero()) minutes++;
long days = minutes / (24 * 60);
long hours = minutes / 60 % 24;
long minutePart = minutes % 60;
return days + (days == 1 ? " Tag, " : " Tagen, ")
+ hours + (hours == 1 ? " Stunde und " : " Stunden und ")
+ minutePart + (minutePart == 1 ? " Minute" : " Minuten");
}
public static String appointment(Optional<Instant> openingAt, Instant now) {
if(openingAt.isEmpty()) return "nicht festgelegt";
Instant time = openingAt.get();
return DATE.format(time) + (time.isAfter(now)
? " (in " + remaining(time, now) + ")"
: " (Termin bereits erreicht)");
}
}
@@ -0,0 +1,231 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.key.InvalidKeyException;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.sound.Sound;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
/** Reads named times, message groups and effect definitions; execution belongs to their consumers. */
public class DimensionScheduler extends Appliance {
public record SoundSettings(String name, float volume, float pitch) {
public Sound asSound() {
return Sound.sound(Key.key(this.name), Sound.Source.MASTER, this.volume, this.pitch);
}
}
public record MessageGroup(List<String> texts, Optional<SoundSettings> sound) {
}
public record Effect(String name, int minutesBeforeOpening, String message, Optional<SoundSettings> sound) {
public Duration leadTime() {
return Duration.ofMinutes(this.minutesBeforeOpening);
}
}
private static final String FILE_NAME = "dimension-schedule.yml";
private static final MessageGroup EMPTY_MESSAGE_GROUP = new MessageGroup(List.of(), Optional.empty());
private final File scheduleFile = new File(Main.instance().getDataFolder(), FILE_NAME);
private Map<String, Instant> times = Map.of();
private Map<String, MessageGroup> messages = Map.of();
private Map<String, List<Effect>> effects = Map.of();
private Instant loadedAt = Instant.now();
@Override
public void onEnable() {
try {
if(!this.scheduleFile.exists()) Main.instance().saveResource(FILE_NAME, false);
this.reload();
} catch(IOException | InvalidConfigurationException | IllegalArgumentException e) {
Main.logger().log(Level.WARNING, "Could not load " + FILE_NAME + "; keeping previous configuration", e);
}
}
/** Validates the complete configuration before replacing it. Never creates or writes the file. */
public void reload() throws IOException, InvalidConfigurationException {
YamlConfiguration configuration = new YamlConfiguration();
configuration.load(this.scheduleFile);
YamlConfiguration defaults = this.readDefaults();
Map<String, Instant> loadedTimes = new HashMap<>();
ConfigurationSection timeSection = section(configuration, "times");
boolean legacyTimes = timeSection == null;
if(legacyTimes) timeSection = configuration;
for(Map.Entry<String, Object> entry : timeSection.getValues(false).entrySet()) {
if(legacyTimes && List.of("times", "messages", "effects").contains(entry.getKey())) continue;
readTime(entry.getKey(), entry.getValue()).ifPresent(time -> loadedTimes.put(entry.getKey(), time));
}
ConfigurationSection messageSection = section(configuration, "messages");
ConfigurationSection effectSection = section(configuration, "effects");
Map<String, MessageGroup> loadedMessages = readMessages(messageSection == null ? section(defaults, "messages") : messageSection);
Map<String, List<Effect>> loadedEffects = readEffects(effectSection == null ? section(defaults, "effects") : effectSection);
this.times = Map.copyOf(loadedTimes);
this.messages = loadedMessages;
this.effects = loadedEffects;
this.loadedAt = Instant.now();
}
private YamlConfiguration readDefaults() throws IOException, InvalidConfigurationException {
YamlConfiguration defaults = new YamlConfiguration();
try(InputStream resource = Main.instance().getResource(FILE_NAME)) {
if(resource == null) throw new IOException("Missing bundled " + FILE_NAME);
defaults.load(new InputStreamReader(resource, StandardCharsets.UTF_8));
}
return defaults;
}
private static ConfigurationSection section(ConfigurationSection parent, String key) {
Object value = parent.get(key);
if(value == null) return null;
if(value instanceof ConfigurationSection section) return section;
throw new IllegalArgumentException(parent.getCurrentPath() + "." + key + " muss ein YAML-Abschnitt sein.");
}
private static Map<String, MessageGroup> readMessages(ConfigurationSection section) {
if(section == null) return Map.of();
Map<String, MessageGroup> messages = new HashMap<>();
for(Map.Entry<String, Object> entry : section.getValues(false).entrySet()) {
Object configured = entry.getValue();
Optional<SoundSettings> sound = Optional.empty();
if(configured instanceof ConfigurationSection group) {
configured = group.get("texts");
sound = readSound(group);
}
if(!(configured instanceof List<?> values)) {
throw new IllegalArgumentException("messages." + entry.getKey() + " muss eine Textliste oder ein Abschnitt mit texts sein.");
}
List<String> texts = new ArrayList<>();
for(Object value : values) {
if(!(value instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException("messages." + entry.getKey() + " enthält einen ungültigen Text.");
}
texts.add(text);
}
messages.put(entry.getKey(), new MessageGroup(List.copyOf(texts), sound));
}
return Map.copyOf(messages);
}
private static Map<String, List<Effect>> readEffects(ConfigurationSection section) {
if(section == null) return Map.of();
Map<String, List<Effect>> effects = new HashMap<>();
for(String groupName : section.getKeys(false)) {
ConfigurationSection group = section(section, groupName);
if(group == null) throw new IllegalArgumentException("effects." + groupName + " muss ein YAML-Abschnitt sein.");
List<Effect> definitions = new ArrayList<>();
for(String name : group.getKeys(false)) {
ConfigurationSection effect = section(group, name);
if(effect == null) throw new IllegalArgumentException("effects." + groupName + "." + name + " muss ein YAML-Abschnitt sein.");
Object minutes = effect.get("minutes-before-opening");
Object message = effect.get("message");
if(!(minutes instanceof Integer count) || count < 0) {
throw new IllegalArgumentException(effect.getCurrentPath() + ".minutes-before-opening muss eine nicht negative ganze Zahl sein.");
}
if(!(message instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException(effect.getCurrentPath() + ".message muss ein nicht leerer Text sein.");
}
definitions.add(new Effect(name, count, text, readSound(effect)));
}
effects.put(groupName, List.copyOf(definitions));
}
return Map.copyOf(effects);
}
private static Optional<SoundSettings> readSound(ConfigurationSection section) {
Object configured = section.get("sound");
if(configured == null) return Optional.empty();
if(!(configured instanceof String name)) {
throw new IllegalArgumentException(section.getCurrentPath() + ".sound muss ein Sound-Name sein.");
}
if(name.isBlank()) return Optional.empty();
String key;
try {
key = Key.key(name.trim()).asString();
} catch(InvalidKeyException e) {
throw new IllegalArgumentException("Ungültiger Sound-Name bei " + section.getCurrentPath() + ".sound: " + name, e);
}
float volume = readSoundNumber(section, "volume", true);
float pitch = readSoundNumber(section, "pitch", false);
return Optional.of(new SoundSettings(key, volume, pitch));
}
private static float readSoundNumber(ConfigurationSection section, String key, boolean allowZero) {
Object configured = section.get(key);
if(configured == null) return 1.0f;
if(!(configured instanceof Number number)) {
throw new IllegalArgumentException(section.getCurrentPath() + "." + key + " muss eine Zahl sein.");
}
float value = number.floatValue();
if(!Float.isFinite(value) || (allowZero ? value < 0 : value <= 0)) {
throw new IllegalArgumentException(section.getCurrentPath() + "." + key
+ (allowZero ? " muss endlich und mindestens 0 sein." : " muss endlich und größer als 0 sein."));
}
return value;
}
private static Optional<Instant> readTime(String key, Object configured) {
if(configured == null) return Optional.empty();
if(!(configured instanceof String value)) {
throw new IllegalArgumentException(key + " muss null oder ein Datum in Anführungszeichen mit UTC-Offset sein.");
}
if(value.isBlank()) return Optional.empty();
try {
return Optional.of(OffsetDateTime.parse(value.trim()).toInstant());
} catch(DateTimeParseException e) {
throw new IllegalArgumentException("Ungültiger Zeitpunkt für " + key + ". Beispiel: \"2026-11-15T18:00:00+01:00\"", e);
}
}
public Optional<Instant> getTime(String name) {
return Optional.ofNullable(this.times.get(name));
}
/** Immutable snapshot of all configured, non-empty times. */
public Map<String, Instant> getTimes() {
return this.times;
}
public List<String> getMessages(String name) {
return this.getMessageGroup(name).texts();
}
public MessageGroup getMessageGroup(String name) {
return this.messages.getOrDefault(name, EMPTY_MESSAGE_GROUP);
}
public List<Effect> getEffects(String name) {
return this.effects.getOrDefault(name, List.of());
}
/** Wall-clock time of the last successful load, before consumers check the new times. */
public Instant getLoadedAt() {
return this.loadedAt;
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new DimensionScheduleCommand());
}
}
@@ -0,0 +1,64 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.Effect;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/** Tracks one dimension's announcement cycle in memory. Owns no timer or file operations. */
public final class OpeningAnnouncements {
private final DimensionOpeningEffects effects;
private final Set<String> handled = new HashSet<>();
private Optional<Instant> openingAt = Optional.empty();
private List<Effect> configuredEffects = List.of();
private boolean initialized;
public OpeningAnnouncements(DimensionOpeningEffects effects) {
this.effects = effects;
}
/** Reconfigure only when the opening time or its effects change; skip stages before cutoff. */
public void prepare(Optional<Instant> openingAt, Instant cutoff) {
List<Effect> configuredEffects = this.effects.getConfiguredEffects();
if(this.initialized && openingAt.equals(this.openingAt) && configuredEffects.equals(this.configuredEffects)) return;
this.openingAt = openingAt;
this.configuredEffects = configuredEffects;
this.initialized = true;
this.handled.clear();
if(openingAt.isEmpty()) return;
for(Effect effect : configuredEffects) {
if(effect.minutesBeforeOpening() > 0 && openingAt.get().minus(effect.leadTime()).isBefore(cutoff)) {
this.handled.add(effect.name());
}
}
}
/** Consume all crossed stages, but emit only the latest due time while access is closed. */
public void announceDue(Instant now, boolean closed) {
if(this.openingAt.isEmpty()) return;
if(!now.isBefore(this.openingAt.get())) {
// Zero-minute effects belong to the actual opening transition, never to polling.
for(Effect effect : this.configuredEffects) this.handled.add(effect.name());
return;
}
List<Effect> latestDue = new ArrayList<>();
Instant latestDueAt = null;
for(Effect effect : this.configuredEffects) {
if(effect.minutesBeforeOpening() == 0 || this.handled.contains(effect.name())) continue;
Instant dueAt = this.openingAt.get().minus(effect.leadTime());
if(now.isBefore(dueAt)) continue;
this.handled.add(effect.name());
if(latestDueAt == null || dueAt.isAfter(latestDueAt)) {
latestDue.clear();
latestDueAt = dueAt;
}
if(dueAt.equals(latestDueAt)) latestDue.add(effect);
}
if(closed) latestDue.forEach(this.effects::play);
}
}
@@ -0,0 +1,254 @@
# Dimension schedule
## Zuständigkeiten und Lebenszyklus
| Klasse | Aufgabe und Erstellung |
| --- | --- |
| `DimensionScheduler` | Automatisch geladene Appliance. Erstellt die fehlende `plugins/spawn/dimension-schedule.yml` aus der Vorlage und liest benannte Zeitpunkte, Textgruppen und Effektdefinitionen. Kennt keine End- oder Nether-Aktionen. |
| `DimensionScheduleCommand` | Wird über die Appliance registriert. `/dimensionSchedule reload` liest die Datei für Konsole und OPs neu ein und zeigt nur dem Ausführenden den End- und Nether-Termin. Bei Fehlern bleiben alle bisherigen Werte erhalten. |
| `DimensionScheduleText` | End-Grün (`#80A71A`), Nether-Rot (`#B83D32`) und deutsche Formatierung von Restzeiten und Terminen. Absolute Zeiten werden in `Europe/Berlin` mit Zeitzone ausgegeben. |
| `EndPrevent` | Besitzt die End-Effektausgabe. Erstellt in `onEnable()` eine `EndOpeningSchedule`, wenn die Scheduler-Appliance vorhanden ist, und stoppt sie in `onDisable()`. Verwaltet End-Zustand und Weltgrenze. |
| `NetherAccess` | Neue Appliance in `common`. Besitzt die Nether-Effektausgabe, erstellt und stoppt `NetherOpeningSchedule` und verwaltet den Nether-Zustand. Die bisherigen Varo-Klassen bleiben unverändert und werden hierfür nicht eingebunden. |
| `NetherPortalIgniter` / `NetherIgniterCommand` | Erstellen das eindeutig markierte Portalfeuerzeug und geben es über `/netherIgniter` an OPs im Kreativmodus aus. |
| `MarkedNetherPortals` | Wird von `NetherAccess` erstellt. Verwaltet Markierungen, Chunk-Index und die vorübergehende Aktivierungswarteschlange auf dem Hauptthread. |
| `MarkedNetherPortal` / `PortalMarkStore` | Unveränderliche Portalgeometrie ohne gespeicherte Welt-/Block-/Spielerobjekte und serialisierte Hintergrundspeicherung in einer eigenen YAML. |
| `EndOpeningSchedule` / `NetherOpeningSchedule` | Je ein Bukkit-Task. Starten im nächsten Tick nach der Initialisierung aller Appliances und prüfen dann alle 20 Ticks Termine und Ankündigungen. Die Zustandslogik bleibt bei der jeweiligen Dimension; End behält seine Outer-End-Logik. |
| `DimensionOpeningEffects` | Gemeinsame Nachrichten- und Soundausgabe mit übergebenen Konfigurationsgruppen und Farbe. Wählt Ablehnungstexte zufällig aus und gibt Effekte über `play(effect)` aus. Den Nachrichten-Cooldown verwaltet der jeweilige Portal-Listener. |
| `OpeningAnnouncements` | Gemeinsame Verfolgung fälliger und übersprungener Vorankündigungen. Verwendet die dynamischen Effekte, ohne Zustände zu ändern oder eigene Tasks anzulegen. |
| `EndCommand` / `NetherCommand` | Öffentliche Informationsbefehle `/end` und `/nether`. Zeigen die Restzeit oder einen passenden Hinweis zum tatsächlichen Zustand. |
## YAML und dynamische Effekte
```yaml
times:
end-open-at: "2026-11-15T18:00:00+01:00"
outer-end-open-at: "2026-11-16T18:00:00+01:00"
nether-open-at: null
messages:
end-activation-denied:
texts:
- "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..."
sound: "entity.warden.roar"
pitch: 0.2
volume: 1.0
effects:
end-opening:
five-minutes-before:
minutes-before-opening: 5
message: "Etwas regt sich in der Leere ..."
sound: "ambient.cave"
pitch: 0.5
volume: 1.0
opened:
minutes-before-opening: 0
message: "Der Weg ins End ist nun offen!"
sound: "block.end_portal.spawn"
pitch: 1.0
volume: 1.0
```
`times` kann beliebige benannte Zeitpunkte enthalten. `messages.end-activation-denied.texts`
und `messages.nether-activation-denied.texts` enthalten die Texte für zufällige private
Chat-Nachrichten. Beide Dimensionen verwenden dasselbe oben gezeigte Format.
Die bisherige direkte Textliste wird ebenfalls weiterhin gelesen.
`sound`, `pitch` und `volume` stehen direkt bei den zugehörigen Texten beziehungsweise Effekten.
Diese Sound-Angaben werden beim Reload mit eingelesen und direkt mit der jeweiligen Nachricht
einmal beim Empfänger abgespielt. Der Ablehnungs-Sound ist nur für den auslösenden Spieler hörbar;
Ankündigungs- und Öffnungssounds erhalten alle Empfänger der Chat-Nachricht jeweils an ihrer
eigenen Position. Es gibt keine zusätzlichen Tasks oder Schleifen für die Sound-Wiedergabe.
Ohne `sound` oder mit leerem Sound-Namen bleibt es bei der Textausgabe. `volume` und `pitch`
haben den Standardwert `1.0`; Lautstärke muss mindestens `0`, Pitch größer als `0` sein.
Ungültige Sound-Schlüssel oder nicht endliche Zahlen lassen den Reload wie andere ungültige
Konfigurationswerte scheitern und behalten die bisherige Konfiguration.
Unter `effects.end-opening` und `effects.nether-opening` können frei benannte Effekte ergänzt
oder entfernt werden.
`minutes-before-opening` ist eine nicht negative ganze Zahl: Ein positiver Wert gibt den
Abstand zur jeweiligen Öffnung in Minuten an, `0` kennzeichnet einen Effekt beim tatsächlichen Öffnen.
Texte und Vorlaufzeiten stehen ausschließlich in der YAML; eine feste Stufen-Enum gibt es nicht.
Die mitgelieferte Vorlage enthält die bisherigen End-Originaltexte mit 240, 60, 10, 1 und 0 Minuten.
Für den Nether enthält sie fünf Ablehnungstexte sowie Effekte mit 120, 30, 1 und 0 Minuten.
Die Nether-Texte bleiben wortgetreu. Die Vorlage verwendet `entity.wither.ambient` bei Ablehnung
(Lautstärke `1.0`, Pitch `0.3`), `block.portal.ambient` bei 120 Minuten (Lautstärke `2.0`, Pitch `0.3`),
`entity.warden.roar` bei 30 Minuten (Lautstärke `2.0`, Pitch `0.2`), `entity.wither.spawn` bei einer Minute
(Lautstärke `2.0`, Pitch `0.5`) und `block.end_portal.spawn` bei der Öffnung
(Lautstärke `1.0`, Pitch `1.0`). Sounds bleiben optional konfigurierbar.
Die ursprünglichen flachen Zeitangaben (`end-open-at` direkt im YAML-Hauptabschnitt) bleiben
lesbar, solange kein `times`-Abschnitt vorhanden ist. Fehlt der gesamte `messages`- oder
`effects`-Abschnitt, wird nur für diesen Abschnitt die mitgelieferte Vorlage im Arbeitsspeicher
verwendet. Bestehende Serverdateien werden nicht überschrieben oder automatisch ergänzt.
Zur Inbetriebnahme die Termine unter `times` setzen und die gewünschten Abschnitte aus der
Vorlage in die Serverdatei übernehmen. Sind eigene `messages`- beziehungsweise `effects`-Abschnitte
bereits vorhanden, müssen fehlende Nether-Gruppen (`nether-activation-denied` und `nether-opening`)
aus der Vorlage übernommen werden. Anschließend `/dimensionSchedule reload` ausführen.
Explizite leere Abschnitte (`messages: {}`, `effects: {}`) verwenden keine Standardgruppen;
einzelne Gruppen lassen sich durch `end-activation-denied: []` beziehungsweise `end-opening: {}` leeren.
## Öffnungszeiten
Vor `end-open-at` ist das End `CLOSED`, danach bis `outer-end-open-at` ist nur die
Hauptinsel verfügbar (`NO_OUTER`), anschließend ist es vollständig `OPEN`.
Ohne Outer-End-Termin öffnet `end-open-at` das gesamte End. Mit ausschließlich einem
Outer-End-Termin ist die Hauptinsel sofort verfügbar und das Outer End öffnet zum Termin.
Ein zukünftiger End-Termin hat Vorrang vor einem bereits vergangenen Outer-End-Termin.
Sind beide Einträge leer oder `null`, erfolgt keine automatische Zustandsänderung.
Weitere Namen können in der YAML ergänzt werden; nur die End-Klasse interpretiert diese zwei Namen.
`NetherOpeningSchedule` liest `times.nether-open-at`: Vor dem Termin ist der Nether `CLOSED`,
danach `OPEN`. `NetherAccess` speichert den Zustand unter `netherAccess.state` in `config.yml`.
Ohne gespeicherten Zustand startet der Nether geschlossen. Ohne Öffnungstermin bleibt der
gespeicherte Zustand maßgeblich; es erfolgt keine automatische Änderung.
## Gemeinsamer Zustandswechsel und Commands
`/end` und `/nether` sind für alle Spieler und die Konsole verfügbar. Bei geschlossenem End mit zukünftigem
Termin lautet die Antwort beispielsweise: „Das End öffnet in 2 Tagen, 3 Stunden und 4 Minuten.“
Angebrochene Minuten werden aufgerundet. Ist das End schon geöffnet, kein Termin gesetzt oder
ein vergangener Termin bei weiterhin geschlossenem End vorhanden, wird dies entsprechend gemeldet.
`/nether` verwendet dieselbe Restzeitformatierung und entsprechende Hinweise auf einen bereits
geöffneten Nether, einen fehlenden Termin oder einen vergangenen Termin. Informationsbefehle
ändern keinen Zustand und schreiben keine Datei.
`/endPrevent preventEnd`, `/endPrevent allowEnd` und `/endPrevent onlyInnerEnd` bleiben bestehen.
`/endPrevent` ohne Argument zeigt den Zustand. `/netherPrevent preventNether` sperrt den Nether,
`/netherPrevent allowNether` öffnet ihn und `/netherPrevent` ohne Argument zeigt seinen Zustand.
Beide Verwaltungsbefehle sowie `/dimensionSchedule` und `/netherIgniter` besitzen registrierte
Command-Berechtigungen mit `default: op`. Für normale Spieler erscheinen auch die Befehlsnamen
und ihre `spawn:`-Varianten nicht in der Autovervollständigung. Die OP-Prüfungen bei der
Ausführung bleiben zusätzlich erhalten; `/netherIgniter` erfordert außerdem einen Spieler im Kreativmodus.
Nach Installation dieser Änderung den Server neu starten; `/dimensionSchedule reload` lädt nur die Termin-YAML.
Status und Bestätigungen sind auf Deutsch in End-Grün beziehungsweise Nether-Rot formuliert;
ungültige Argumente zeigen die Verwendung statt einer Erfolgsmeldung.
Commands und laufende zeitgesteuerte Übergänge verwenden `EndPrevent.setEndState()`.
Der gemeinsame Zustandswechsel aktualisiert Zustand und Weltgrenze, speichert Änderungen in
`config.yml` und führt beim Wechsel von `CLOSED` zu `NO_OUTER` oder `OPEN` alle Effekte mit
`minutes-before-opening: 0` aus.
Bereits offene Zustände erzeugen bei erneutem Öffnen keine weitere Meldung. Die spätere
Freigabe des Outer Ends (`NO_OUTER` zu `OPEN`) wiederholt die End-Öffnungsmeldung ebenfalls nicht.
Für den Nether verwenden Command und Zeitplan entsprechend denselben zentralen Zustandswechsel
in `NetherAccess`. Jede tatsächliche Zustandsänderung wird gespeichert; nur der Wechsel von
`CLOSED` zu `OPEN` führt alle Nether-Öffnungseffekte aus. Wiederholtes Öffnen erzeugt keine doppelten Effekte.
Beim Start und bei geänderten Öffnungszeiten verwendet der Zeitplan `restoreEndState()`.
Der Nether stellt seinen Zustand auf dieselbe Weise still wieder her. Diese Übergänge
führen denselben Zustandswechsel mit Speicherung aus, stellen den aktuellen
Zustand aber ohne nachträgliche Öffnungseffekte her. Entfernte Termine heben den zuletzt
gesetzten Zustand nicht auf; die Steuerung bleibt dann vollständig manuell.
Manuelle Commands werden nicht bei jeder Prüfung überschrieben: Erst beim nächsten
zeitgesteuerten Zustandswechsel oder bei geänderten Terminen der jeweiligen Dimension greift der
Zeitplan erneut ein. Ein Reload mit unveränderten Terminen setzt einen manuellen Zustand nicht zurück.
Die Reload-Bestätigung zeigt End- und Nether-Termin als Datum mit Uhrzeit und Berliner Zeitzone,
bei zukünftigen Terminen zusätzlich die Restzeit. Fehlende und vergangene Termine sind gekennzeichnet.
Diese Zusammenfassung wird ausschließlich an den ausführenden CommandSender gesendet.
Die End-Zeile ist grün, die Nether-Zeile rot.
## Ankündigungen und Portalversuche
Vorankündigungen erscheinen einmalig für alle Online-Spieler im Chat, solange die jeweilige
Dimension geschlossen ist. Bereits vergangene Stufen werden beim Start oder bei einem neuen Termin
übersprungen. Unveränderte Reloads, neue Outer-End-Termine und Änderungen anderer YAML-Einträge
setzen die bereits behandelten Stufen nicht zurück. Bei Verzögerungen wird höchstens die
zuletzt fällige Vorankündigungszeit berücksichtigt. Mehrere Effekte mit genau derselben
Vorlaufzeit werden gemeinsam ausgeführt. Ist die Öffnung bereits fällig, entfallen alte Teaser.
Während einer manuellen Freigabe fällige Teaser werden still als behandelt markiert.
Geänderte Definitionen unter `effects.end-opening` und `effects.nether-opening` werden nach einem erfolgreichen Reload
ebenfalls übernommen. Bereits vor diesem Reload vergangene Ankündigungen werden übersprungen;
zukünftige Effekte laufen mit den neuen Texten und Minutenwerten. Reine Textänderungen in
`messages` setzen die Ankündigungen nicht zurück. Ungültige Zeiten, Minutenwerte oder Texte
lassen den gesamten Reload scheitern und behalten die bisherige Konfiguration.
Für einen geänderten Termin gilt der erfolgreiche Ladezeitpunkt als Grenze für verpasste
Stufen. Wird eine Ankündigung erst zwischen dem Reload und der nächsten Sekundenprüfung
fällig, wird sie regulär ausgeführt. Dasselbe gilt für eine in diesem Zeitraum fällige Öffnung.
Beim erneuten Aktivieren einer Appliance gilt frühestens deren Startzeitpunkt als Grenze.
Ein Rechtsklick mit Enderauge auf einen leeren, gesperrten Endportalrahmen wird für beide Hände
abgebrochen. Aktivierungs- und Betretversuche gesperrter Endportale verwenden dieselbe Sperrprüfung
und einen gemeinsamen Nachrichten-Cooldown: Nur der auslösende Spieler erhält einen zufälligen
konfigurierten Text im Chat, höchstens einmal alle drei echten Sekunden. Versuche während des
Cooldowns werden weiterhin abgebrochen; sie verlängern den Nachrichten-Cooldown nicht.
Die Prüfung nutzt `System.nanoTime()`, benötigt keinen zusätzlichen Task und entfernt
Spieler-Einträge beim Verlassen. Die Gateway-Meldung für das gesperrte Outer End bleibt getrennt davon.
End-Flavourtexte und zeitgesteuerte End-Effekte verwenden End-Grün (`#80A71A`).
Solange der Nether geschlossen ist, verhindert `NetherAccess` neue Netherportale mit Ursache
`FIRE` außerhalb des Nethers sowie automatisch erzeugte Zielportale mit Ursache `NETHER_PAIR`
im Nether. `END_PLATFORM` bleibt unberührt. Der Eintritt in den Nether durch bestehende Portale
wird für Spieler, Mobs und Gegenstände über `PlayerPortalEvent` und `EntityPortalEvent` gesperrt.
Die Rückreise aus dem Nether und dafür nötige Rückportale bleiben erlaubt; das Zünden eines
Rückportals innerhalb des Nethers wird ebenfalls nicht verhindert.
Bei Spieleraktionen erhält ausschließlich der auslösende Spieler einen zufälligen Text aus
`messages.nether-activation-denied` und gegebenenfalls den konfigurierten Sound. Bei
`PortalCreateEvent` wird dafür nur ein von `getEntity()` gelieferter Spieler verwendet;
automatische Ereignisse ohne Spieler werden still blockiert. Es wird kein Spieler über die
Entfernung zum Portal ermittelt. Nachrichten und Sounds teilen einen Cooldown von drei echten
Sekunden pro Spieler. Die Sperre gilt auch während des Cooldowns; Spieler-Einträge werden beim
Verlassen entfernt. Alle Nether-Ausgaben verwenden Nether-Rot (`#B83D32`).
## Portalfeuerzeug und vorgemerkte Netherportale
OPs im Kreativmodus erhalten mit `/netherIgniter` ein glänzendes Feuerzeug namens
„Portalfeuerzeug“ mit erklärender Lore. Bei vollem Inventar schlägt der Befehl fehl;
es wird kein Item fallen gelassen. Ein PersistentDataContainer-Marker identifiziert das
Item unabhängig vom Anzeigenamen. Das Umbenennen eines normalen Feuerzeugs genügt nicht.
Auch ein weitergegebenes Portalfeuerzeug lässt sich ausschließlich im Kreativmodus benutzen.
Bei offenem Nether und für Rückportale innerhalb des Nethers bleibt die gewöhnliche
Vanilla-Benutzung erhalten. Ist der Nether geschlossen, kann das Spezialitem gültige
Zugangsportale außerhalb des Nethers vormerken: Der Listener erfasst die tatsächlich
verwendete Hand, Position und den Server-Tick. Erst das zugehörige native
`PortalCreateEvent(FIRE)` liefert den geprüften Rahmen. Die Portalbildung wird abgebrochen
und das zurückgebliebene Zündfeuer im nächsten Tick gezielt entfernt. Statt einer
Ablehnungsnachricht erhält nur der Spieler eine Bestätigung in Nether-Rot, sobald die
Markierung erfolgreich gespeichert wurde. Mehrfaches Vormerken erzeugt keinen zweiten Eintrag.
`plugins/spawn/marked-nether-portals.yml` enthält Welt- und Ersteller-UUID, Innenraum,
Achse, Rahmen und Zündposition. Die Datei entsteht beim ersten Speichern einer Markierung.
Ein eigener serieller Hintergrund-Worker schreibt Änderungen zuerst in eine temporäre
Datei und ersetzt anschließend die Speicherdatei. Fehlgeschlagene Schreibvorgänge werden
nicht als erfolgreiche Markierung bestätigt. Eine beschädigte Speicherdatei wird weder
überschrieben noch teilweise geladen; neue Markierungen bleiben bis zur Behebung und
erneuten Aktivierung der Appliance gesperrt. Die Termin- und Effekt-YAML bleibt unverändert.
Der gemeinsame Zustandswechsel von `NetherAccess` startet vorgemerkte Portale bei Öffnung
per Command, Zeitplan und stiller Wiederherstellung. Beim Serverstart werden gespeicherte
Markierungen erst nach der anfänglichen Terminprüfung berücksichtigt. Dadurch funktioniert
auch ein bereits gespeichert offener Nether, ohne vergangene Öffnungsnachrichten abzuspielen.
Es werden keine Chunks geladen oder erzeugt. Ein Chunk-Index ordnet reguläre
`ChunkLoadEvent`s den betroffenen Portalen zu. Erst wenn alle Chunks des Rahmens und des
benötigten Vanilla-Suchbereichs geladen sind, wird das Portal zur Aktivierung eingereiht.
Ungeladene Portale bleiben gespeichert und öffnen entsprechend später, wenn ihre Umgebung
regulär geladen wird. Die Warteschlange ist dedupliziert und bearbeitet höchstens ein Portal
pro Tick. Ihr Task läuft ausschließlich bei gefüllter Warteschlange; Schließen des Nethers
oder Deaktivieren der Appliance stoppt die Verarbeitung.
Vor der Zündung werden Zugangszustand, geladene Chunks, Obsidianrahmen und freier Innenraum
erneut geprüft. Beschädigte oder verbaute Portale werden mit protokollierter Ursache aus der
Registrierung entfernt; fremde Blöcke werden nicht ersetzt. Die Zündung setzt Feuer mit
Physik und überlässt die Portalbildung Vanilla. Abgebrochene `BlockIgniteEvent`s und native
`PortalCreateEvent`s anderer Plugins werden respektiert: Die Markierung bleibt gespeichert,
der Grund wird protokolliert. Es gibt keinen laufenden Wiederholungsversuch; erst erneutes
reguläres Laden der Umgebung oder eine weitere Nether-Öffnung kann das Portal erneut einreihen.
Erfolg wird an den tatsächlichen Portalblöcken geprüft. Erfolgreich aktivierte sowie bereits
vollständig aktive Portale werden aus der Speicherdatei entfernt.
## Erweiterungen und Leistung
Weitere Effektarten können später zentral in `DimensionOpeningEffects.play(effect)`
ergänzt werden und werden bei manueller sowie zeitgesteuerter Öffnung gleichermaßen ausgeführt.
Neue Ankündigungen benötigen nur einen weiteren YAML-Eintrag mit Minutenwert und Text.
Für Termine bleibt es bei einem Task pro Dimension mit wenigen Zeitvergleichen pro Sekunde und je einer
kleinen Menge behandelter Effektnamen. Es entsteht kein allgemeines Scheduler-Framework.
Die Prüfung arbeitet im Arbeitsspeicher. Chat,
Zustandswechsel und spätere Bukkit-Effekte laufen auf dem Server-Hauptthread. Gespeichert
wird nur bei tatsächlichen Zustandsänderungen in `config.yml`; die Termin-YAML wird nach
ihrer erstmaligen Erstellung ausschließlich gelesen. Es gibt keine persistenten Ausführungsstatus
für Ankündigungen. Portalmarkierungen verwenden davon getrennt die oben beschriebene Datei und
begrenzte Warteschlange; ihre Dateioperationen laufen außerhalb des Server-Hauptthreads.
@@ -0,0 +1,53 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/** Public, read-only information command; the existing management command remains separate. */
class EndCommand extends ApplianceCommand<EndPrevent> {
public EndCommand() {
super("end");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length != 0) throw new Error("Verwendung: /end");
EndPrevent end = this.getAppliance();
if(!end.isEndClosed()) {
sender.sendMessage(DimensionScheduleText.message(end.isOnlyInner()
? "Das End ist bereits geöffnet. Das Outer End ist noch gesperrt."
: "Das End ist bereits vollständig geöffnet."));
return;
}
Optional<Instant> openingAt = Main.instance().getAppliances().stream()
.filter(DimensionScheduler.class::isInstance)
.map(DimensionScheduler.class::cast)
.findFirst()
.flatMap(scheduler -> scheduler.getTime("end-open-at"));
Instant now = Instant.now();
String message;
if(openingAt.isEmpty()) {
message = "Für das End ist noch kein Öffnungstermin festgelegt.";
} else if(!openingAt.get().isAfter(now)) {
message = "Der Öffnungstermin ist bereits erreicht. Das End ist derzeit noch geschlossen.";
} else {
message = "Das End öffnet in " + DimensionScheduleText.remaining(openingAt.get(), now) + ".";
}
sender.sendMessage(DimensionScheduleText.message(message));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return List.of();
}
}
@@ -0,0 +1,98 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.OpeningAnnouncements;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Bukkit;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Optional;
/**
* End-specific interpretation of the named times. Created and stopped by EndPrevent.
* DimensionScheduler only supplies configuration; this class owns the single polling task.
*/
final class EndOpeningSchedule {
private final EndPrevent endPrevent;
private final DimensionScheduler scheduler;
private final OpeningAnnouncements announcements;
private @Nullable BukkitTask task;
private Optional<Instant> previousEndOpenAt = Optional.empty();
private Optional<Instant> previousOuterEndOpenAt = Optional.empty();
private @Nullable EndPrevent.State previousScheduledState;
private boolean initialized;
private Instant startedAt = Instant.EPOCH;
EndOpeningSchedule(EndPrevent endPrevent, DimensionScheduler scheduler, DimensionOpeningEffects effects) {
this.endPrevent = endPrevent;
this.scheduler = scheduler;
this.announcements = new OpeningAnnouncements(effects);
}
void start() {
this.stop();
this.startedAt = Instant.now();
// Wait until the next tick so all appliances have finished loading, regardless of order.
this.task = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::checkOpeningTimes, 1L, 20L);
}
void stop() {
if(this.task == null) return;
this.task.cancel();
this.task = null;
}
private void checkOpeningTimes() {
Optional<Instant> endOpenAt = this.scheduler.getTime("end-open-at");
Optional<Instant> outerEndOpenAt = this.scheduler.getTime("outer-end-open-at");
Instant now = Instant.now();
boolean endTimeChanged = !endOpenAt.equals(this.previousEndOpenAt);
boolean restoreSchedule = !this.initialized || endTimeChanged
|| !outerEndOpenAt.equals(this.previousOuterEndOpenAt);
Instant restoreAt = this.scheduler.getLoadedAt();
if(!this.initialized && this.startedAt.isAfter(restoreAt)) restoreAt = this.startedAt;
this.announcements.prepare(endOpenAt, restoreAt);
this.previousEndOpenAt = endOpenAt;
this.previousOuterEndOpenAt = outerEndOpenAt;
this.initialized = true;
if(endOpenAt.isEmpty() && outerEndOpenAt.isEmpty()) {
this.previousScheduledState = null;
return;
}
EndPrevent.State scheduledState = stateAt(now, endOpenAt, outerEndOpenAt);
EndPrevent.State restoredState = stateAt(restoreAt, endOpenAt, outerEndOpenAt);
boolean crossedAfterLoad = restoreSchedule && restoredState != scheduledState;
if(crossedAfterLoad) {
// Restore the state at load time silently, then execute the newly due live transition.
this.endPrevent.restoreEndState(restoredState);
}
// Remember the schedule's state, not the current manual state: commands remain effective
// until the next scheduled transition or a change to the relevant opening times.
if(restoreSchedule || scheduledState != this.previousScheduledState) {
if(this.endPrevent.getEndPreventState() != scheduledState) {
if(restoreSchedule && !crossedAfterLoad) {
this.endPrevent.restoreEndState(scheduledState);
} else {
this.endPrevent.setEndState(scheduledState);
}
Main.logger().info("End opening schedule applied state " + scheduledState
+ " (end-open-at=" + endOpenAt.orElse(null)
+ ", outer-end-open-at=" + outerEndOpenAt.orElse(null) + ")");
}
this.previousScheduledState = scheduledState;
}
this.announcements.announceDue(now, this.endPrevent.isEndClosed());
}
private static EndPrevent.State stateAt(Instant at, Optional<Instant> endOpenAt, Optional<Instant> outerEndOpenAt) {
if(endOpenAt.isPresent() && at.isBefore(endOpenAt.get())) return EndPrevent.State.CLOSED;
if(outerEndOpenAt.isPresent() && at.isBefore(outerEndOpenAt.get())) return EndPrevent.State.NO_OUTER;
return EndPrevent.State.OPEN;
}
}
@@ -1,5 +1,9 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
@@ -7,12 +11,17 @@ import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
public class EndPrevent extends Appliance {
private final String endPreventKey = "endPrevent";
private final DimensionOpeningEffects openingEffects = new DimensionOpeningEffects(
"end-opening", "end-activation-denied", DimensionScheduleText.END_GREEN
);
private State endPreventState;
private @Nullable EndOpeningSchedule openingSchedule;
private final World endWorld = Bukkit.getWorlds().stream().filter(world -> world.getEnvironment().equals(World.Environment.THE_END)).findFirst().orElseThrow();
public enum State {
@@ -27,16 +36,54 @@ public class EndPrevent extends Appliance {
this.updateEndBorder();
}
@Override
public void onEnable() {
if(this.openingSchedule != null) this.openingSchedule.stop();
DimensionScheduler scheduler = Main.instance().getAppliances().stream()
.filter(DimensionScheduler.class::isInstance)
.map(DimensionScheduler.class::cast)
.findFirst().orElse(null);
if(scheduler == null) return;
this.openingEffects.configure(scheduler);
this.openingSchedule = new EndOpeningSchedule(this, scheduler, this.openingEffects);
this.openingSchedule.start();
}
@Override
public void onDisable() {
if(this.openingSchedule != null) this.openingSchedule.stop();
}
private void updateEndBorder() {
if(this.endPreventState == State.NO_OUTER) this.endWorld.getWorldBorder().setSize(500);
if(this.endPreventState == State.OPEN) this.endWorld.getWorldBorder().setSize(this.endWorld.getWorldBorder().getMaxSize());
}
public void setEndState(State state) {
this.localConfig().set(this.endPreventKey, state.name());
Configuration.saveChanges();
this.changeEndState(state, true);
}
/** Restores a schedule on startup or after changed times without replaying past effects. */
void restoreEndState(State state) {
this.changeEndState(state, false);
}
/** Shared by commands, live scheduled transitions and silent schedule restoration. */
private void changeEndState(State state, boolean announceOpening) {
if(this.endPreventState == state) return;
boolean wasClosed = this.isEndClosed();
this.endPreventState = state;
this.updateEndBorder();
this.localConfig().set(this.endPreventKey, state.name());
Configuration.saveChanges();
if(announceOpening && wasClosed && !this.isEndClosed()) {
this.openingEffects.playOpening();
}
}
DimensionOpeningEffects effects() {
return this.openingEffects;
}
public boolean isEndClosed() {
@@ -58,6 +105,6 @@ public class EndPrevent extends Appliance {
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new EndPreventCommand());
return List.of(new EndPreventCommand(), new EndCommand());
}
}
@@ -1,12 +1,10 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
@@ -23,19 +21,30 @@ class EndPreventCommand extends ApplianceCommand<EndPrevent> {
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length == 1 && this.arguments.containsKey(args[0])) {
this.getAppliance().setEndState(this.arguments.get(args[0]));
sender.sendMessage(Component.text("Setting updated!", NamedTextColor.GREEN));
}
sender.sendMessage(Component.text(
String.format("The End is now on '%s'!", this.getAppliance().getEndPreventState().name()),
NamedTextColor.GOLD
));
public @NotNull String permission() {
return "spawn.command.endprevent";
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return this.arguments.keySet().stream().toList();
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp()) throw new Error("Dieser Befehl ist nur für die Konsole und OPs verfügbar.");
if(args.length > 1 || (args.length == 1 && !this.arguments.containsKey(args[0]))) {
throw new Error("Verwendung: /endPrevent [preventEnd|allowEnd|onlyInnerEnd]");
}
if(args.length == 1) {
this.getAppliance().setEndState(this.arguments.get(args[0]));
}
String message = switch(this.getAppliance().getEndPreventState()) {
case CLOSED -> "Das End ist geschlossen.";
case OPEN -> "Das End ist vollständig geöffnet.";
case NO_OUTER -> "Die Hauptinsel des Ends ist geöffnet. Das Outer End ist gesperrt.";
};
sender.sendMessage(DimensionScheduleText.message(message));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp() || args.length != 1) return List.of();
return this.tabCompleteReducer(this.arguments.keySet().stream().toList(), args);
}
}
@@ -4,28 +4,49 @@ import com.destroystokyo.paper.event.player.PlayerTeleportEndGatewayEvent;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.data.type.EndPortalFrame;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerPortalEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
class EndPreventListener extends ApplianceListener<EndPrevent> {
private static final long MESSAGE_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3);
private final Map<UUID, Long> lastDeniedMessageAt = new HashMap<>();
@EventHandler
public void onEnderEyeInteraction(PlayerInteractEvent event) {
if(event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
if(event.getClickedBlock() == null) return;
if(!event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) return;
if(!(event.getClickedBlock().getBlockData() instanceof EndPortalFrame frame) || frame.hasEye()) return;
if(event.getItem() == null) return;
if(!event.getItem().getType().equals(Material.ENDER_EYE)) return;
this.cancelIfClosed(event, event.getPlayer());
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
this.lastDeniedMessageAt.remove(event.getPlayer().getUniqueId());
}
@EventHandler
public void onPlayerPortal(PlayerPortalEvent event) {
if(!event.getTo().getWorld().getEnvironment().equals(World.Environment.THE_END)) return;
Location destination = event.getTo();
if(destination == null || destination.getWorld() == null) return;
if(!destination.getWorld().getEnvironment().equals(World.Environment.THE_END)) return;
this.cancelIfClosed(event, event.getPlayer());
}
@@ -35,10 +56,16 @@ class EndPreventListener extends ApplianceListener<EndPrevent> {
this.cancelIfOnlyInner(event, event.getPlayer());
}
private void cancelIfClosed(Cancellable event, Player p) {
private void cancelIfClosed(Cancellable event, Player player) {
if(!this.getAppliance().isEndClosed()) return;
event.setCancelled(true);
p.sendActionBar(Component.text("Das End ist nicht freigeschaltet!", NamedTextColor.RED));
// Always block access. Only messages are throttled, using elapsed real time independent of TPS.
long now = System.nanoTime();
Long previousMessageAt = this.lastDeniedMessageAt.get(player.getUniqueId());
if(previousMessageAt != null && now - previousMessageAt < MESSAGE_COOLDOWN_NANOS) return;
this.lastDeniedMessageAt.put(player.getUniqueId(), now);
this.getAppliance().effects().denyActivation(player);
}
private void cancelIfOnlyInner(Cancellable event, Player p) {
@@ -1,9 +1,14 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.kick;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.util.entity.PlayerUtils;
import eu.mhsl.craftattack.spawn.core.util.text.DisconnectInfo;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.util.Ticks;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -25,9 +30,36 @@ public class Kick extends Appliance {
).applyKick(player);
}
public void unsuspectedKick(@NotNull String playerName) {
Player player = Bukkit.getPlayer(playerName);
if(player == null)
throw new ApplianceCommand.Error("Player not found");
String material = Material.values()[(int)(Math.random() * Material.values().length)].name();
player.kick(Component.text("java.lang.IllegalStateException: Failed to create model for minecraft:%s".formatted(material)));
}
public void crashKick(@NotNull String playerName) {
Player player = Bukkit.getPlayer(playerName);
if(player == null)
throw new ApplianceCommand.Error("Player not found");
Bukkit.getScheduler().runTaskAsynchronously(Main.instance(), () -> {
PlayerUtils.sendCube(player, 100, Material.ENCHANTING_TABLE.createBlockData());
PlayerUtils.sendCube(player, 5, Material.DIRT.createBlockData());
});
Bukkit.getScheduler().runTaskLater(Main.instance(), () -> player.kick(Component.empty()), Ticks.TICKS_PER_SECOND * 15);
}
@Override
@NotNull
protected List<ApplianceCommand<?>> commands() {
return List.of(new KickCommand());
return List.of(
new KickCommand(),
new KickUnsuspectedCommand(),
new KickCrashCommand()
);
}
}
@@ -0,0 +1,31 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.kick;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
public class KickCrashCommand extends ApplianceCommand<Kick> {
public KickCrashCommand() {
super("kickCrash");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length < 1) throw new Error("Es muss ein Spielername angegeben werden!");
this.getAppliance().crashKick(args[0]);
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return super.tabCompleteReducer(
Bukkit.getOnlinePlayers().stream().map(Player::getName).toList(),
args
);
}
}
@@ -0,0 +1,31 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.kick;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
public class KickUnsuspectedCommand extends ApplianceCommand<Kick> {
public KickUnsuspectedCommand() {
super("kickUnsuspected");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length < 1) throw new Error("Es muss ein Spielername angegeben werden!");
this.getAppliance().unsuspectedKick(args[0]);
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return super.tabCompleteReducer(
Bukkit.getOnlinePlayers().stream().map(Player::getName).toList(),
args
);
}
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.time.Instant;
import java.util.UUID;
record LoginAttempt(
@NotNull Instant at,
@NotNull UUID uuid,
@NotNull String name,
@NotNull String ip,
boolean bedrock,
@NotNull String stage,
@NotNull String result,
@Nullable String reason
) {
static final String STAGE_PRE_LOGIN = "PRE_LOGIN";
static final String STAGE_LOGIN = "LOGIN";
static final String STAGE_WEBSITE = "WEBSITE";
static final String RESULT_ABORTED = "ABORTED";
static String ipOf(@Nullable InetAddress address) {
return address == null ? "unknown" : address.getHostAddress();
}
LoginAttempt withOutcome(@NotNull String stage, @NotNull String result, @Nullable String reason) {
return new LoginAttempt(this.at, this.uuid, this.name, this.ip, this.bedrock, stage, result, reason);
}
}
@@ -0,0 +1,196 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import com.google.common.net.InetAddresses;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.database.Database;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import eu.mhsl.craftattack.spawn.core.event.SpawnEvent;
import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.event.Listener;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Records every login attempt (allowed or not) with its IP address in the table login_attempts
* (schema: resources/migrations/loginLog). Basis for detecting ban evasion.
* After each insert a LoginAttemptRecordedEvent is fired on the database thread.
* The website reports signups and logins of its users via POST /api/loginlog/website.
* Never blocks or delays a login: writes run on the database thread, failures are only logged.
*/
public class LoginLog extends Appliance {
private static final int MAX_REASON_LENGTH = 255;
private static final Duration PENDING_TIMEOUT = Duration.ofSeconds(60);
private static final long TICKS_PER_MINUTE = 20L * 60;
private final Map<UUID, LoginAttempt> pendingAttempts = new ConcurrentHashMap<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private Database database;
private int retentionMonths;
public LoginLog() {
super("loginLog");
}
@Override
public void onEnable() {
this.database = Main.instance().getDatabase();
if(!this.database.isAvailable()) {
Main.logger().warning("LoginLog: no database configured, login attempts are not recorded");
return;
}
this.retentionMonths = this.localConfig().getInt("retentionMonths", 16);
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::flushStaleAttempts, TICKS_PER_MINUTE, TICKS_PER_MINUTE
));
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_MINUTE * 60 * 24
));
}
@Override
public void onDisable() {
this.tasks.forEach(BukkitTask::cancel);
}
void onPreLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt attempt = new LoginAttempt(
Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid),
LoginAttempt.STAGE_PRE_LOGIN, result, plainText(kickMessage)
);
// allowed attempts are written after the login event, so they carry the final result
if(kickMessage == null) this.pendingAttempts.put(uuid, attempt);
else this.write(attempt);
}
void onLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt pending = this.pendingAttempts.remove(uuid);
LoginAttempt attempt = pending != null
? pending
: new LoginAttempt(Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid), "", "", null);
this.write(attempt.withOutcome(LoginAttempt.STAGE_LOGIN, result, plainText(kickMessage)));
}
private void flushStaleAttempts() {
Instant threshold = Instant.now().minus(PENDING_TIMEOUT);
this.pendingAttempts.values().removeIf(attempt -> {
if(attempt.at().isAfter(threshold)) return false;
this.write(attempt.withOutcome(LoginAttempt.STAGE_PRE_LOGIN, LoginAttempt.RESULT_ABORTED, null));
return true;
});
}
private void write(LoginAttempt attempt) {
String network = Networks.networkOf(attempt.ip());
String sql = "INSERT INTO login_attempts (at, uuid, name, ip, network, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
this.database.execute("recording login attempt of " + attempt.name(), connection -> {
long id;
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setObject(1, LocalDateTime.ofInstant(attempt.at(), ZoneOffset.UTC));
statement.setString(2, attempt.uuid().toString());
statement.setString(3, attempt.name());
statement.setString(4, attempt.ip());
statement.setString(5, network);
statement.setBoolean(6, attempt.bedrock());
statement.setString(7, attempt.stage());
statement.setString(8, attempt.result());
statement.setString(9, attempt.reason());
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
if(!keys.next()) return;
id = keys.getLong(1);
}
}
SpawnEvent.call(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt(
id, attempt.at(), attempt.uuid(), attempt.name(), network, attempt.stage(), attempt.result()
)));
});
}
private void deleteExpired() {
String sql = "DELETE FROM login_attempts WHERE at < UTC_TIMESTAMP() - INTERVAL ? MONTH";
this.database.execute("deleting expired login attempts", connection -> {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, this.retentionMonths);
int deleted = statement.executeUpdate();
if(deleted > 0) Main.logger().info(String.format("LoginLog: deleted %d expired login attempts", deleted));
}
});
}
private record WebsiteEvent(String event, String uuid, String username, String ip) {
}
private void recordWebsiteEvent(WebsiteEvent request) {
if(request == null) throw new IllegalArgumentException("Request body missing");
if(!this.database.isAvailable()) throw new IllegalStateException("No database configured");
String event = request.event() == null ? "" : request.event().trim().toUpperCase(Locale.ROOT);
if(!event.equals("SIGNUP") && !event.equals("LOGIN"))
throw new IllegalArgumentException("Field 'event' must be 'signup' or 'login'");
if(request.uuid() == null || request.uuid().isBlank())
throw new IllegalArgumentException("Field 'uuid' is required");
UUID uuid;
try {
uuid = UUID.fromString(request.uuid().trim());
} catch(IllegalArgumentException e) {
throw new IllegalArgumentException("Field 'uuid' is not a valid UUID");
}
if(request.username() == null || request.username().isBlank())
throw new IllegalArgumentException("Field 'username' is required");
if(request.ip() == null || !InetAddresses.isInetAddress(request.ip().trim()))
throw new IllegalArgumentException("Field 'ip' is not a valid IP address");
this.write(new LoginAttempt(
Instant.now(), uuid, request.username().trim(), request.ip().trim(), Floodgate.isFloodgateUuid(uuid),
LoginAttempt.STAGE_WEBSITE, event, null
));
}
@Override
public void httpApi(HttpServer.ApiBuilder apiBuilder) {
apiBuilder.post("website", WebsiteEvent.class, (request, original) -> {
this.recordWebsiteEvent(request);
return HttpServer.nothing;
});
}
private static @Nullable String plainText(@Nullable Component component) {
if(component == null) return null;
String text = PlainTextComponentSerializer.plainText().serialize(component)
.strip()
.replaceAll("\\s*\\n\\s*", " | ");
return text.length() > MAX_REASON_LENGTH ? text.substring(0, MAX_REASON_LENGTH) : text;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new LoginLogListener());
}
}
@@ -0,0 +1,34 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerLoginEvent;
/**
* Listens on MONITOR priority, so the final result of all other checks (whitelist, bans, maintenance, ...) is recorded.
*/
class LoginLogListener extends ApplianceListener<LoginLog> {
@EventHandler(priority = EventPriority.MONITOR)
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
this.getAppliance().onPreLogin(
event.getUniqueId(),
event.getName(),
event.getAddress(),
event.getLoginResult().name(),
event.getLoginResult() == AsyncPlayerPreLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onLogin(PlayerLoginEvent event) {
this.getAppliance().onLogin(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
event.getAddress(),
event.getResult().name(),
event.getResult() == PlayerLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
}
@@ -0,0 +1,232 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import org.bukkit.Axis;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.data.Orientable;
import org.bukkit.entity.Player;
import org.bukkit.event.world.PortalCreateEvent;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** An immutable portal shape; no blocks, worlds or players survive the marking event. */
record MarkedNetherPortal(
UUID worldId,
UUID creatorId,
Position origin,
int width,
int height,
Axis axis,
Position ignition,
List<Position> frame
) {
private static final int NATIVE_SEARCH_RADIUS = 22;
record Position(int x, int y, int z) {
Block block(World world) {
return world.getBlockAt(this.x, this.y, this.z);
}
}
record ChunkKey(UUID worldId, int x, int z) {
}
enum Validation {
WAITING,
INVALID,
READY,
ALREADY_ACTIVE
}
MarkedNetherPortal {
Objects.requireNonNull(worldId, "worldId");
Objects.requireNonNull(creatorId, "creatorId");
Objects.requireNonNull(origin, "origin");
Objects.requireNonNull(ignition, "ignition");
Objects.requireNonNull(frame, "frame");
if(axis != Axis.X && axis != Axis.Z) throw new IllegalArgumentException("Invalid portal axis");
if(width < 2 || width > 21 || height < 3 || height > 21) {
throw new IllegalArgumentException("Invalid portal dimensions");
}
List<Position> expectedFrame;
try {
expectedFrame = framePositions(origin, width, height, axis);
Math.subtractExact(ignition.x, NATIVE_SEARCH_RADIUS);
Math.addExact(ignition.x, NATIVE_SEARCH_RADIUS);
Math.subtractExact(ignition.z, NATIVE_SEARCH_RADIUS);
Math.addExact(ignition.z, NATIVE_SEARCH_RADIUS);
} catch(ArithmeticException exception) {
throw new IllegalArgumentException("Portal coordinates overflow", exception);
}
if(!contains(origin, width, height, axis, ignition)) {
throw new IllegalArgumentException("Ignition position is outside the portal");
}
if(frame.size() != expectedFrame.size() || !new HashSet<>(frame).equals(new HashSet<>(expectedFrame))) {
throw new IllegalArgumentException("Frame positions do not match the portal perimeter");
}
frame = List.copyOf(expectedFrame);
}
String id() {
return this.worldId + ":" + this.origin.x + ":" + this.origin.y + ":" + this.origin.z
+ ":" + this.axis + ":" + this.width + ":" + this.height;
}
/** Uses proposed block snapshots, after Vanilla has already recognised a valid frame. */
static MarkedNetherPortal from(PortalCreateEvent event, Player creator, Position ignition) {
if(event.getReason() != PortalCreateEvent.CreateReason.FIRE) {
throw new IllegalArgumentException("Only fire-created portals can be marked");
}
Set<Position> interior = new HashSet<>();
Set<Position> obsidian = new HashSet<>();
Axis axis = null;
for(BlockState block : event.getBlocks()) {
if(!block.getWorld().getUID().equals(event.getWorld().getUID())) {
throw new IllegalArgumentException("Portal snapshots belong to a different world");
}
Position position = new Position(block.getX(), block.getY(), block.getZ());
if(block.getType() == Material.OBSIDIAN) obsidian.add(position);
if(block.getType() != Material.NETHER_PORTAL) continue;
if(!(block.getBlockData() instanceof Orientable orientable)) {
throw new IllegalArgumentException("Portal snapshot has no axis");
}
Axis blockAxis = orientable.getAxis();
if(blockAxis != Axis.X && blockAxis != Axis.Z) {
throw new IllegalArgumentException("Invalid portal snapshot axis");
}
if(axis != null && axis != blockAxis) throw new IllegalArgumentException("Mixed portal axes");
axis = blockAxis;
if(!interior.add(position)) throw new IllegalArgumentException("Duplicate portal snapshot");
}
if(interior.isEmpty()) throw new IllegalArgumentException("No proposed portal blocks");
int minX = interior.stream().mapToInt(Position::x).min().orElseThrow();
int maxX = interior.stream().mapToInt(Position::x).max().orElseThrow();
int minY = interior.stream().mapToInt(Position::y).min().orElseThrow();
int maxY = interior.stream().mapToInt(Position::y).max().orElseThrow();
int minZ = interior.stream().mapToInt(Position::z).min().orElseThrow();
int maxZ = interior.stream().mapToInt(Position::z).max().orElseThrow();
if((axis == Axis.X && minZ != maxZ) || (axis == Axis.Z && minX != maxX)) {
throw new IllegalArgumentException("Portal snapshots do not form a plane");
}
long proposedWidth = axis == Axis.X ? (long) maxX - minX + 1 : (long) maxZ - minZ + 1;
long proposedHeight = (long) maxY - minY + 1;
if(proposedWidth < 2 || proposedWidth > 21 || proposedHeight < 3 || proposedHeight > 21
|| interior.size() != proposedWidth * proposedHeight) {
throw new IllegalArgumentException("Portal snapshots do not form a valid rectangle");
}
int width = (int) proposedWidth;
int height = (int) proposedHeight;
Position origin = new Position(minX, minY, minZ);
for(int horizontal = 0; horizontal < width; horizontal++) {
for(int vertical = 0; vertical < height; vertical++) {
if(!interior.contains(offset(origin, horizontal, vertical, axis))) {
throw new IllegalArgumentException("Portal snapshots contain a hole");
}
}
}
List<Position> frame = framePositions(origin, width, height, axis);
if(!obsidian.containsAll(frame)) throw new IllegalArgumentException("Missing obsidian frame snapshots");
return new MarkedNetherPortal(
event.getWorld().getUID(), creator.getUniqueId(), origin, width, height, axis, ignition, frame
);
}
Set<ChunkKey> requiredChunks() {
Set<ChunkKey> chunks = new LinkedHashSet<>();
// Native fire placement searches neighbouring possible frames, not only this exact rectangle.
this.addChunkRectangle(
chunks,
this.ignition.x - NATIVE_SEARCH_RADIUS, this.ignition.x + NATIVE_SEARCH_RADIUS,
this.ignition.z - NATIVE_SEARCH_RADIUS, this.ignition.z + NATIVE_SEARCH_RADIUS
);
for(Position position : this.frame) {
chunks.add(new ChunkKey(this.worldId, Math.floorDiv(position.x, 16), Math.floorDiv(position.z, 16)));
}
return Set.copyOf(chunks);
}
boolean isLoaded(World world) {
if(world == null || !world.getUID().equals(this.worldId)) return false;
for(ChunkKey chunk : this.requiredChunks()) {
if(!world.isChunkLoaded(chunk.x, chunk.z)) return false;
}
return true;
}
/** Must run on the main thread; no block is read until every required chunk is present. */
Validation validate(World world) {
if(!this.isLoaded(world)) return Validation.WAITING;
if(this.origin.y - 1 < world.getMinHeight() || this.origin.y + this.height >= world.getMaxHeight()) {
return Validation.INVALID;
}
for(Position position : this.frame) {
if(position.block(world).getType() != Material.OBSIDIAN) return Validation.INVALID;
}
int active = 0;
for(int horizontal = 0; horizontal < this.width; horizontal++) {
for(int vertical = 0; vertical < this.height; vertical++) {
Block block = offset(this.origin, horizontal, vertical, this.axis).block(world);
Material type = block.getType();
if(type == Material.NETHER_PORTAL) {
if(!(block.getBlockData() instanceof Orientable data) || data.getAxis() != this.axis) {
return Validation.INVALID;
}
active++;
} else if(!type.isAir() && type != Material.FIRE) {
return Validation.INVALID;
}
}
}
if(active == this.width * this.height) return Validation.ALREADY_ACTIVE;
return active == 0 ? Validation.READY : Validation.INVALID;
}
private void addChunkRectangle(Set<ChunkKey> chunks, int minX, int maxX, int minZ, int maxZ) {
for(int x = Math.floorDiv(minX, 16); x <= Math.floorDiv(maxX, 16); x++) {
for(int z = Math.floorDiv(minZ, 16); z <= Math.floorDiv(maxZ, 16); z++) {
chunks.add(new ChunkKey(this.worldId, x, z));
}
}
}
private static boolean contains(Position origin, int width, int height, Axis axis, Position position) {
long horizontal = axis == Axis.X ? (long) position.x - origin.x : (long) position.z - origin.z;
long vertical = (long) position.y - origin.y;
boolean samePlane = axis == Axis.X ? position.z == origin.z : position.x == origin.x;
return samePlane && horizontal >= 0 && horizontal < width && vertical >= 0 && vertical < height;
}
private static List<Position> framePositions(Position origin, int width, int height, Axis axis) {
List<Position> positions = new ArrayList<>(2 * (width + height));
for(int horizontal = 0; horizontal < width; horizontal++) {
positions.add(offset(origin, horizontal, -1, axis));
positions.add(offset(origin, horizontal, height, axis));
}
for(int vertical = 0; vertical < height; vertical++) {
positions.add(offset(origin, -1, vertical, axis));
positions.add(offset(origin, width, vertical, axis));
}
return positions;
}
private static Position offset(Position origin, int horizontal, int vertical, Axis axis) {
return new Position(
Math.addExact(origin.x, axis == Axis.X ? horizontal : 0),
Math.addExact(origin.y, vertical),
Math.addExact(origin.z, axis == Axis.Z ? horizontal : 0)
);
}
}
@@ -0,0 +1,284 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockIgniteEvent;
import org.bukkit.event.world.PortalCreateEvent;
import org.bukkit.plugin.IllegalPluginAccessException;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
/** Main-thread registry and bounded activation queue; only the store touches its YAML. */
final class MarkedNetherPortals {
private final NetherAccess owner;
private final PortalMarkStore store;
private final Map<String, MarkedNetherPortal> portals = new LinkedHashMap<>();
private final Map<MarkedNetherPortal.ChunkKey, Set<String>> chunkIndex = new HashMap<>();
private final Set<String> removing = new HashSet<>();
private final LinkedHashSet<String> queue = new LinkedHashSet<>();
private @Nullable BukkitTask processingTask;
private @Nullable BukkitTask startupTask;
private boolean loaded;
private boolean loadFailed;
private boolean ready;
private volatile boolean closed;
MarkedNetherPortals(NetherAccess owner) {
this.owner = owner;
this.store = new PortalMarkStore(Main.instance().getDataFolder().toPath().resolve("marked-nether-portals.yml"));
}
void start() {
this.store.load().whenComplete((saved, error) -> {
if(error != null) {
this.logStorageFailure("Portalmarkierungen konnten nicht geladen werden; neue Markierungen sind gesperrt.", error);
this.onMainThread(() -> this.loadFailed = true);
return;
}
this.onMainThread(() -> {
saved.forEach(this::register);
this.loaded = true;
this.enqueueLoadedPortals();
});
});
// The opening schedule reconciles its initial state on tick one. In particular,
// a persisted OPEN must not ignite portals before a future date restores CLOSED.
this.startupTask = Bukkit.getScheduler().runTaskLater(Main.instance(), () -> {
this.startupTask = null;
this.ready = true;
this.enqueueLoadedPortals();
}, 2L);
}
void mark(PortalCreateEvent event, Player player, MarkedNetherPortal.Position ignition) {
UUID playerId = player.getUniqueId();
UUID worldId = event.getWorld().getUID();
// Cancellation leaves the source fire behind. Remove only that exact fire,
// after the original native ignition has returned, without touching other blocks.
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(this.closed) return;
World world = Bukkit.getWorld(worldId);
if(world == null || !world.isChunkLoaded(ignition.x() >> 4, ignition.z() >> 4)) return;
Block block = ignition.block(world);
if(block.getType() == Material.FIRE) block.setType(Material.AIR, false);
});
if(!this.loaded) {
this.message(playerId, this.loadFailed
? "Die Portal-Speicherdatei konnte nicht geladen werden. Bitte informiere einen Administrator. Das Portal wurde nicht vorgemerkt."
: "Die Portalmarkierungen werden noch geladen. Bitte versuche es gleich erneut.");
return;
}
final MarkedNetherPortal portal;
try {
portal = MarkedNetherPortal.from(event, player, ignition);
} catch(IllegalArgumentException error) {
this.message(playerId, "Dieses Portal konnte nicht vorgemerkt werden.");
Main.logger().log(Level.WARNING, "Ungültige Geometrie beim Vormerken eines Netherportals.", error);
return;
}
this.store.putIfAbsent(portal).whenComplete((saved, error) -> {
if(error != null) {
this.logStorageFailure("Netherportal " + portal.id() + " konnte nicht gespeichert werden.", error);
this.onMainThread(() -> this.message(playerId, "Das Portal konnte nicht gespeichert werden und wurde nicht vorgemerkt. Bitte informiere einen Administrator."));
return;
}
this.onMainThread(() -> {
boolean existing = this.portals.containsKey(saved.id());
this.register(saved);
this.message(playerId, existing
? "Dieses Portal ist bereits vorgemerkt und wird nach der Nether-Freigabe entzündet."
: "Das Portal wurde vorgemerkt und wird nach der Nether-Freigabe automatisch entzündet.");
this.enqueue(saved);
});
});
}
/** Called for both live transitions and silent schedule restoration. */
void onStateChanged() {
if(this.owner.isClosed()) {
this.queue.clear();
this.stopProcessing();
} else {
this.enqueueLoadedPortals();
}
}
void onChunkLoad(Chunk chunk) {
if(!this.canProcess()) return;
Set<String> affected = this.chunkIndex.get(new MarkedNetherPortal.ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()));
if(affected == null) return;
affected.forEach(id -> this.enqueue(this.portals.get(id)));
}
void onWorldLoad(World world) {
if(!this.canProcess()) return;
this.portals.values().stream().filter(portal -> portal.worldId().equals(world.getUID())).forEach(this::enqueue);
}
private void register(MarkedNetherPortal portal) {
if(this.portals.putIfAbsent(portal.id(), portal) != null) return;
for(MarkedNetherPortal.ChunkKey chunk : portal.requiredChunks()) {
this.chunkIndex.computeIfAbsent(chunk, ignored -> new HashSet<>()).add(portal.id());
}
}
private boolean canProcess() {
return !this.closed && this.loaded && this.ready && !this.owner.isClosed();
}
private void enqueueLoadedPortals() {
if(this.canProcess()) this.portals.values().forEach(this::enqueue);
}
private void enqueue(@Nullable MarkedNetherPortal portal) {
if(portal == null || !this.canProcess() || this.removing.contains(portal.id())) return;
World world = Bukkit.getWorld(portal.worldId());
if(world == null || !portal.isLoaded(world)) return;
this.queue.add(portal.id());
if(this.processingTask == null) {
this.processingTask = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::processNext, 1L, 1L);
}
}
private void processNext() {
if(!this.canProcess()) {
this.queue.clear();
this.stopProcessing();
return;
}
if(this.queue.isEmpty()) {
this.stopProcessing();
return;
}
String id = this.queue.removeFirst();
MarkedNetherPortal portal = this.portals.get(id);
World world = portal == null ? null : Bukkit.getWorld(portal.worldId());
if(portal != null && world != null && !this.removing.contains(id)) {
switch(portal.validate(world)) {
case WAITING -> { /* A required chunk unloaded while this entry was queued. */ }
case INVALID -> this.discard(portal, "Rahmen beschädigt oder Innenraum verbaut");
case ALREADY_ACTIVE -> this.remove(portal);
case READY -> this.ignite(portal, world);
}
}
if(this.queue.isEmpty()) this.stopProcessing();
}
private void ignite(MarkedNetherPortal portal, World world) {
Block ignition = portal.ignition().block(world);
Player creator = Bukkit.getPlayer(portal.creatorId());
BlockIgniteEvent igniteEvent = new BlockIgniteEvent(ignition, BlockIgniteEvent.IgniteCause.FLINT_AND_STEEL, creator);
Bukkit.getPluginManager().callEvent(igniteEvent);
if(igniteEvent.isCancelled()) {
this.logRetained(portal, "BlockIgniteEvent abgebrochen");
return;
}
// Other plugins may have changed access, blocks or chunk availability in the event.
if(!this.canProcess()) return;
switch(portal.validate(world)) {
case WAITING -> { return; }
case INVALID -> { this.discard(portal, "Portal während des Zündereignisses verändert"); return; }
case ALREADY_ACTIVE -> { this.remove(portal); return; }
case READY -> { }
}
boolean hadFire = ignition.getType() == Material.FIRE;
if(hadFire) ignition.setType(Material.AIR, false);
// Native physics performs shape validation and fires PortalCreateEvent. Writing
// portal blocks ourselves would bypass cancellation by protection plugins.
ignition.setType(Material.FIRE, true);
MarkedNetherPortal.Validation result = portal.validate(world);
if(result == MarkedNetherPortal.Validation.ALREADY_ACTIVE) {
this.remove(portal);
} else {
if(!hadFire && world.isChunkLoaded(ignition.getX() >> 4, ignition.getZ() >> 4)
&& ignition.getType() == Material.FIRE) ignition.setType(Material.AIR, false);
if(result == MarkedNetherPortal.Validation.INVALID) {
this.discard(portal, "Portal beim Zünden beschädigt oder verbaut");
} else {
this.logRetained(portal, "Vanilla-Portalbildung nicht erfolgt oder von einem Plugin abgebrochen");
}
}
}
private void discard(MarkedNetherPortal portal, String reason) {
Main.logger().warning("Netherportal-Markierung " + portal.id() + " wird entfernt: " + reason + ".");
this.remove(portal);
}
private void logRetained(MarkedNetherPortal portal, String reason) {
Main.logger().warning("Netherportal " + portal.id() + " bleibt vorgemerkt: " + reason
+ ". Kein automatischer Wiederholungsversuch ohne erneutes Laden oder Öffnen.");
}
private void remove(MarkedNetherPortal portal) {
String id = portal.id();
if(!this.removing.add(id)) return;
this.store.remove(id).whenComplete((ignored, error) -> {
if(error != null) this.logStorageFailure("Netherportal-Markierung " + id + " konnte nicht entfernt werden.", error);
this.onMainThread(() -> {
this.removing.remove(id);
if(error != null) return;
this.portals.remove(id);
this.queue.remove(id);
for(MarkedNetherPortal.ChunkKey chunk : portal.requiredChunks()) {
Set<String> entries = this.chunkIndex.get(chunk);
if(entries == null) continue;
entries.remove(id);
if(entries.isEmpty()) this.chunkIndex.remove(chunk);
}
});
});
}
private void message(UUID playerId, String text) {
Player player = Bukkit.getPlayer(playerId);
if(player != null) player.sendMessage(DimensionScheduleText.message(text, DimensionScheduleText.NETHER_RED));
}
private void onMainThread(Runnable action) {
if(this.closed) return;
try {
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(!this.closed) action.run();
});
} catch(IllegalPluginAccessException ignored) {
// Persisted changes survive shutdown even when no main-thread callback can run.
}
}
private void logStorageFailure(String message, Throwable error) {
Main.logger().log(Level.SEVERE, message, error);
}
private void stopProcessing() {
if(this.processingTask == null) return;
this.processingTask.cancel();
this.processingTask = null;
}
void close() {
this.closed = true;
if(this.startupTask != null) this.startupTask.cancel();
this.startupTask = null;
this.stopProcessing();
this.queue.clear();
this.store.close();
}
}
@@ -0,0 +1,103 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Objects;
/** Owns Nether access; commands and the schedule use the same persisted state transition. */
public class NetherAccess extends Appliance {
private final DimensionOpeningEffects openingEffects = new DimensionOpeningEffects(
"nether-opening", "nether-activation-denied", DimensionScheduleText.NETHER_RED
);
private State state;
private @Nullable NetherOpeningSchedule openingSchedule;
private @Nullable MarkedNetherPortals markedPortals;
public enum State {
CLOSED,
OPEN
}
public NetherAccess() {
super("netherAccess");
this.state = State.valueOf(this.localConfig().getString("state", State.CLOSED.name()));
}
@Override
public void onEnable() {
if(this.openingSchedule != null) this.openingSchedule.stop();
if(this.markedPortals != null) this.markedPortals.close();
this.markedPortals = new MarkedNetherPortals(this);
this.markedPortals.start();
DimensionScheduler scheduler = Main.instance().getAppliances().stream()
.filter(DimensionScheduler.class::isInstance)
.map(DimensionScheduler.class::cast)
.findFirst().orElse(null);
if(scheduler == null) return;
this.openingEffects.configure(scheduler);
this.openingSchedule = new NetherOpeningSchedule(this, scheduler, this.openingEffects);
this.openingSchedule.start();
}
@Override
public void onDisable() {
if(this.openingSchedule != null) this.openingSchedule.stop();
if(this.markedPortals != null) this.markedPortals.close();
}
public void setState(State state) {
this.changeState(state, true);
}
/** Restore startup or changed schedule state without replaying past opening effects. */
void restoreState(State state) {
this.changeState(state, false);
}
private void changeState(State state, boolean announceOpening) {
if(this.state == state) return;
boolean wasClosed = this.isClosed();
this.state = state;
this.localConfig().set("state", state.name());
Configuration.saveChanges();
if(this.markedPortals != null) this.markedPortals.onStateChanged();
if(announceOpening && wasClosed && !this.isClosed()) this.openingEffects.playOpening();
}
public State getState() {
return this.state;
}
public boolean isClosed() {
return this.state == State.CLOSED;
}
DimensionOpeningEffects effects() {
return this.openingEffects;
}
MarkedNetherPortals markedPortals() {
return Objects.requireNonNull(this.markedPortals, "Nether portal registry is not enabled");
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new NetherAccessListener());
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new NetherCommand(), new NetherPreventCommand(), new NetherIgniterCommand());
}
}
@@ -0,0 +1,143 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.Bukkit;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.Action;
import org.bukkit.event.entity.EntityPortalEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerPortalEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.world.ChunkLoadEvent;
import org.bukkit.event.world.PortalCreateEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
class NetherAccessListener extends ApplianceListener<NetherAccess> {
private static final long MESSAGE_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3);
private final Map<UUID, Long> lastDeniedMessageAt = new HashMap<>();
private final Map<UUID, IgnitionAttempt> ignitionAttempts = new HashMap<>();
@EventHandler(priority = EventPriority.HIGHEST)
public void onSpecialItemUse(PlayerInteractEvent event) {
if(!NetherPortalIgniter.isSpecial(event.getItem()) || event.getPlayer().getGameMode() == GameMode.CREATIVE) return;
event.setCancelled(true);
this.ignitionAttempts.remove(event.getPlayer().getUniqueId());
if(!this.canSendMessage(event.getPlayer())) return;
event.getPlayer().sendMessage(DimensionScheduleText.message(
"Das Portalfeuerzeug kann nur im Kreativmodus verwendet werden.", DimensionScheduleText.NETHER_RED
));
}
@EventHandler(priority = EventPriority.MONITOR)
public void onIgnitionAttempt(PlayerInteractEvent event) {
if(event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
UUID playerId = event.getPlayer().getUniqueId();
// Record the hand actually used, including ordinary tools. Merely holding the special
// item in the other hand must never turn a normal ignition into a marked portal.
this.ignitionAttempts.remove(playerId);
if(event.useItemInHand() == Event.Result.DENY || event.useInteractedBlock() == Event.Result.DENY) return;
if(event.getClickedBlock() == null || event.getHand() == null || event.getItem() == null) return;
Material material = event.getItem().getType();
if(material != Material.FLINT_AND_STEEL && material != Material.FIRE_CHARGE) return;
Block ignition = event.getClickedBlock().getRelative(event.getBlockFace());
this.ignitionAttempts.put(playerId, new IgnitionAttempt(
ignition.getWorld().getUID(), event.getHand(), Bukkit.getCurrentTick(),
new MarkedNetherPortal.Position(ignition.getX(), ignition.getY(), ignition.getZ()),
NetherPortalIgniter.isSpecial(event.getItem())
));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPortalCreate(PortalCreateEvent event) {
boolean nether = event.getWorld().getEnvironment() == World.Environment.NETHER;
boolean blockedCreation = switch(event.getReason()) {
case FIRE -> !nether;
case NETHER_PAIR -> nether;
default -> false;
};
if(!blockedCreation || !this.getAppliance().isClosed()) return;
if(event.getReason() == PortalCreateEvent.CreateReason.FIRE && event.getEntity() instanceof Player player) {
IgnitionAttempt attempt = this.ignitionAttempts.remove(player.getUniqueId());
if(attempt != null && attempt.special() && player.getGameMode() == GameMode.CREATIVE
&& attempt.tick() == Bukkit.getCurrentTick() && attempt.worldId().equals(event.getWorld().getUID())
&& event.getBlocks().stream().anyMatch(block -> block.getType() == Material.NETHER_PORTAL
&& block.getX() == attempt.ignition().x() && block.getY() == attempt.ignition().y()
&& block.getZ() == attempt.ignition().z())) {
event.setCancelled(true);
this.getAppliance().markedPortals().mark(event, player, attempt.ignition());
return;
}
}
this.cancelIfClosed(event, event.getEntity());
}
@EventHandler
public void onChunkLoad(ChunkLoadEvent event) {
this.getAppliance().markedPortals().onChunkLoad(event.getChunk());
}
@EventHandler
public void onWorldLoad(WorldLoadEvent event) {
this.getAppliance().markedPortals().onWorldLoad(event.getWorld());
}
@EventHandler(ignoreCancelled = true)
public void onPlayerPortal(PlayerPortalEvent event) {
if(isNetherDestination(event.getTo())) this.cancelIfClosed(event, event.getPlayer());
}
@EventHandler(ignoreCancelled = true)
public void onEntityPortal(EntityPortalEvent event) {
if(isNetherDestination(event.getTo())) this.cancelIfClosed(event, event.getEntity());
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
this.lastDeniedMessageAt.remove(event.getPlayer().getUniqueId());
this.ignitionAttempts.remove(event.getPlayer().getUniqueId());
}
private static boolean isNetherDestination(@Nullable Location destination) {
return destination != null && destination.getWorld() != null
&& destination.getWorld().getEnvironment() == World.Environment.NETHER;
}
private void cancelIfClosed(Cancellable event, @Nullable Entity actor) {
if(!this.getAppliance().isClosed()) return;
event.setCancelled(true);
if(!(actor instanceof Player player)) return;
if(this.canSendMessage(player)) this.getAppliance().effects().denyActivation(player);
}
private boolean canSendMessage(Player player) {
// Cancellation always applies; message and sound share an elapsed-real-time cooldown.
long now = System.nanoTime();
Long previousMessageAt = this.lastDeniedMessageAt.get(player.getUniqueId());
if(previousMessageAt != null && now - previousMessageAt < MESSAGE_COOLDOWN_NANOS) return false;
this.lastDeniedMessageAt.put(player.getUniqueId(), now);
return true;
}
private record IgnitionAttempt(UUID worldId, EquipmentSlot hand, int tick,
MarkedNetherPortal.Position ignition, boolean special) {
}
}
@@ -0,0 +1,50 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/** Public information about Nether access and the configured opening date. */
class NetherCommand extends ApplianceCommand<NetherAccess> {
public NetherCommand() {
super("nether", DimensionScheduleText.message("Fehler: ", DimensionScheduleText.NETHER_RED));
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length != 0) throw new Error("Verwendung: /nether");
if(!this.getAppliance().isClosed()) {
sender.sendMessage(DimensionScheduleText.message("Der Nether ist bereits geöffnet.", DimensionScheduleText.NETHER_RED));
return;
}
Optional<Instant> openingAt = Main.instance().getAppliances().stream()
.filter(DimensionScheduler.class::isInstance)
.map(DimensionScheduler.class::cast)
.findFirst()
.flatMap(scheduler -> scheduler.getTime("nether-open-at"));
Instant now = Instant.now();
String message;
if(openingAt.isEmpty()) {
message = "Für den Nether ist noch kein Öffnungstermin festgelegt.";
} else if(!openingAt.get().isAfter(now)) {
message = "Der Öffnungstermin ist bereits erreicht. Der Nether ist derzeit noch geschlossen.";
} else {
message = "Der Nether öffnet in " + DimensionScheduleText.remaining(openingAt.get(), now) + ".";
}
sender.sendMessage(DimensionScheduleText.message(message, DimensionScheduleText.NETHER_RED));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return List.of();
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.GameMode;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.List;
class NetherIgniterCommand extends ApplianceCommand<NetherAccess> {
public NetherIgniterCommand() {
super("netherIgniter", DimensionScheduleText.message("Fehler: ", DimensionScheduleText.NETHER_RED));
}
@Override
public @NotNull String permission() {
return "spawn.command.netherigniter";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!(sender instanceof Player player)) throw new Error("Dieser Befehl kann nur von Spielern ausgeführt werden.");
if(!player.isOp()) throw new Error("Dieser Befehl ist nur für OPs verfügbar.");
if(player.getGameMode() != GameMode.CREATIVE) throw new Error("Das Portalfeuerzeug ist nur im Kreativmodus verfügbar.");
if(args.length != 0) throw new Error("Verwendung: /netherIgniter");
if(player.getInventory().firstEmpty() == -1) throw new Error("Dein Inventar ist voll. Schaffe einen freien Platz für das Portalfeuerzeug.");
if(!player.getInventory().addItem(NetherPortalIgniter.create()).isEmpty()) {
throw new Error("Das Portalfeuerzeug konnte nicht in dein Inventar gelegt werden.");
}
player.sendMessage(DimensionScheduleText.message("Du hast ein Portalfeuerzeug erhalten.", DimensionScheduleText.NETHER_RED));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return List.of();
}
}
@@ -0,0 +1,86 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.OpeningAnnouncements;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Bukkit;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Optional;
/** Interprets nether-open-at; NetherAccess creates and stops its single polling task. */
final class NetherOpeningSchedule {
private final NetherAccess netherAccess;
private final DimensionScheduler scheduler;
private final OpeningAnnouncements announcements;
private @Nullable BukkitTask task;
private Optional<Instant> previousOpenAt = Optional.empty();
private @Nullable NetherAccess.State previousScheduledState;
private boolean initialized;
private Instant startedAt = Instant.EPOCH;
NetherOpeningSchedule(NetherAccess netherAccess, DimensionScheduler scheduler, DimensionOpeningEffects effects) {
this.netherAccess = netherAccess;
this.scheduler = scheduler;
this.announcements = new OpeningAnnouncements(effects);
}
void start() {
this.stop();
this.startedAt = Instant.now();
// All appliances must finish loading before the first check, regardless of their order.
this.task = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::checkOpeningTime, 1L, 20L);
}
void stop() {
if(this.task == null) return;
this.task.cancel();
this.task = null;
}
private void checkOpeningTime() {
Optional<Instant> openingAt = this.scheduler.getTime("nether-open-at");
Instant now = Instant.now();
boolean restoreSchedule = !this.initialized || !openingAt.equals(this.previousOpenAt);
Instant restoreAt = this.scheduler.getLoadedAt();
if(!this.initialized && this.startedAt.isAfter(restoreAt)) restoreAt = this.startedAt;
this.announcements.prepare(openingAt, restoreAt);
this.previousOpenAt = openingAt;
this.initialized = true;
if(openingAt.isEmpty()) {
this.previousScheduledState = null;
return;
}
NetherAccess.State scheduledState = stateAt(now, openingAt.get());
NetherAccess.State restoredState = stateAt(restoreAt, openingAt.get());
boolean crossedAfterLoad = restoreSchedule && restoredState != scheduledState;
if(crossedAfterLoad) {
// A boundary crossed since loading is a live transition, not a missed opening.
this.netherAccess.restoreState(restoredState);
}
// Manual changes remain effective until the next scheduled transition or changed date.
if(restoreSchedule || scheduledState != this.previousScheduledState) {
if(this.netherAccess.getState() != scheduledState) {
if(restoreSchedule && !crossedAfterLoad) {
this.netherAccess.restoreState(scheduledState);
} else {
this.netherAccess.setState(scheduledState);
}
Main.logger().info("Nether opening schedule applied state " + scheduledState
+ " (nether-open-at=" + openingAt.get() + ")");
}
this.previousScheduledState = scheduledState;
}
this.announcements.announceDue(now, this.netherAccess.isClosed());
}
private static NetherAccess.State stateAt(Instant at, Instant openingAt) {
return at.isBefore(openingAt) ? NetherAccess.State.CLOSED : NetherAccess.State.OPEN;
}
}
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more