Skip to content

Commit e7874e1

Browse files
Add HYTL plugin core, config, heartbeat, and vote logic
Introduces the HYTL plugin core implementation, including configuration management, heartbeat service, vote polling and processing, and command registration. Adds support for exponential backoff, HTTP client setup with serialization, online player tracking, processed vote persistence, and a default vote rewarder. Also provides a public API for external plugins to hook into vote rewards, and implements setup, reload, and status commands for plugin management. Updates build scripts to include required dependencies and improves clean scripts to not error on missing directories.
1 parent 67c3121 commit e7874e1

18 files changed

Lines changed: 885 additions & 6 deletions

build.gradle.kts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import java.util.TimeZone
77
plugins {
88
val kotlin_version: String by System.getProperties()
99
kotlin("jvm").version(kotlin_version)
10+
kotlin("plugin.serialization").version(kotlin_version)
1011

1112
id("com.gradleup.shadow") version "8.3.6"
1213
}
@@ -16,7 +17,7 @@ val ktor_version: String by project
1617

1718
group = "cc.modlabs"
1819
version = System.getenv("VERSION_OVERRIDE") ?: Calendar.getInstance(TimeZone.getTimeZone("UTC")).run {
19-
"${get(Calendar.YEAR)}.${get(Calendar.MONTH) + 1}.${get(Calendar.DAY_OF_MONTH)}.${
20+
"${get(Calendar.YEAR)}.${get(Calendar.MONTH) + 1}.${get(Calendar.DAY_OF_MONTH)}-${
2021
String.format("%02d%02d", get(Calendar.HOUR_OF_DAY), get(Calendar.MINUTE))
2122
}"
2223
}
@@ -29,7 +30,9 @@ val shadowDependencies = listOf(
2930
"org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version",
3031
"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version",
3132
"io.ktor:ktor-client-core:$ktor_version",
32-
"io.ktor:ktor-client-cio:$ktor_version"
33+
"io.ktor:ktor-client-cio:$ktor_version",
34+
"io.ktor:ktor-client-content-negotiation:$ktor_version",
35+
"io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
3336
//"cc.modlabs:ktale:${project.ext["ktaleVersion"] as String}",
3437
//"cc.modlabs:KlassicX:2025.12.4.1928"
3538
)

cleanlibs.bat

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ set "TARGET_DIR=.\build\libs"
55
REM Check if the directory exists
66
if not exist "%TARGET_DIR%" (
77
echo The directory %TARGET_DIR% does not exist.
8-
exit /b 1
8+
exit /b 0
99
)
1010

1111
REM Delete all .jar files in the directory

cleanlibs.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ TARGET_DIR="./build/libs"
66
# Check if the directory exists
77
if [ ! -d "$TARGET_DIR" ]; then
88
echo "The directory $TARGET_DIR does not exist."
9-
exit 1
9+
exit 0
1010
fi
1111

1212
# Delete all .jar files in the directory
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package cc.modlabs.api;
2+
3+
@FunctionalInterface
4+
public interface VoteRewarder {
5+
/**
6+
* @return true if the reward was granted successfully and it is safe to ACK the vote.
7+
*/
8+
boolean reward(PendingVote vote);
9+
}
10+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package cc.modlabs
2+
3+
import kotlin.math.min
4+
import kotlin.random.Random
5+
6+
class ExponentialBackoff(
7+
private val baseSeconds: Long = 1,
8+
private val maxSeconds: Long = 60,
9+
private val jitterRatio: Double = 0.2
10+
) {
11+
private var failures: Int = 0
12+
13+
fun reset() {
14+
failures = 0
15+
}
16+
17+
fun nextDelaySeconds(): Long {
18+
failures = min(failures + 1, 30) // avoid overflow
19+
val exp = 1L shl min(failures - 1, 20)
20+
val raw = min(maxSeconds, baseSeconds * exp)
21+
22+
val jitter = (raw * jitterRatio).toLong().coerceAtLeast(0)
23+
val delta = if (jitter == 0L) 0L else Random.nextLong(-jitter, jitter + 1)
24+
return (raw + delta).coerceIn(1, maxSeconds)
25+
}
26+
}
27+
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package cc.modlabs
2+
3+
import cc.modlabs.api.PendingVote
4+
import cc.modlabs.api.VoteRewarder
5+
import com.hypixel.hytale.logger.HytaleLogger
6+
7+
class DefaultVoteRewarder(
8+
private val cfg: HytlPluginConfig,
9+
private val logger: HytaleLogger
10+
) : VoteRewarder {
11+
override fun reward(vote: PendingVote): Boolean {
12+
val mode = cfg.voteRewards.mode.uppercase()
13+
if (mode == "DISABLED") return false
14+
15+
val tier = computeTier(vote.streak)
16+
val multiplier = cfg.voteRewards.tierMultipliers.getOrNull(tier - 1) ?: 1
17+
val amount = cfg.voteRewards.baseAmount * multiplier
18+
19+
val who = vote.playerUuid ?: vote.playerName ?: "<unknown>"
20+
logger.info("Rewarding voteId=${vote.voteId} player=$who streak=${vote.streak} tier=$tier amount=$amount (default LOG_ONLY)")
21+
22+
// Default implementation is intentionally "log only" because reward systems are server-specific.
23+
// External plugins can hook in via HytlDevApi.setVoteRewarder(...) to grant actual items/currency/etc.
24+
return true
25+
}
26+
27+
private fun computeTier(streak: Int): Int {
28+
val thresholds = cfg.voteRewards.tierStreakThresholds
29+
.map { it.coerceAtLeast(1) }
30+
.sorted()
31+
.distinct()
32+
.ifEmpty { listOf(1) }
33+
34+
var tier = 1
35+
for (t in thresholds) {
36+
if (streak >= t) tier++
37+
}
38+
// If thresholds=[1,3,7,14,30], tiers become 1..5. Clamp to multipliers length if provided.
39+
val maxTier = cfg.voteRewards.tierMultipliers.size.takeIf { it > 0 } ?: 1
40+
return (tier - 1).coerceIn(1, maxTier)
41+
}
42+
}
43+
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package cc.modlabs
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class HeartbeatPayload(
7+
val serverId: String,
8+
val ts: String,
9+
val game: GameInfo,
10+
val universe: UniverseInfo? = null,
11+
val players: List<PlayerInfo>? = null,
12+
val mods: ModsInfo? = null
13+
)
14+
15+
@Serializable
16+
data class GameInfo(
17+
val host: String? = null,
18+
val port: Int? = null,
19+
val version: String? = null,
20+
val patchline: String? = null,
21+
val maxPlayers: Int? = null,
22+
val motd: List<String>? = null
23+
)
24+
25+
@Serializable
26+
data class UniverseInfo(
27+
val currentPlayers: Int? = null,
28+
val defaultWorld: String? = null
29+
)
30+
31+
@Serializable
32+
data class PlayerInfo(
33+
val name: String,
34+
val uuid: String? = null,
35+
val world: String? = null
36+
)
37+
38+
@Serializable
39+
data class ModsInfo(
40+
val enabled: Boolean,
41+
val list: List<ModEntry>? = null
42+
)
43+
44+
@Serializable
45+
data class ModEntry(
46+
val id: String,
47+
val version: String? = null,
48+
val state: String? = null
49+
)
50+
51+
@Serializable
52+
data class PendingVotesResponse(
53+
val serverId: String,
54+
val events: List<VoteEvent> = emptyList()
55+
)
56+
57+
@Serializable
58+
data class VoteEvent(
59+
val voteId: String,
60+
val createdAt: String? = null,
61+
val playerName: String? = null,
62+
val playerUuid: String? = null,
63+
val streak: Int? = null
64+
)
65+
66+
@Serializable
67+
data class VoteAckRequest(
68+
val serverId: String,
69+
val voteId: String
70+
)
71+
72+
@Serializable
73+
data class VoteAckResponse(
74+
val ok: Boolean = false,
75+
val acked: Boolean = false
76+
)
77+
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cc.modlabs
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
import kotlinx.serialization.json.Json
6+
import java.nio.file.Files
7+
import java.nio.file.Path
8+
9+
@Serializable
10+
data class HytlPluginConfig(
11+
val serverId: String = "",
12+
val serverSecret: String = "",
13+
val backendBaseUrl: String = "https://hytl.dev/api/plugin",
14+
val heartbeatSeconds: Int = 10,
15+
val votePollSeconds: Int = 15,
16+
val privacy: PrivacyConfig = PrivacyConfig(),
17+
val voteRewards: VoteRewardsConfig = VoteRewardsConfig(),
18+
val httpTimeoutMs: Long = 3_000,
19+
val maxBackoffSeconds: Int = 60,
20+
// Optional convenience: override the public host/port shown in HYTL.
21+
val publicHost: String? = null,
22+
val publicPort: Int? = null,
23+
// Optional convenience: override server version/patchline reported to HYTL.
24+
val gameVersion: String? = null,
25+
val patchline: String? = null
26+
)
27+
28+
@Serializable
29+
data class PrivacyConfig(
30+
val shareMods: Boolean = true,
31+
val sharePlayerCount: Boolean = true,
32+
val sharePlayerNames: Boolean = true,
33+
val sharePlayerUUIDs: Boolean = false
34+
)
35+
36+
@Serializable
37+
data class VoteRewardsConfig(
38+
/**
39+
* LOG_ONLY: logs what would be rewarded and ACKs votes (default).
40+
* DISABLED: does not reward and does not ACK (votes stay pending).
41+
*/
42+
val mode: String = "LOG_ONLY",
43+
val baseAmount: Int = 1,
44+
/**
45+
* Tier thresholds (inclusive) by streak. Example default tiers:
46+
* 1 -> tier 1, 3 -> tier 2, 7 -> tier 3, 14 -> tier 4, 30 -> tier 5.
47+
*/
48+
val tierStreakThresholds: List<Int> = listOf(1, 3, 7, 14, 30),
49+
/**
50+
* Amount multipliers per tier index (1-based). Default: [1,2,3,4,5].
51+
*/
52+
val tierMultipliers: List<Int> = listOf(1, 2, 3, 4, 5)
53+
)
54+
55+
object HytlConfigIO {
56+
private val json = Json {
57+
prettyPrint = true
58+
encodeDefaults = true
59+
ignoreUnknownKeys = true
60+
explicitNulls = false
61+
}
62+
63+
const val CONFIG_FILE_NAME: String = "config.json"
64+
65+
fun configPath(dataDir: Path): Path = dataDir.resolve(CONFIG_FILE_NAME)
66+
67+
/**
68+
* Loads config from the plugin data directory. If missing, writes a starter file and returns defaults.
69+
*/
70+
fun loadOrCreate(dataDir: Path): HytlPluginConfig {
71+
Files.createDirectories(dataDir)
72+
val path = configPath(dataDir)
73+
if (!Files.exists(path)) {
74+
val starter = HytlPluginConfig()
75+
Files.writeString(path, json.encodeToString(HytlPluginConfig.serializer(), starter))
76+
return starter
77+
}
78+
val raw = Files.readString(path)
79+
return json.decodeFromString(HytlPluginConfig.serializer(), raw)
80+
}
81+
82+
fun save(dataDir: Path, cfg: HytlPluginConfig) {
83+
Files.createDirectories(dataDir)
84+
val path = configPath(dataDir)
85+
Files.writeString(path, json.encodeToString(HytlPluginConfig.serializer(), cfg))
86+
}
87+
}
88+

0 commit comments

Comments
 (0)