feat: v4.1.0 -- workshop-ready (logo, path discovery, docs)

Players install from Steam Workshop instead of install.sh, which failed on
Windows (bash) and a Manjaro box (hard-coded Mods globs). Adds Logo +
logo.png the uploader ships, tools/steam_paths.py reading Steam's
libraryfolders.vdf (Flatpak/Snap/second disk/Windows), and README install
tiers plus in-game publishing steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TkQMhvEyoFqKzeCC69N6o
This commit is contained in:
2026-08-29 17:10:22 +02:00
co-authored by Claude Fable 5
parent 68af02fb3f
commit 1001abf57a
7 changed files with 344 additions and 45 deletions
+25 -15
View File
@@ -8,23 +8,20 @@
# against the player's actual installed game data, before the game is launched.
#
# Run: python3 tests/test_mod.py
# The game install is found automatically, or set COSMOTEER_DATA to override.
# The game install is found through Steam's own library list (tools/steam_paths.py),
# or set COSMOTEER_DATA to override.
import os
import re
import sys
MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
import rules_parser
import steam_paths
MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CANDIDATE_DATA_DIRS = [
"/mnt/SSD4TB/SteamLibrary/steamapps/common/Cosmoteer/Data",
os.path.expanduser("~/.local/share/Steam/steamapps/common/Cosmoteer/Data"),
os.path.expanduser("~/.steam/steam/steamapps/common/Cosmoteer/Data"),
]
# Component fields whose value names another component in the same part.
COMPONENT_REF_FIELDS = {
@@ -56,13 +53,8 @@ def check(condition, message):
def find_data_dir():
override = os.environ.get("COSMOTEER_DATA")
if override:
return override
for path in CANDIDATE_DATA_DIRS:
if os.path.isdir(path):
return path
return None
"""Locate the installed game's Data folder via tools/steam_paths.py."""
return steam_paths.find_data_dir()
DATA = find_data_dir()
@@ -745,6 +737,24 @@ def test_manifest_is_well_formed():
)
def test_manifest_declares_existing_logo():
"""Steam Workshop shows the mod's Logo; the file must ship inside the mod
folder, be a real PNG, and stay under Steam's 500 KB preview limit."""
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
logo = mod.get("Logo")
check(logo is not None, "mod.rules is missing the 'Logo' field")
if logo is None:
return
path = os.path.join(MOD_ROOT, logo.value.strip('"'))
check(os.path.isfile(path), f"Logo file does not exist: {path}")
if not os.path.isfile(path):
return
with open(path, "rb") as f:
magic = f.read(8)
check(magic == b"\x89PNG\r\n\x1a\n", f"Logo is not a PNG: {path}")
check(os.path.getsize(path) < 500_000, "Logo must be under 500 KB for Workshop upload")
def main():
if DATA is None or not os.path.isdir(DATA):
print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.")
+137
View File
@@ -0,0 +1,137 @@
# ABOUT ME
# Tests for tools/steam_paths.py, the Steam library discovery shared by
# install.sh and tests/test_mod.py.
#
# Why this exists: the discovery must work on machines the author does not
# own -- Flatpak Steam, a second library on another disk, Windows profiles.
# Each test builds a fake filesystem under a temporary HOME, so no real Steam
# install is needed and every layout is exercised on every run.
#
# Run: python3 tests/test_steam_paths.py
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools"))
import steam_paths
_failures = []
_checks = 0
def check(condition, message):
global _checks
_checks += 1
if not condition:
_failures.append(message)
PROTON_MODS = "steamapps/compatdata/799600/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer/76561190000000000/Mods"
def make_library(root, with_game=True, with_mods=True):
os.makedirs(os.path.join(root, "steamapps"), exist_ok=True)
if with_game:
os.makedirs(os.path.join(root, "steamapps/common/Cosmoteer/Data"))
if with_mods:
os.makedirs(os.path.join(root, PROTON_MODS))
def write_vdf(steam_root, library_paths):
entries = "".join(
f'\t"{i}"\n\t{{\n\t\t"path"\t\t"{p}"\n\t\t"label"\t\t""\n\t}}\n'
for i, p in enumerate(library_paths)
)
os.makedirs(os.path.join(steam_root, "steamapps"), exist_ok=True)
with open(os.path.join(steam_root, "steamapps/libraryfolders.vdf"), "w") as f:
f.write('"libraryfolders"\n{\n' + entries + "}\n")
def test_second_library_from_vdf():
with tempfile.TemporaryDirectory() as home:
steam = os.path.join(home, ".local/share/Steam")
second = os.path.join(home, "disk2/SteamLibrary")
make_library(steam, with_game=False, with_mods=False)
make_library(second)
write_vdf(steam, [steam, second])
env = {"HOME": home}
check(
steam_paths.find_data_dir(env) == os.path.join(second, "steamapps/common/Cosmoteer/Data"),
"Data dir in a second library listed in libraryfolders.vdf was not found",
)
check(
steam_paths.find_mods_dir(env) == os.path.join(second, PROTON_MODS),
"Proton Mods dir in a second library was not found",
)
def test_flatpak_steam_root():
with tempfile.TemporaryDirectory() as home:
steam = os.path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam")
make_library(steam)
env = {"HOME": home}
check(
steam_paths.find_data_dir(env) == os.path.join(steam, "steamapps/common/Cosmoteer/Data"),
"Flatpak Steam root was not searched for the Data dir",
)
check(
steam_paths.find_mods_dir(env) == os.path.join(steam, PROTON_MODS),
"Flatpak Steam root was not searched for the Mods dir",
)
def test_steam_root_without_vdf_still_works():
with tempfile.TemporaryDirectory() as home:
steam = os.path.join(home, ".steam/debian-installation")
make_library(steam)
env = {"HOME": home}
check(
steam_paths.find_data_dir(env) == os.path.join(steam, "steamapps/common/Cosmoteer/Data"),
"A Steam root with no libraryfolders.vdf should still be searched itself",
)
def test_windows_profile_mods_dir():
with tempfile.TemporaryDirectory() as profile:
mods = os.path.join(profile, "Saved Games/Cosmoteer/76561190000000000/Mods")
os.makedirs(mods)
env = {"USERPROFILE": profile}
check(steam_paths.find_mods_dir(env) == mods, "Windows Saved Games Mods dir was not found")
def test_env_overrides_win():
with tempfile.TemporaryDirectory() as home:
env = {"HOME": home, "COSMOTEER_DATA": "/override/Data", "COSMOTEER_MODS_DIR": "/override/Mods"}
check(steam_paths.find_data_dir(env) == "/override/Data", "COSMOTEER_DATA override ignored")
check(steam_paths.find_mods_dir(env) == "/override/Mods", "COSMOTEER_MODS_DIR override ignored")
def test_nothing_found_returns_none_and_lists_roots():
with tempfile.TemporaryDirectory() as home:
env = {"HOME": home}
check(steam_paths.find_data_dir(env) is None, "Empty HOME must yield None for Data")
check(steam_paths.find_mods_dir(env) is None, "Empty HOME must yield None for Mods")
roots = steam_paths.steam_roots(env)
check(any(r.startswith(home) for r in roots), "steam_roots must report the candidates it would try")
def main():
for name, test in sorted(globals().items()):
if name.startswith("test_") and callable(test):
before = len(_failures)
test()
print(f" [{'PASS' if len(_failures) == before else 'FAIL'}] {name}")
print(f"\n{_checks} checks run.")
if _failures:
print(f"{len(_failures)} FAILED:\n")
for failure in _failures:
print(f" - {failure}")
return 1
print("All checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())