first init
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
dist/
|
||||
@@ -0,0 +1,22 @@
|
||||
# Escape from Funcraft — client beta
|
||||
|
||||
This repository contains the reproducible client-packaging files for the beta.
|
||||
The playable client is distributed as a Gitea Release asset, not as a Git commit.
|
||||
|
||||
## Install in Prism Launcher
|
||||
|
||||
1. In Prism Launcher, choose **Add Instance** → **Import**.
|
||||
2. Paste the release-asset URL below.
|
||||
3. Start the imported instance and use the beta server address supplied by the team.
|
||||
|
||||
For `beta.1`, use:
|
||||
|
||||
`https://gitea.projectshitpost.fun/funcraft/escape-from-funcraft-client/releases/download/beta.1/escape-from-funcraft-client-beta.1.zip`
|
||||
|
||||
The ZIP is a Prism Launcher instance. It includes the required client mods, shared pack settings, and only key bindings from `options.txt`. It deliberately does not include the world, server files, player screenshots/logs, shader selection, graphics/audio/FOV settings, or server credentials.
|
||||
|
||||
## Beta notes
|
||||
|
||||
- Minecraft: 1.20.1
|
||||
- Forge: 47.4.10
|
||||
- The server distribution is private and is not part of this repository.
|
||||
@@ -0,0 +1,14 @@
|
||||
+# Beta mod inventory
|
||||
|
||||
Active client set: **136** JARs. The manifest is the authoritative machine-readable mapping used by the builders.
|
||||
|
||||
| Side | Count | Audit basis |
|
||||
| --- | ---: | --- |
|
||||
| client | 48 | client HUD/render/input/UI mods and their loader/config dependencies |
|
||||
| server | 16 | permissions, protection, profiling, Discord integration, pre-generation and server tools |
|
||||
| both | 72 | gameplay content, registries, entities, TACZ, shared libraries and network features |
|
||||
|
||||
The old `server-staging` was not used as an input. It contains 119 JARs while the active client contains 136; its list served only as a historical comparison.
|
||||
|
||||
Before a public release, the server build must be started once and any side error is corrected in `packaging/mod-classification.toml` before repacking.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[release]
|
||||
version = "beta.1"
|
||||
client_archive = "escape-from-funcraft-client-beta.1.zip"
|
||||
server_archive = "escape-from-funcraft-server-beta.1.tar.gz"
|
||||
|
||||
[source]
|
||||
world = "minecraft/saves/§2MC Zombie Apocalypse §f- §4Update #17.1"
|
||||
|
||||
[gitea]
|
||||
base_url = "https://gitea.projectshitpost.fun"
|
||||
client_repository = "funcraft/escape-from-funcraft-client"
|
||||
server_repository = "funcraft/escape-from-funcraft-server"
|
||||
|
||||
[client]
|
||||
excluded_roots = ["saves", "logs", "crash-reports", "screenshots", "mods-disabled", "shaderpacks"]
|
||||
excluded_files = ["servers.dat", "optionsof.txt", "optionsshaders.txt"]
|
||||
@@ -0,0 +1,137 @@
|
||||
[classification]
|
||||
"AmbientSounds_FORGE_v6.3.8_mc1.20.1.jar" = "client"
|
||||
"Bookshelf-Forge-1.20.1-20.2.13.jar" = "client"
|
||||
"Camerapture-1.10.8+mc1.20.1-forge.jar" = "client"
|
||||
"Chunky-1.3.146.jar" = "server"
|
||||
"Clumps-forge-1.20.1-12.0.0.4.jar" = "both"
|
||||
"Connector-1.0.0-beta.48+1.20.1.jar" = "client"
|
||||
"CraftTweaker-forge-1.20.1-14.0.57.jar" = "both"
|
||||
"CreativeCore_FORGE_v2.12.39_mc1.20.1.jar" = "both"
|
||||
"DeltaForce Melee Pack-0.2.0.jar" = "both"
|
||||
"DistantHorizons-3.2.0-b-1.20.1-fabric-forge.jar" = "client"
|
||||
"Elite X Quality Guns Forge v5.1 - 1.20.1.jar" = "both"
|
||||
"EliteHolograms-1.20.1-1.0.7.jar" = "both"
|
||||
"EmoteTweaks-4.0.2.27-forge.jar" = "client"
|
||||
"EnhancedVisuals_FORGE_v1.8.2_mc1.20.1.jar" = "client"
|
||||
"FarmersDelight-1.20.1-1.2.9.jar" = "both"
|
||||
"Kiwi-1.20.1-Forge-11.9.2.jar" = "both"
|
||||
"Loot Journal-forge-1.20.1-5.0.1.jar" = "client"
|
||||
"LuckPerms-Forge-5.4.102.jar" = "server"
|
||||
"MouseTweaks-forge-mc1.20.1-2.25.1.jar" = "client"
|
||||
"Necronomicon-Forge-1.6.0+1.20.1.jar" = "both"
|
||||
"NoChatReports-FORGE-1.20.1-v2.2.2.jar" = "client"
|
||||
"SmartBrainLib-forge-1.20.1-1.15.jar" = "both"
|
||||
"TES-forge-1.20.1-1.6.1.jar" = "both"
|
||||
"Tips-Forge-1.20.1-12.1.8.jar" = "client"
|
||||
"WeaponsOfMiracles-2.0.164-mc1.20.1-forge.jar" = "both"
|
||||
"WorldEditCUI-1.20+01.jar" = "client"
|
||||
"XeKr's Decoration-1.20.1-Forge-0.9.3.jar" = "both"
|
||||
"YungsApi-1.20-Forge-4.0.6.jar" = "both"
|
||||
"[CS] Library-4.4.6-1.20.1.jar" = "both"
|
||||
"[CS] Roleplay Decor-2.4.2-1.20.1.jar" = "both"
|
||||
"[TACZ] Supressing-1.0.jar" = "both"
|
||||
"amendments-1.20-2.2.3.jar" = "both"
|
||||
"apocalypsenow-3.0.5-forge-1.20.1.jar" = "both"
|
||||
"apocalypticarsenal-1.0.1.jar" = "both"
|
||||
"appleskin-forge-mc1.20.1-2.5.1.jar" = "client"
|
||||
"architectury-9.2.14-forge.jar" = "both"
|
||||
"arctics_essentials-1.0.9.jar" = "server"
|
||||
"autohide-hotbar-forge-1.0.0+mc1.20.1.jar" = "client"
|
||||
"auudio_forge_1.0.3_MC_1.19.3.jar" = "client"
|
||||
"banksystem-forge-1.4.1.jar" = "both"
|
||||
"cc-recrafted-forge-1.20.1.0.0.3.jar" = "both"
|
||||
"cc-tweaked-1.20.1-forge-1.116.2.jar" = "both"
|
||||
"chamber_clarity-4.0.0-1.20.1.jar" = "both"
|
||||
"chat_heads-0.15.2-forge-1.20.jar" = "client"
|
||||
"cloth-config-11.1.136-forge.jar" = "client"
|
||||
"collective-1.20.1-8.13.jar" = "both"
|
||||
"configured-fabric-1.20.1-2.2.3.jar" = "client"
|
||||
"configured-forge-1.20.1-2.2.3.jar" = "client"
|
||||
"constructionwand-1.20.1-2.11.jar" = "both"
|
||||
"continuity-3.0.0+1.20.1.forge.jar" = "client"
|
||||
"create-1.20.1-6.0.8.jar" = "both"
|
||||
"createimmersivetacz-1.4-1.20.1.jar" = "both"
|
||||
"createtaczauto-1.4.8-forge-1.20.1.jar" = "both"
|
||||
"curios-forge-5.14.1+1.20.1.jar" = "both"
|
||||
"daffas_arsenal-3.5.0.jar" = "both"
|
||||
"dcintegration-forge-3.0.7.1-1.20.1.jar" = "server"
|
||||
"decorative_blocks-forge-1.20.1-4.1.3.jar" = "both"
|
||||
"discord_chat_mod-forge-1.20.1-2.7.0.jar" = "server"
|
||||
"doomsday_decoration-1.1.3-forge-1.20.1.jar" = "both"
|
||||
"dyairdrop-1.0.0-1.20.1-hotfix3.0.jar" = "server"
|
||||
"dynamic-fps-3.11.4+minecraft-1.20.0-forge.jar" = "client"
|
||||
"easy_npc_bundle-6.0.21-forge-1.20.1.jar" = "both"
|
||||
"embeddium-0.3.31+mc1.20.1.jar" = "client"
|
||||
"emotecraft-for-MC1.20.1-2.2.7-b.build.50-forge.jar" = "client"
|
||||
"entity_model_features-3.2.4-1.20.1-forge.jar" = "client"
|
||||
"entity_texture_features_1.20.1-forge-7.1.jar" = "client"
|
||||
"entityculling-forge-1.9.3-mc1.20.1.jar" = "client"
|
||||
"epic-fight-20.14.17-mc1.20.1-forge.jar" = "both"
|
||||
"epictweaks-forge-1.20.1-1.1.1.jar" = "client"
|
||||
"escape_from_minecov-1.4.0-forge-1.20.1.jar" = "both"
|
||||
"fabric-api-0.92.6+1.11.14+1.20.1.jar" = "both"
|
||||
"fancymenu_forge_3.8.0_MC_1.20.1.jar" = "client"
|
||||
"ferritecore-6.0.1-forge.jar" = "both"
|
||||
"firstaid-1.20.1-1.1.jar" = "both"
|
||||
"foxapi-1.0.0-remaster.jar" = "client"
|
||||
"framework-forge-1.20.1-0.7.15.jar" = "both"
|
||||
"ftb-library-forge-2001.2.12.jar" = "server"
|
||||
"ftb-ranks-forge-2001.1.7.jar" = "server"
|
||||
"geckolib-forge-1.20.1-4.8.2.jar" = "both"
|
||||
"grieflogger-1.2.4-1.20.1-forge.jar" = "server"
|
||||
"guccivuitton-1.20.1-0.2.2.jar" = "both"
|
||||
"gundb-2.1.0-all.jar" = "both"
|
||||
"immersive_melodies-0.6.0+1.20.1-forge.jar" = "client"
|
||||
"immersive_weathering-1.20.1-2.0.5-forge.jar" = "both"
|
||||
"jei-1.20.1-forge-15.20.0.125.jar" = "client"
|
||||
"konkrete_forge_1.8.0_MC_1.20-1.20.1.jar" = "client"
|
||||
"kotlinforforge-4.11.0-all.jar" = "client"
|
||||
"leawind_third_person-v2.2.0-mc1.20-1.20.1-forge.jar" = "client"
|
||||
"lootr-forge-1.20-0.7.35.94.jar" = "both"
|
||||
"lradd-1.20.1-0.3.0.jar" = "both"
|
||||
"lrarmor-1.20.1-0.1.4.4.jar" = "both"
|
||||
"lrtactical-1.20.1-0.3.0.jar" = "both"
|
||||
"melody_forge_1.0.3_MC_1.20.1-1.20.4.jar" = "both"
|
||||
"memoryleakfix-forge-1.17+-1.1.5.jar" = "client"
|
||||
"modelfix-1.15.jar" = "client"
|
||||
"modernfix-forge-5.27.51+mc1.20.1.jar" = "both"
|
||||
"moonlight-1.20-2.16.16-forge.jar" = "both"
|
||||
"mrfegs_coffee_box-1.0.0-forge-1.20.1.jar" = "both"
|
||||
"nef-1.2.0-forge-1.20.1.jar" = "both"
|
||||
"notenoughanimations-forge-1.12.4-mc1.20.1.jar" = "client"
|
||||
"oculus-flywheel-compat-Forge-2.0.3.jar" = "client"
|
||||
"oculus-mc1.20.1-1.8.0.jar" = "client"
|
||||
"opacbonusclaims-1.2.0-1.20.1.jar" = "server"
|
||||
"opacfixes-1.0.jar" = "server"
|
||||
"opacpvp-forge-1.0.1-1.20.1.jar" = "both"
|
||||
"open-parties-and-claims-forge-1.20.1-0.27.6.jar" = "both"
|
||||
"panoramica_forge_1.2.1_MC_1.19-1.19.2.jar" = "client"
|
||||
"player-animation-lib-forge-1.0.2-rc1+1.20.jar" = "both"
|
||||
"prma-1.20.1-0.4.4-cr6.0-SIMPLE-beta-all.jar" = "client"
|
||||
"props_mod-1.4.1-forge-1.20.1.jar" = "both"
|
||||
"refurbished_furniture-forge-1.20.1-1.0.14.jar" = "both"
|
||||
"rubidium-extra-0.5.4.4+mc1.20.1-build.131.jar" = "client"
|
||||
"skinlayers3d-forge-1.11.2-mc1.20.1.jar" = "client"
|
||||
"skinrestorer-2.8.1+1.20-forge.jar" = "both"
|
||||
"sodiumoptionsapi-forge-1.0.10-1.20.1.jar" = "client"
|
||||
"spark-1.10.53-forge.jar" = "server"
|
||||
"stockmarket-forge-1.3.1.jar" = "both"
|
||||
"supermartijn642configlib-1.1.8-forge-mc1.20.jar" = "both"
|
||||
"supplementaries-1.20-3.1.41.jar" = "both"
|
||||
"survival_instinct-1.0.1-forge-1.20.1.jar" = "both"
|
||||
"tacz-1.20.1-1.1.6-hotfix.jar" = "both"
|
||||
"tacz_turrets-1.0.1-all.jar" = "both"
|
||||
"taczadditions-1.20.1-1.0.5-hotfix2.jar" = "both"
|
||||
"taczaddon-1.20.1-1.1.6.jar" = "both"
|
||||
"taczcustomdeath-1.0.0-Beta.jar" = "both"
|
||||
"taczdd-0.1.1.jar" = "both"
|
||||
"taczmagazines-1.20.1-0.1.6-Beta.jar" = "both"
|
||||
"tacznpcs-2.1.0-1.20.1.jar" = "both"
|
||||
"tarkovdayz-1.0.0-forge-1.20.1.jar" = "both"
|
||||
"visualhealth-forge-1.20.1-2.0.2.jar" = "client"
|
||||
"voicechat-forge-1.20.1-2.6.6.jar" = "both"
|
||||
"worldedit-mod-7.2.15.jar" = "server"
|
||||
"worldedit_items-2.0-forge-1.20.1.jar" = "server"
|
||||
"worldflags-1.3.0-forge-1.20.1.jar" = "server"
|
||||
"worldprotect-1.0.0.jar" = "server"
|
||||
"yet_another_config_lib_v3-3.6.6+1.20.1-forge.jar" = "client"
|
||||
@@ -0,0 +1,54 @@
|
||||
[[path]]
|
||||
source = "instance.cfg"
|
||||
side = "client"
|
||||
client_destination = "instance.cfg"
|
||||
|
||||
[[path]]
|
||||
source = "mmc-pack.json"
|
||||
side = "client"
|
||||
client_destination = "mmc-pack.json"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/config"
|
||||
side = "both"
|
||||
client_destination = "minecraft/config"
|
||||
server_destination = "config"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/defaultconfigs"
|
||||
side = "both"
|
||||
client_destination = "minecraft/defaultconfigs"
|
||||
server_destination = "defaultconfigs"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/tacz"
|
||||
side = "both"
|
||||
client_destination = "minecraft/tacz"
|
||||
server_destination = "tacz"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/easy_npc"
|
||||
side = "both"
|
||||
client_destination = "minecraft/easy_npc"
|
||||
server_destination = "easy_npc"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/data"
|
||||
side = "both"
|
||||
client_destination = "minecraft/data"
|
||||
server_destination = "data"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/emotes"
|
||||
side = "client"
|
||||
client_destination = "minecraft/emotes"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/fancymenu_data"
|
||||
side = "client"
|
||||
client_destination = "minecraft/fancymenu_data"
|
||||
|
||||
[[path]]
|
||||
source = "minecraft/DiscordIntegration-Data"
|
||||
side = "server"
|
||||
server_destination = "DiscordIntegration-Data"
|
||||
@@ -0,0 +1,3 @@
|
||||
[tool.pytest]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,172 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.beta_release_lib import validate_complete_mapping
|
||||
|
||||
|
||||
def test_manifest_rejects_an_unclassified_or_missing_mod() -> None:
|
||||
with pytest.raises(ValueError, match="missing=.*alpha.jar.*unknown=.*beta.jar"):
|
||||
validate_complete_mapping(
|
||||
{"alpha.jar", "beta.jar"},
|
||||
{"beta.jar": "UNCLASSIFIED"},
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_accepts_only_known_sides_and_exact_names() -> None:
|
||||
validate_complete_mapping(
|
||||
{"alpha.jar", "beta.jar"},
|
||||
{"alpha.jar": "client", "beta.jar": "server"},
|
||||
)
|
||||
with pytest.raises(ValueError, match="extra=.*gamma.jar"):
|
||||
validate_complete_mapping(
|
||||
{"alpha.jar"}, {"alpha.jar": "both", "gamma.jar": "client"},
|
||||
)
|
||||
|
||||
|
||||
def test_write_keybind_options_keeps_only_version_and_key_lines(tmp_path: Path) -> None:
|
||||
from tools.beta_release_lib import write_keybind_options
|
||||
|
||||
source = tmp_path / "source-options.txt"
|
||||
destination = tmp_path / "options.txt"
|
||||
source.write_text(
|
||||
"version:3465\nrenderDistance:32\nkey_key.tacz.reload.desc:key.keyboard.r\n"
|
||||
"fov:0.0\nkey_key.forward:key.keyboard.w\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
write_keybind_options(source, destination)
|
||||
|
||||
assert destination.read_text(encoding="utf-8") == (
|
||||
"version:3465\nkey_key.tacz.reload.desc:key.keyboard.r\nkey_key.forward:key.keyboard.w\n"
|
||||
)
|
||||
|
||||
|
||||
def test_copy_classified_tree_never_copies_wrong_side_or_world(tmp_path: Path) -> None:
|
||||
from tools.beta_release_lib import copy_classified_tree
|
||||
|
||||
source = tmp_path / "source"
|
||||
(source / "minecraft/mods").mkdir(parents=True)
|
||||
(source / "minecraft/saves/world").mkdir(parents=True)
|
||||
(source / "minecraft/mods/client.jar").write_bytes(b"client")
|
||||
(source / "minecraft/mods/both.jar").write_bytes(b"both")
|
||||
(source / "minecraft/mods/server.jar").write_bytes(b"server")
|
||||
(source / "minecraft/saves/world/level.dat").write_bytes(b"world")
|
||||
|
||||
destination = tmp_path / "client"
|
||||
copy_classified_tree(
|
||||
source,
|
||||
destination,
|
||||
"client",
|
||||
{"client.jar": "client", "both.jar": "both", "server.jar": "server"},
|
||||
[],
|
||||
)
|
||||
|
||||
assert (destination / "minecraft/mods/client.jar").is_file()
|
||||
assert (destination / "minecraft/mods/both.jar").is_file()
|
||||
assert not (destination / "minecraft/mods/server.jar").exists()
|
||||
assert not (destination / "minecraft/saves").exists()
|
||||
|
||||
|
||||
def test_validate_client_stage_requires_prism_root_and_rejects_world(tmp_path: Path) -> None:
|
||||
from tools.beta_release_lib import validate_client_stage
|
||||
|
||||
stage = tmp_path / "client-stage"
|
||||
(stage / "minecraft/saves/world").mkdir(parents=True)
|
||||
(stage / "minecraft/saves/world/level.dat").write_bytes(b"world")
|
||||
(stage / "minecraft/mods").mkdir()
|
||||
(stage / "minecraft/options.txt").write_text("version:3465\n", encoding="utf-8")
|
||||
(stage / "instance.cfg").write_text("InstanceType=OneSix\n", encoding="utf-8")
|
||||
(stage / "mmc-pack.json").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="forbidden client path.*minecraft/saves"):
|
||||
validate_client_stage(stage, {})
|
||||
|
||||
|
||||
def test_sanitize_client_stage_removes_server_credentials_and_personal_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from tools.beta_release_lib import sanitize_client_stage
|
||||
|
||||
stage = tmp_path / "client-stage"
|
||||
credentials = stage / "minecraft/config/Discord-Integration.toml"
|
||||
cache = stage / "minecraft/config/voicechat/username-cache.json"
|
||||
shader_selection = stage / "minecraft/config/oculus.properties"
|
||||
skin_provider = stage / "minecraft/config/skinrestorer/config.json"
|
||||
credentials.parent.mkdir(parents=True)
|
||||
cache.parent.mkdir(parents=True)
|
||||
skin_provider.parent.mkdir(parents=True)
|
||||
credentials.write_text("botToken=do-not-publish\n", encoding="utf-8")
|
||||
cache.write_text('{"player":"do-not-publish"}\n', encoding="utf-8")
|
||||
shader_selection.write_text("shaderPack=local-only\n", encoding="utf-8")
|
||||
skin_provider.write_text('{"apiKey":"do-not-publish"}\n', encoding="utf-8")
|
||||
|
||||
sanitize_client_stage(stage)
|
||||
|
||||
assert not credentials.exists()
|
||||
assert not cache.exists()
|
||||
assert not shader_selection.exists()
|
||||
assert not skin_provider.exists()
|
||||
|
||||
|
||||
def test_validate_server_stage_rejects_client_jar_and_requires_target_world(tmp_path: Path) -> None:
|
||||
from tools.beta_release_lib import validate_server_stage
|
||||
|
||||
stage = tmp_path / "server-stage"
|
||||
(stage / "mods").mkdir(parents=True)
|
||||
(stage / "mods/client.jar").write_bytes(b"client")
|
||||
(stage / "eula.txt").write_text("eula=false\n", encoding="utf-8")
|
||||
(stage / "server.properties").write_text("level-name=world\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="server JAR mismatch|missing target world"):
|
||||
validate_server_stage(stage, {"client.jar": "client"}, "world")
|
||||
|
||||
|
||||
def test_forge_request_declares_a_user_agent() -> None:
|
||||
from tools.build_beta_release import forge_request
|
||||
|
||||
request = forge_request("https://example.invalid/forge-installer.jar")
|
||||
|
||||
assert request.get_header("User-agent") == "EscapeFromFuncraftBetaBuilder/1.0"
|
||||
|
||||
|
||||
def test_forge_install_env_prepends_compatibility_library_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from tools.build_beta_release import forge_install_env
|
||||
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH", "/system/lib")
|
||||
|
||||
env = forge_install_env(tmp_path / "legacy-zlib")
|
||||
|
||||
assert env is not None
|
||||
assert env["LD_LIBRARY_PATH"] == f"{tmp_path / 'legacy-zlib'}:/system/lib"
|
||||
|
||||
|
||||
def test_sha256_file_streams_without_reading_the_archive_at_once(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from tools.beta_release_lib import sha256_file
|
||||
|
||||
archive = tmp_path / "large-archive.bin"
|
||||
payload = b"release-data" * 100_000
|
||||
archive.write_bytes(payload)
|
||||
|
||||
def fail_read_bytes(_: Path) -> bytes:
|
||||
raise AssertionError("release archives must not be read into memory at once")
|
||||
|
||||
monkeypatch.setattr(Path, "read_bytes", fail_read_bytes)
|
||||
|
||||
assert sha256_file(archive) == hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def test_source_mod_names_includes_only_mod_jars(tmp_path: Path) -> None:
|
||||
from tools.build_beta_release import source_mod_names
|
||||
|
||||
mods = tmp_path / "minecraft/mods"
|
||||
mods.mkdir(parents=True)
|
||||
(mods / "required.jar").write_bytes(b"jar")
|
||||
(mods / "notes.txt").write_text("ignore", encoding="utf-8")
|
||||
|
||||
assert source_mod_names(tmp_path) == {"required.jar"}
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import gzip
|
||||
import shutil
|
||||
import tarfile
|
||||
import tomllib
|
||||
import zipfile
|
||||
|
||||
|
||||
VALID_SIDES = frozenset({"client", "server", "both"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathRule:
|
||||
source: str
|
||||
side: str
|
||||
client_destination: str | None = None
|
||||
server_destination: str | None = None
|
||||
|
||||
|
||||
def load_side_manifest(path: Path) -> dict[str, str]:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
mapping = data["classification"]
|
||||
if not isinstance(mapping, dict):
|
||||
raise ValueError(f"{path}: [classification] is required")
|
||||
result = {str(name): str(side) for name, side in mapping.items()}
|
||||
invalid = sorted(name for name, side in result.items() if side not in VALID_SIDES)
|
||||
if invalid:
|
||||
raise ValueError(f"{path}: invalid side for {', '.join(invalid)}")
|
||||
return result
|
||||
|
||||
|
||||
def load_path_rules(path: Path) -> list[PathRule]:
|
||||
rules = []
|
||||
for item in tomllib.loads(path.read_text(encoding="utf-8")).get("path", []):
|
||||
rule = PathRule(
|
||||
source=str(item["source"]),
|
||||
side=str(item["side"]),
|
||||
client_destination=item.get("client_destination"),
|
||||
server_destination=item.get("server_destination"),
|
||||
)
|
||||
if rule.side not in VALID_SIDES:
|
||||
raise ValueError(f"{path}: invalid side for {rule.source}")
|
||||
if rule.side in {"client", "both"} and rule.client_destination is None:
|
||||
raise ValueError(f"{path}: missing client destination for {rule.source}")
|
||||
if rule.side in {"server", "both"} and rule.server_destination is None:
|
||||
raise ValueError(f"{path}: missing server destination for {rule.source}")
|
||||
rules.append(rule)
|
||||
return rules
|
||||
|
||||
|
||||
def validate_complete_mapping(expected: set[str], mapping: dict[str, str]) -> None:
|
||||
missing = sorted(expected - mapping.keys())
|
||||
extra = sorted(mapping.keys() - expected)
|
||||
unknown = sorted(name for name, side in mapping.items() if side not in VALID_SIDES)
|
||||
if missing or extra or unknown:
|
||||
raise ValueError(
|
||||
f"missing={','.join(missing)} extra={','.join(extra)} unknown={','.join(unknown)}"
|
||||
)
|
||||
|
||||
|
||||
def write_keybind_options(source: Path, destination: Path) -> None:
|
||||
kept = [
|
||||
line
|
||||
for line in source.read_text(encoding="utf-8").splitlines()
|
||||
if line.startswith("version:") or line.startswith("key_")
|
||||
]
|
||||
if sum(line.startswith("version:") for line in kept) != 1:
|
||||
raise ValueError(f"{source}: expected exactly one version line")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _matches_side(item_side: str, destination_side: str) -> bool:
|
||||
return item_side == "both" or item_side == destination_side
|
||||
|
||||
|
||||
def _safe_relative(value: str) -> Path:
|
||||
candidate = Path(value)
|
||||
if candidate.is_absolute() or ".." in candidate.parts:
|
||||
raise ValueError(f"unsafe relative path: {value}")
|
||||
return candidate
|
||||
|
||||
|
||||
def copy_classified_tree(
|
||||
source_root: Path,
|
||||
destination_root: Path,
|
||||
side: str,
|
||||
mod_map: dict[str, str],
|
||||
path_rules: list[PathRule],
|
||||
) -> None:
|
||||
if side not in {"client", "server"}:
|
||||
raise ValueError(f"invalid destination side: {side}")
|
||||
|
||||
mods_root = Path("minecraft/mods") if side == "client" else Path("mods")
|
||||
for jar_name, jar_side in mod_map.items():
|
||||
if not _matches_side(jar_side, side):
|
||||
continue
|
||||
source = source_root / "minecraft/mods" / jar_name
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(source)
|
||||
target = destination_root / mods_root / jar_name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
for rule in path_rules:
|
||||
if not _matches_side(rule.side, side):
|
||||
continue
|
||||
destination_name = (
|
||||
rule.client_destination if side == "client" else rule.server_destination
|
||||
)
|
||||
if destination_name is None:
|
||||
raise ValueError(f"missing {side} destination for {rule.source}")
|
||||
source = source_root / _safe_relative(rule.source)
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(source)
|
||||
target = destination_root / _safe_relative(destination_name)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, dirs_exist_ok=False)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
CLIENT_FORBIDDEN_PREFIXES = (
|
||||
"minecraft/saves/",
|
||||
"minecraft/logs/",
|
||||
"minecraft/crash-reports/",
|
||||
"minecraft/screenshots/",
|
||||
"minecraft/mods-disabled/",
|
||||
"minecraft/shaderpacks/",
|
||||
)
|
||||
CLIENT_FORBIDDEN_NAMES = {
|
||||
"minecraft/servers.dat",
|
||||
"minecraft/optionsof.txt",
|
||||
"minecraft/optionsshaders.txt",
|
||||
}
|
||||
CLIENT_SANITIZED_PATHS = (
|
||||
"minecraft/config/.backups",
|
||||
"minecraft/config/Discord-Integration.toml",
|
||||
"minecraft/config/EssentialsCommands-config.toml",
|
||||
"minecraft/config/NoChatReports/NCR-ServerPreferences.json",
|
||||
"minecraft/config/arctics_essentials",
|
||||
"minecraft/config/bstats.json",
|
||||
"minecraft/config/camerapture.server.json",
|
||||
"minecraft/config/chunky",
|
||||
"minecraft/config/discord_chat_mod-client.toml",
|
||||
"minecraft/config/discord_chat_mod-common.toml",
|
||||
"minecraft/config/dyairdrop.toml",
|
||||
"minecraft/config/elite-holograms",
|
||||
"minecraft/config/fancymenu/user_variables.db",
|
||||
"minecraft/config/maplink/friends.json5",
|
||||
"minecraft/config/oculus.properties",
|
||||
"minecraft/config/refurbished_furniture.server.toml",
|
||||
"minecraft/config/skinrestorer/config.json",
|
||||
"minecraft/config/skinrestorer/mojang_profile_cache.json",
|
||||
"minecraft/config/spark",
|
||||
"minecraft/config/voicechat/player-volumes.properties",
|
||||
"minecraft/config/voicechat/username-cache.json",
|
||||
"minecraft/config/voicechat/voicechat-server.properties",
|
||||
"minecraft/config/voicechat/voicechat-volumes.properties",
|
||||
"minecraft/config/worldedit",
|
||||
"minecraft/config/worldprotect-common.toml",
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as input_file:
|
||||
while chunk := input_file.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def sanitize_client_stage(stage: Path) -> None:
|
||||
for relative in CLIENT_SANITIZED_PATHS:
|
||||
target = stage / relative
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
elif target.is_file() or target.is_symlink():
|
||||
target.unlink()
|
||||
|
||||
|
||||
def validate_client_stage(stage: Path, mod_map: dict[str, str]) -> None:
|
||||
for required in ("instance.cfg", "mmc-pack.json", "minecraft/options.txt"):
|
||||
if not (stage / required).is_file():
|
||||
raise ValueError(f"missing client file: {required}")
|
||||
for path in sorted(stage.rglob("*")):
|
||||
relative = path.relative_to(stage).as_posix()
|
||||
if relative in CLIENT_FORBIDDEN_NAMES or relative.startswith(CLIENT_FORBIDDEN_PREFIXES):
|
||||
raise ValueError(f"forbidden client path: {relative}")
|
||||
for relative in CLIENT_SANITIZED_PATHS:
|
||||
if (stage / relative).exists():
|
||||
raise ValueError(f"unsanitized client path: {relative}")
|
||||
actual = {path.name for path in (stage / "minecraft/mods").glob("*.jar")}
|
||||
expected = {name for name, side in mod_map.items() if side in {"client", "both"}}
|
||||
if actual != expected:
|
||||
raise ValueError(
|
||||
f"client JAR mismatch missing={sorted(expected - actual)} extra={sorted(actual - expected)}"
|
||||
)
|
||||
|
||||
|
||||
def create_zip(source: Path, archive: Path) -> str:
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as output:
|
||||
for path in sorted(source.rglob("*")):
|
||||
if path.is_file():
|
||||
info = zipfile.ZipInfo(
|
||||
path.relative_to(source).as_posix(),
|
||||
date_time=(2026, 7, 18, 0, 0, 0),
|
||||
)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
output.writestr(info, path.read_bytes())
|
||||
return sha256_file(archive)
|
||||
|
||||
|
||||
def validate_server_stage(stage: Path, mod_map: dict[str, str], world_name: str) -> None:
|
||||
for required in ("eula.txt", "server.properties", "mods"):
|
||||
if not (stage / required).exists():
|
||||
raise ValueError(f"missing server file: {required}")
|
||||
if not (stage / world_name).is_dir():
|
||||
raise ValueError(f"missing target world: {world_name}")
|
||||
if (stage / "eula.txt").read_text(encoding="utf-8") != "eula=false\n":
|
||||
raise ValueError("server eula.txt must remain eula=false")
|
||||
properties = (stage / "server.properties").read_text(encoding="utf-8")
|
||||
if f"level-name={world_name}\n" not in properties:
|
||||
raise ValueError("server.properties does not target the expected world")
|
||||
actual = {path.name for path in (stage / "mods").glob("*.jar")}
|
||||
expected = {name for name, side in mod_map.items() if side in {"server", "both"}}
|
||||
if actual != expected:
|
||||
raise ValueError(
|
||||
f"server JAR mismatch missing={sorted(expected - actual)} extra={sorted(actual - expected)}"
|
||||
)
|
||||
|
||||
|
||||
def create_tar_gz(source: Path, archive: Path) -> str:
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open("wb") as raw:
|
||||
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w") as output:
|
||||
for path in sorted(source.rglob("*")):
|
||||
relative = path.relative_to(source).as_posix()
|
||||
info = output.gettarinfo(str(path), arcname=relative)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mtime = 0
|
||||
if path.is_file():
|
||||
with path.open("rb") as input_file:
|
||||
output.addfile(info, input_file)
|
||||
else:
|
||||
output.addfile(info)
|
||||
return sha256_file(archive)
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tomllib
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .beta_release_lib import (
|
||||
create_zip,
|
||||
create_tar_gz,
|
||||
copy_classified_tree,
|
||||
load_path_rules,
|
||||
load_side_manifest,
|
||||
sanitize_client_stage,
|
||||
validate_complete_mapping,
|
||||
validate_client_stage,
|
||||
validate_server_stage,
|
||||
write_keybind_options,
|
||||
)
|
||||
except ImportError:
|
||||
from beta_release_lib import (
|
||||
create_zip,
|
||||
create_tar_gz,
|
||||
copy_classified_tree,
|
||||
load_path_rules,
|
||||
load_side_manifest,
|
||||
sanitize_client_stage,
|
||||
validate_complete_mapping,
|
||||
validate_client_stage,
|
||||
validate_server_stage,
|
||||
write_keybind_options,
|
||||
)
|
||||
|
||||
|
||||
def version_directory(output_root: Path, version: str) -> Path:
|
||||
resolved_root = output_root.resolve()
|
||||
candidate = (resolved_root / version).resolve()
|
||||
if candidate.parent != resolved_root:
|
||||
raise ValueError(f"invalid release version: {version}")
|
||||
return candidate
|
||||
|
||||
|
||||
def source_mod_names(source: Path) -> set[str]:
|
||||
return {path.name for path in (source / "minecraft/mods").glob("*.jar")}
|
||||
|
||||
|
||||
def forge_request(url: str) -> urllib.request.Request:
|
||||
return urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "EscapeFromFuncraftBetaBuilder/1.0"},
|
||||
)
|
||||
|
||||
|
||||
def forge_install_env(compatibility_library_path: Path | None) -> dict[str, str] | None:
|
||||
if compatibility_library_path is None:
|
||||
return None
|
||||
env = os.environ.copy()
|
||||
existing = env.get("LD_LIBRARY_PATH")
|
||||
env["LD_LIBRARY_PATH"] = (
|
||||
f"{compatibility_library_path}:{existing}" if existing else str(compatibility_library_path)
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def download_forge_installer(
|
||||
server_stage: Path, compatibility_library_path: Path | None = None
|
||||
) -> None:
|
||||
version = "1.20.1-47.4.10"
|
||||
base = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"
|
||||
installer = server_stage / f"forge-{version}-installer.jar"
|
||||
expected_sha1 = (
|
||||
urllib.request.urlopen(forge_request(base + ".sha1"), timeout=30)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.strip()
|
||||
.split()[0]
|
||||
)
|
||||
with urllib.request.urlopen(forge_request(base), timeout=60) as response:
|
||||
installer.write_bytes(response.read())
|
||||
import hashlib
|
||||
|
||||
actual_sha1 = hashlib.sha1(installer.read_bytes()).hexdigest()
|
||||
if actual_sha1 != expected_sha1:
|
||||
raise ValueError(f"Forge installer checksum mismatch: {actual_sha1} != {expected_sha1}")
|
||||
subprocess.run(
|
||||
["java", "-jar", installer.name, "--installServer", "."],
|
||||
cwd=server_stage,
|
||||
check=True,
|
||||
env=forge_install_env(compatibility_library_path),
|
||||
)
|
||||
installer.unlink()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", required=True, type=Path)
|
||||
parser.add_argument("--client-output", required=True, type=Path)
|
||||
parser.add_argument("--server-output", required=True, type=Path)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--manifest", type=Path, default=Path("packaging/mod-classification.toml"))
|
||||
parser.add_argument("--path-manifest", type=Path, default=Path("packaging/path-classification.toml"))
|
||||
parser.add_argument("--release-config", type=Path, default=Path("packaging/beta-release.toml"))
|
||||
parser.add_argument(
|
||||
"--forge-installer-lib-path",
|
||||
type=Path,
|
||||
help="directory containing a zlib implementation compatible with Forge's processors",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
source = args.source.resolve()
|
||||
client_version_root = version_directory(args.client_output, args.version)
|
||||
server_version_root = version_directory(args.server_output, args.version)
|
||||
for root in (client_version_root, server_version_root):
|
||||
if root.exists():
|
||||
shutil.rmtree(root)
|
||||
|
||||
client_stage = client_version_root / "client-stage"
|
||||
server_stage = server_version_root / "server-stage"
|
||||
mod_map = load_side_manifest(args.manifest)
|
||||
validate_complete_mapping(source_mod_names(source), mod_map)
|
||||
path_rules = load_path_rules(args.path_manifest)
|
||||
release = tomllib.loads(args.release_config.read_text(encoding="utf-8"))
|
||||
world_source = source / release["source"]["world"]
|
||||
world_name = world_source.name
|
||||
copy_classified_tree(source, client_stage, "client", mod_map, path_rules)
|
||||
copy_classified_tree(source, server_stage, "server", mod_map, path_rules)
|
||||
write_keybind_options(source / "minecraft/options.txt", client_stage / "minecraft/options.txt")
|
||||
sanitize_client_stage(client_stage)
|
||||
validate_client_stage(client_stage, mod_map)
|
||||
client_archive = client_version_root / f"escape-from-funcraft-client-{args.version}.zip"
|
||||
digest = create_zip(client_stage, client_archive)
|
||||
(client_version_root / "SHA256SUMS").write_text(
|
||||
f"{digest} {client_archive.name}\n", encoding="utf-8"
|
||||
)
|
||||
shutil.copytree(world_source, server_stage / world_name)
|
||||
(server_stage / "eula.txt").write_text("eula=false\n", encoding="utf-8")
|
||||
(server_stage / "server.properties").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
f"level-name={world_name}",
|
||||
"online-mode=true",
|
||||
"enable-command-block=true",
|
||||
"spawn-protection=0",
|
||||
"",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
validate_server_stage(server_stage, mod_map, world_name)
|
||||
download_forge_installer(server_stage, args.forge_installer_lib_path)
|
||||
server_archive = server_version_root / f"escape-from-funcraft-server-{args.version}.tar.gz"
|
||||
server_digest = create_tar_gz(server_stage, server_archive)
|
||||
(server_version_root / "SHA256SUMS").write_text(
|
||||
f"{server_digest} {server_archive.name}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from beta_release_lib import load_side_manifest, validate_client_stage, validate_server_stage
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--client-stage", type=Path)
|
||||
parser.add_argument("--server-stage", type=Path)
|
||||
parser.add_argument("--manifest", required=True, type=Path)
|
||||
parser.add_argument("--world")
|
||||
args = parser.parse_args()
|
||||
if (args.client_stage is None) == (args.server_stage is None):
|
||||
parser.error("provide exactly one of --client-stage or --server-stage")
|
||||
manifest = load_side_manifest(args.manifest)
|
||||
if args.client_stage is not None:
|
||||
validate_client_stage(args.client_stage, manifest)
|
||||
else:
|
||||
if args.world is None:
|
||||
parser.error("--world is required with --server-stage")
|
||||
validate_server_stage(args.server_stage, manifest, args.world)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user