Phase 9: the private pack to hand out
build/...-private.mrpack lists every mod by download link, CurseForge-only ones included, with the credits and non-affiliation notice inside and no third-party jar. tools/check_private_pack.py downloads all of it and checks the hashes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4083791944
commit
6ace26e012
8 files changed
+139
-6
No files matched your search
@@ -133,6 +133,14 @@ python3 tools/jei_sources.py
|
||||
```
|
||||
Writes the data behind the pack's own JEI pages (Phase 8) into the data pack (`datapacks/core/data/cobblesurvival/drop_sources/pages.json`), and the server sends it to players: every Pokémon's drops and zones, guardians, the End boss, the altar, ore veins, pasture finds. Run it after changing species drops, spawns, guardians, machines, ores or Cobblebase tables, then rebuild; the self-test `drop_sources_match_the_server` fails while it's stale. Needs the Cobblemon jar in `build/server/mods` (any test-server run).
|
||||
|
||||
## Handing out the pack
|
||||
|
||||
`tools/build.py` writes `build/cobblemon-survival-pack-<ver>-private.mrpack` (about 1 MB). Send that file to players: in Prism Launcher, Add Instance > Import, then pick the file. It lists all 43 mods and the shader pack by download link, so each player's launcher fetches them from Modrinth, CurseForge or the author's own host; no third-party jar is inside. It needs Prism: Modrinth's own app and site only accept links to their listed hosts, and four mods are only on CurseForge.
|
||||
|
||||
`tools/check_private_pack.py` downloads every link and checks it against the pinned hashes. After handing out a pack, run `python3 tools/server_update.py --mark-client-release`, so later balance updates know what players have.
|
||||
|
||||
For the server, install NeoForge from its installer (`--install-server`), then put the mods in with `python3 tools/packwiz_sync.py server <server folder>`, and copy `build/world-template` in as `world`. Balance updates then go through the section below.
|
||||
|
||||
## Balancing on a running server
|
||||
|
||||
Balance lives on the server, so playtesters keep the client they have. After changing the pack's data:
|
||||
|
||||
@@ -18,8 +18,12 @@
|
||||
|
||||
- **Adrian's list of 2026-11-09:** shortcuts, drop pages link to Cobblemon Info, real drop chances, bosses only hurt in battles, half the battle XP, low channels, quicker hazards, no item dots on the map, attacks start battles. Automated: `drop_chances_match_cobblemon`, `starter_only_faints`, `regional_forms_are_obtainable`, `new_players_get_the_quest_book`, `bosses_only_take_battle_damage`, `wild_attack_starts_a_battle`, `wild_attack_hurts_without_a_party`, `mire_poisons_without_waders`.
|
||||
|
||||
- **Private pack:** `build/…-private.mrpack`. Automated: `tools/check_private_pack.py` downloads all 44 files and checks them.
|
||||
|
||||
## In game (Prism instance)
|
||||
|
||||
- [ ] Import the private pack into Prism as a new instance: it downloads everything and starts.
|
||||
|
||||
- [ ] Let your starter faint: it stays, fainted, and a Healing Machine wakes it. Try to release it and sacrifice it: both are refused. Any other Pokémon is still lost.
|
||||
- [ ] A new player spawns with the quest book. Its first page walks through the basics; the second lists every HM.
|
||||
- [ ] Are the regional forms' zones and rarity right? They're in `docs/zone-species.md`.
|
||||
|
||||
+1
-1
@@ -358,7 +358,7 @@ The JEI pages were the one thing clients took from the jar. Their data moved int
|
||||
| Decision | Default used for now |
|
||||
| --- | --- |
|
||||
| Roadmap's open decisions table (loader, island size, safety net, boss AoE, etc.) | Roadmap defaults, unchanged |
|
||||
| **Platform split** (deferred until a public release; not publishing for now) | No platform hosts the full mod list (FTB Quests is CurseForge-only; CobbleHardcoreMon is Modrinth-only). Both mods stay; the exports keep listing what they leave out. Options for release: (a) publish one platform's pack, or both with the gaps listed; (b) ask the authors to cross-post (CobbleHardcoreMon, Matthiesen Core, Regenerating Ore Veins and Ore Generation Configuration are MIT); (c) a quest mod on both platforms; (d) permadeath in the companion mod |
|
||||
| **Platform split** (settled for now: no public release, so the private `.mrpack` lists every mod by its own link, which Prism installs; only matters again for a public release) | No platform hosts the full mod list (FTB Quests is CurseForge-only; CobbleHardcoreMon is Modrinth-only). Both mods stay; the exports keep listing what they leave out. Options for release: (a) publish one platform's pack, or both with the gaps listed; (b) ask the authors to cross-post (CobbleHardcoreMon, Matthiesen Core, Regenerating Ore Veins and Ore Generation Configuration are MIT); (c) a quest mod on both platforms; (d) permadeath in the companion mod |
|
||||
| **No CI** (Adrian, 2026-10-30): no Actions runners on the Gitea server, which is too weak; a push that started the old `.github/workflows/ci.yml` crashed it. The workflow is removed (still in the git history) | The roadmap's "CI smoke test" runs locally instead: `tools/smoke_test.py`, `tools/selftest.py`, `tools/quick_check.py`. Adrian accepted the Minecraft EULA for the local test server on 2026-10-28 |
|
||||
| License for the companion mod and pack content | MIT for the companion mod (placeholder); nothing chosen for the rest |
|
||||
| Pack name and mod id | "Cobblemon Survival Pack" / `cobblesurvival` (working titles) |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Licenses and credits
|
||||
|
||||
The pack is free. It references every third-party mod through packwiz metadata, so players download each jar from Modrinth or CurseForge, the author's own host. No third-party jar is copied into this repo or into an export; `tools/build.py` fails an export that would do that.
|
||||
The pack is free. It references every third-party mod through packwiz metadata, so players download each jar from Modrinth or CurseForge, the author's own host. The private pack that Adrian hands out does the same, by each file's download link. No third-party jar is copied into this repo or into an export; `tools/build.py` fails an export that would do that.
|
||||
|
||||
Read each license before adding a mod, and add a row here in the same change.
|
||||
|
||||
|
||||
+1
-1
@@ -196,7 +196,7 @@ Goal: a quest book that points players onward, laid out like a research tree who
|
||||
- **Reproducible builds** (Adrian, 2026-11-09). The same commit gives the same bytes: pinned JDK, packwiz, Gradle plugins and Python packages; `tools/check_reproducible.py` proves it.
|
||||
- **Balancing on the server** (Adrian, 2026-11-09). Balance changes reach playtesters without a client update: `tools/server_update.py`, a server-held data pack, JEI pages sent by the server.
|
||||
- **Multiplayer test** with Adrian's group.
|
||||
- **Release.** Final `docs/licenses.md`, credits and non-affiliation disclaimer; publish client and server packs on Modrinth and CurseForge as dependency-based packs.
|
||||
- **Release.** No public release (Adrian, 2026-11-09): `build/…-private.mrpack`, every mod by download link, credits and non-affiliation notice inside; Adrian hands it out himself.
|
||||
|
||||
**Done when:** a full playthrough from spawn to the last zone is possible without commands, every implemented Pokémon can be obtained without commands, and a server runs a multi-hour session without tick-time spikes from Pokémon entities.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Outputs, all under build/:
|
||||
instance and the server smoke test install this
|
||||
<slug>-<ver>.mrpack Modrinth pack
|
||||
<slug>-<ver>-curseforge.zip CurseForge pack
|
||||
<slug>-<ver>-private.mrpack every mod, by download link: the pack to hand out
|
||||
SHA256SUMS what the build produced
|
||||
|
||||
Reproducible: the same commit gives the same bytes, whatever the machine has
|
||||
@@ -142,6 +143,38 @@ def normalise_export(archive):
|
||||
z.writestr(info, files[name])
|
||||
|
||||
|
||||
def write_private_export(modrinth_pack, out):
|
||||
"""The pack Adrian hands out himself (2026-11-09: no public release): the Modrinth export plus the mods only
|
||||
CurseForge hosts, by their CurseForge download links. Still no third-party jar inside. Prism installs any link;
|
||||
Modrinth's own site and app only accept their listed hosts, which is why this is separate. The credits go in too."""
|
||||
with zipfile.ZipFile(modrinth_pack) as z:
|
||||
files = {i.filename: z.read(i.filename) for i in z.infolist() if not i.is_dir()}
|
||||
index = json.loads(files["modrinth.index.json"])
|
||||
listed = {Path(entry["path"]).name for entry in index["files"]}
|
||||
for path in sorted((PACK / "mods").glob("*.pw.toml")):
|
||||
meta = tomllib.loads(path.read_text())
|
||||
if meta["filename"] in listed:
|
||||
continue
|
||||
jar = lock.fetch_mod(meta)
|
||||
side = meta.get("side", "both")
|
||||
index["files"].append({
|
||||
"path": f"mods/{meta['filename']}",
|
||||
"hashes": {"sha1": lock.digest(jar, "sha1"), "sha512": lock.digest(jar, "sha512")},
|
||||
"env": {"client": "unsupported" if side == "server" else "required", "server": "unsupported" if side == "client" else "required"},
|
||||
"downloads": [lock.download_url(meta)],
|
||||
"fileSize": jar.stat().st_size,
|
||||
})
|
||||
index["files"].sort(key=lambda entry: entry["path"])
|
||||
index["summary"] = "Unofficial fan-made modpack, not affiliated with or endorsed by Nintendo, Game Freak, Creatures Inc., " \
|
||||
"The Pokémon Company, the Cobblemon team, Mojang Studios or Microsoft. Credits: CREDITS.md."
|
||||
files["modrinth.index.json"] = (json.dumps(index, indent=1, ensure_ascii=False) + "\n").encode()
|
||||
files["overrides/CREDITS.md"] = (ROOT / "docs/licenses.md").read_bytes()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, content in files.items():
|
||||
z.writestr(name, content)
|
||||
normalise_export(out)
|
||||
|
||||
|
||||
def write_checksums(paths):
|
||||
(BUILD / "SHA256SUMS").write_text("".join(f"{lock.digest(p)} {p.relative_to(ROOT).as_posix()}\n" for p in sorted(paths)))
|
||||
|
||||
@@ -176,6 +209,11 @@ def main():
|
||||
check_overrides(out, jar)
|
||||
report[out.name] = left_out
|
||||
|
||||
private = BUILD / f"{base}-private.mrpack"
|
||||
write_private_export(BUILD / f"{base}.mrpack", private)
|
||||
check_overrides(private, jar)
|
||||
report[private.name] = []
|
||||
|
||||
write_checksums([jar, BUILD / "stage/full/pack.toml", BUILD / "stage/full/index.toml", *(BUILD / name for name in report)])
|
||||
print("\nExports (no third-party jars in overrides):")
|
||||
for name, left_out in report.items():
|
||||
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the private pack as a launcher would install it: every link really downloads the pinned file, every mod of the
|
||||
pack is listed exactly once with the right side, and no third-party jar sits inside.
|
||||
|
||||
Downloads each file fresh, so it needs the network and takes a minute or two.
|
||||
|
||||
Usage: tools/check_private_pack.py
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import lock
|
||||
|
||||
ROOT = lock.ROOT
|
||||
PACK = ROOT / "pack"
|
||||
|
||||
|
||||
def main():
|
||||
version = tomllib.loads((PACK / "pack.toml").read_text())["version"]
|
||||
archive = ROOT / f"build/cobblemon-survival-pack-{version}-private.mrpack"
|
||||
if not archive.exists():
|
||||
sys.exit(f"{archive} is missing: run tools/build.py first")
|
||||
problems = []
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
names = z.namelist()
|
||||
index = json.loads(z.read("modrinth.index.json"))
|
||||
jars = [n for n in names if n.endswith(".jar")]
|
||||
if [PurePosixPath(n).name for n in jars] != ["cobblesurvival-0.0.1.jar"]:
|
||||
problems.append(f"jars inside the pack: {jars}")
|
||||
if "overrides/CREDITS.md" not in names:
|
||||
problems.append("no credits in the pack")
|
||||
entries = {PurePosixPath(e["path"]).name: e for e in index["files"]}
|
||||
if len(entries) != len(index["files"]):
|
||||
problems.append("a file is listed twice")
|
||||
metas = [tomllib.loads(p.read_text()) for p in sorted([*(PACK / "mods").glob("*.pw.toml"), *(PACK / "shaderpacks").glob("*.pw.toml")])]
|
||||
wanted = {m["filename"]: m for m in metas}
|
||||
if set(wanted) != set(entries):
|
||||
problems.append(f"missing {sorted(set(wanted) - set(entries))}, unexpected {sorted(set(entries) - set(wanted))}")
|
||||
total = 0
|
||||
for name, entry in sorted(entries.items()):
|
||||
request = urllib.request.Request(entry["downloads"][0], headers={"User-Agent": "cobblemon-survival-pack check"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
content = response.read()
|
||||
except OSError as e:
|
||||
problems.append(f"{name}: {e}")
|
||||
continue
|
||||
total += len(content)
|
||||
if hashlib.sha512(content).hexdigest() != entry["hashes"]["sha512"] or hashlib.sha1(content).hexdigest() != entry["hashes"]["sha1"]:
|
||||
problems.append(f"{name}: the download doesn't match its hash")
|
||||
if len(content) != entry["fileSize"]:
|
||||
problems.append(f"{name}: size {len(content)}, listed {entry['fileSize']}")
|
||||
side = wanted.get(name, {}).get("side", "both")
|
||||
expected = {"client": side != "server", "server": side != "client"}
|
||||
if {k: v == "required" for k, v in entry["env"].items()} != expected:
|
||||
problems.append(f"{name}: env {entry['env']} but side {side}")
|
||||
print(f"{len(entries)} files, {total / 1e6:.0f} MB downloaded and checked; {archive.name} is {archive.stat().st_size / 1e6:.1f} MB")
|
||||
if problems:
|
||||
print("\n".join(f" - {p}" for p in problems))
|
||||
sys.exit("PRIVATE PACK CHECK FAILED")
|
||||
print("PRIVATE PACK CHECK PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+16
-3
@@ -12,6 +12,7 @@ import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tomllib
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import venv
|
||||
from pathlib import Path
|
||||
@@ -49,11 +50,23 @@ def download(name):
|
||||
return fetch(entry["url"], CACHE / name, entry["sha256"])
|
||||
|
||||
|
||||
def download_url(meta):
|
||||
"""Where a packwiz metafile's jar comes from; a CurseForge-only mod records no URL, only the file's id."""
|
||||
if "url" in meta["download"]:
|
||||
return meta["download"]["url"]
|
||||
file_id = meta["update"]["curseforge"]["file-id"]
|
||||
return f"https://edge.forgecdn.net/files/{file_id // 1000}/{file_id % 1000}/{urllib.parse.quote(meta['filename'])}"
|
||||
|
||||
|
||||
def fetch_mod(meta):
|
||||
"""The jar a packwiz metafile pins, checked against its hash."""
|
||||
file = meta["download"]
|
||||
return fetch(download_url(meta), CACHE / "mods" / meta["filename"], file["hash"], file["hash-format"])
|
||||
|
||||
|
||||
def mod_jar(slug):
|
||||
"""A mod's jar as pack/mods/<slug>.pw.toml pins it, for the tools that read a mod's own data."""
|
||||
meta = tomllib.loads((ROOT / f"pack/mods/{slug}.pw.toml").read_text())
|
||||
file = meta["download"]
|
||||
return fetch(file["url"], CACHE / "mods" / meta["filename"], file["hash"], file["hash-format"])
|
||||
return fetch_mod(tomllib.loads((ROOT / f"pack/mods/{slug}.pw.toml").read_text()))
|
||||
|
||||
|
||||
def jdk():
|
||||
|
||||
Reference in new issue
Block a user