diff --git a/build.gradle b/build.gradle index 793e462..e434e24 100644 --- a/build.gradle +++ b/build.gradle @@ -152,7 +152,7 @@ tasks.register("generateExtras") { tasks.named("build") { dependsOn ":test" - def jarPath = Paths.get("${jar.archivePath}") + def jarPath = jar.archiveFile.get().asFile.toPath() doLast { Files.createDirectories(workspacePath.resolve(".bin")) Files.copy( diff --git a/src/main/kotlin/mindurka/api/Gamemode.kt b/src/main/kotlin/mindurka/api/Gamemode.kt index ef31eb8..a4c331c 100644 --- a/src/main/kotlin/mindurka/api/Gamemode.kt +++ b/src/main/kotlin/mindurka/api/Gamemode.kt @@ -311,6 +311,24 @@ object Gamemode { @JvmField var defaultPatch: Prov? = null + /** + * Rules tags that have to reach the client, as exact keys or `prefix.*` patterns. Everything + * else stays server-side. + * + * The whole ruleset travels as one packet with a hard size limit, so it cannot carry + * everything a map puts in `Rules.tags`. Nothing in mindustry/core reads `Rules.tags` on a + * client, and gamemode tables like `mdrk.castle.*` are read by the server + * (CastleUtils.applyRules), so the default is the markers the compat client looks at. + * A tag listed here that does not fit is reported by ModifyWorld.syncRules as an error. + */ + @JvmField + var syncedTags: Seq = Seq.with( + SpecialSettings.FORMAT, + SpecialSettings.GAMEMODE, + SpecialSettings.GAMEMODE_LEGACY, + SpecialSettings.PATCH, + ) + @JvmField var bannedTools: java.util.EnumSet = java.util.EnumSet.noneOf(mindurka.coreplugin.SSTool::class.java) diff --git a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt index 64f336d..4348173 100644 --- a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt +++ b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt @@ -62,6 +62,7 @@ import mindustry.gen.ConnectCallPacket import mindustry.gen.Groups import mindustry.gen.Player import mindustry.gen.SetTileCallPacket +import mindustry.mod.data.PatchAsset import mindustry.net.Administration import mindustry.world.Block import mindustry.world.blocks.environment.StaticWall @@ -143,6 +144,8 @@ object CorePlugin { } private var fakeBlockKind: Block? = null + /** The patch this plugin prepends on every world load, kept so it can be removed by identity. */ + private var defaultPatchAsset: PatchAsset? = null private class FakeBlock( var x: Int, var y: Int, @@ -177,9 +180,11 @@ object CorePlugin { on(priority = Priority.Low) { fakeBlockPos.clear() - if (Vars.state.patcher.patches.size > 0 && Vars.state.patcher.patches[0].name == "Mindurka Default Patch") { - Vars.state.patcher.patches.remove(0) - } + // By identity, not by name: PatchAsset.name is filled by DataPatcher.apply from the + // patch's own "name" key and reset to "" when a patch fails to apply, so matching on + // the name lets a single broken patch make us prepend a new copy every world load. + defaultPatchAsset?.let { Vars.state.data.patches.remove(it, true) } + defaultPatchAsset = null fakeBlockKind = run { for (shift in 0..min(Vars.world.width(), Vars.world.height()) / 2) { @@ -252,7 +257,7 @@ object CorePlugin { } } - Vars.state.patcher.apply(Vars.state.patcher.patches.map { it.patch }.apply { insert(0, run { + val asset = PatchAsset(run { val patch = StringBuilder() patch.append("name: Mindurka Default Patch\n") @@ -286,7 +291,9 @@ object CorePlugin { debug{"$patch"} patch.toString() - }) }) + }) + defaultPatchAsset = asset + Vars.state.data.reloadPatches(Vars.state.data.patches.copy().apply { insert(0, asset) }) } Vars.netServer.admins.addActionFilter { act -> if (!(act.type == Administration.ActionType.breakBlock && act.block == fakeBlockKind) || fakeBlockKind == null) return@addActionFilter true @@ -497,7 +504,7 @@ object CorePlugin { )) if (restarting) Tl.send(it.player).done("{generic.restart-scheduled}") - timer(0.5f) { Call.setRules(it.player.con, Vars.state.rules) } + timer(0.5f) { it.player.con?.let(ModifyWorld::syncRules) } } on { diff --git a/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt b/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt index 28c8bb4..158af81 100644 --- a/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt +++ b/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt @@ -39,7 +39,7 @@ fun setupTerminalInput() { } try { - val terminal = TerminalBuilder.builder().jna(true).system(true).dumb(true).build() + val terminal = TerminalBuilder.builder().system(true).dumb(true).build() val reader = LineReaderBuilder.builder().terminal(terminal).build() terminal.enterRawMode() diff --git a/src/main/kotlin/mindurka/util/ModifyWorld.kt b/src/main/kotlin/mindurka/util/ModifyWorld.kt index 06b9762..8f811ff 100644 --- a/src/main/kotlin/mindurka/util/ModifyWorld.kt +++ b/src/main/kotlin/mindurka/util/ModifyWorld.kt @@ -1,8 +1,13 @@ package mindurka.util +import arc.struct.Seq +import arc.struct.StringMap +import arc.util.Log +import mindurka.api.Gamemode import arc.util.io.Writes import mindurka.api.Consts import mindustry.Vars +import mindustry.io.JsonIO import mindustry.content.Blocks import mindustry.core.NetServer import mindustry.game.Team @@ -18,6 +23,145 @@ import mindustry.world.Tile import mindustry.world.blocks.environment.Floor object ModifyWorld { + /** Mirrors the private NetServer.maxSnapshotSize. */ + private const val maxSnapshotSize = 800 + + /** arcnet serializes one object into a 16384-byte buffer (ArcNetProvider: new Server(..., 16384, ...)). */ + private const val rulesPacketLimit = 15_000 + + /** + * Send the ruleset to one connection, or to everyone when [con] is null. Always use this + * instead of Call.setRules. + * + * Rules go as one JSON blob in one packet, and overflowing arcnet's object buffer makes arcnet + * drop the connection rather than report an error. Only the tags the gamemode declares in + * [Gamemode.syncedTags] are sent; the rest never leaves the server. Declared tags are packed + * smallest-first, and anything declared that still does not fit is an error, not a warning -- + * the gamemode said it was needed. + */ + @JvmStatic + @JvmOverloads + fun syncRules(con: NetConnection? = null) { + // The live ruleset is borrowed and put back rather than Rules.copy()ied: copy() is a JSON + // write plus a parse of the whole 7-22 kB ruleset ("Not efficient at all, do not use + // often" -- Rules.java), and Call.setRules serializes on this thread before it returns + // (Connection.sendTCP -> TcpConnection.send -> serialization.write). + val rules = Vars.state.rules + val tags = rules.tags + rules.tags = StringMap() + try { + // TypeIO.writeRules sends JsonIO.write(rules).getBytes(UTF-8), so the budget is bytes, + // not String.length -- one Cyrillic character costs two of them. + val bare = utf8Size(JsonIO.write(rules)) + Log.debug("Ruleset @ B without tags, @ tag(s), limit @ B", bare, tags.size, rulesPacketLimit) + if (bare > rulesPacketLimit) { + Log.err("Ruleset is @ B even without tags, over the @ B packet limit. Not syncing it: sending it would drop every client.", + bare, rulesPacketLimit) + return + } + + var budget = rulesPacketLimit - bare + val wanted = Gamemode.syncedTags + val entries = ArrayList>(wanted.size) + // + 8 for quotes, colon and separator + tags.each { key, value -> + if (tagWanted(key, wanted)) entries.add(Triple(key, value, utf8Size(key) + utf8Size(value) + 8)) + } + Log.debug("@ of @ tag(s) declared in Gamemode.syncedTags", entries.size, tags.size) + entries.sortBy { it.third } + + val dropped = ArrayList() + for ((key, value, cost) in entries) { + if (cost <= budget) { + budget -= cost + rules.tags.put(key, value) + } else { + dropped.add(key) + } + } + + if (dropped.isNotEmpty()) { + Log.err("Ruleset is @ B without tags, leaving @ B; @ declared tag(s) do not fit and are NOT synced: @", + bare, rulesPacketLimit - bare, dropped.size, dropped.joinToString(", ")) + } + + if (con == null) Call.setRules(rules) else Call.setRules(con, rules) + } finally { + rules.tags = tags + } + } + + /** Whether [key] is listed in [patterns], either exactly or by a trailing `*` prefix. */ + private fun tagWanted(key: String, patterns: Seq): Boolean { + for (i in 0 until patterns.size) { + val p = patterns.get(i) + if (p.endsWith("*")) { + if (key.length >= p.length - 1 && key.regionMatches(0, p, 0, p.length - 1)) return true + } else if (p == key) return true + } + return false + } + + /** UTF-8 length of a string, without encoding it into a throwaway array. */ + private fun utf8Size(s: String): Int { + var size = 0 + var i = 0 + while (i < s.length) { + val c = s[i].code + size += when { + c < 0x80 -> 1 + c < 0x800 -> 2 + c in 0xD800..0xDBFF && i + 1 < s.length && s[i + 1].code in 0xDC00..0xDFFF -> { i++; 4 } + else -> 3 + } + i++ + } + return size + } + + /** + * Synchronize many buildings to one connection, batched like vanilla's own block snapshot + * loop. Use this instead of calling [syncBuild] in a loop: blockSnapshot is unreliable, and a + * packet per building floods the UDP write buffer. + * + * Pass the smallest set that answers the question. NetServer.writeBlockSnapshots only ever + * walks blocks flagged BlockFlag.synced; handing this the whole Groups.build is thousands of + * buildings and hundreds of packets in one frame. + */ + @JvmStatic + fun syncBuilds(con: NetConnection, builds: Iterable) { + val prevSyncTarget = NetServer.mdSyncTarget + NetServer.mdSyncTarget = con.player + try { + val writes = Writes(Consts.dataStream) + Consts.syncStream.reset() + var sent = 0 + + for (build in builds) { + Consts.dataStream.writeInt(build.pos()) + Consts.dataStream.writeShort(build.block.id.toInt()) + build.writeAll(writes) + sent++ + + if (Consts.syncStream.size() > maxSnapshotSize) { + Consts.dataStream.close() + Call.blockSnapshot(con, sent.toShort(), Consts.syncStream.toByteArray()) + sent = 0 + Consts.syncStream.reset() + } + } + + if (sent > 0) { + Consts.dataStream.close() + Call.blockSnapshot(con, sent.toShort(), Consts.syncStream.toByteArray()) + } + } catch (e: Exception) { + Log.err("Failed to sync buildings", e) + } finally { + NetServer.mdSyncTarget = prevSyncTarget + } + } + /** * Synchronize a building over the network. */ @@ -31,9 +175,12 @@ object ModifyWorld { Consts.dataStream.writeShort(build.block.id.toInt()) build.writeAll(Writes(Consts.dataStream)) Consts.dataStream.close() - val bytes = Consts.syncStream.bytes + // toByteArray(), not .bytes: getBytes() returns the buffer, sized by capacity. + val bytes = Consts.syncStream.toByteArray() Call.blockSnapshot(con, 1, bytes) - } catch (_: Exception) {} finally { + } catch (e: Exception) { + Log.err("Failed to sync building at ${build.tile}", e) + } finally { NetServer.mdSyncTarget = prevSyncTarget } } @@ -47,7 +194,7 @@ object ModifyWorld { Consts.dataStream.writeShort(build.block.id.toInt()) build.writeAll(Writes(Consts.dataStream)) Consts.dataStream.close() - val bytes = Consts.syncStream.bytes + val bytes = Consts.syncStream.toByteArray() Call.blockSnapshot(1, bytes) }