Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ jobs:
timeout-minutes: 120
permissions:
contents: read
env:
ASSET_MAPS: "complex turbine dust2 academy metl3 metl4 frostbyte gorge hades injustice lostinspace oasis paradigm phosgene ruby stronghold suburb forge reissen venice"
ASSET_FFA_MAPS: "complex turbine dust2 academy metl3 metl4 frostbyte gorge hades injustice lostinspace oasis paradigm phosgene ruby stronghold suburb"
ASSET_CAPTURE_MAPS: "dust2 forge frostbyte gorge hades lostinspace paradigm reissen ruby stronghold suburb venice"
ASSET_CTF_MAPS: "dust2 forge reissen stronghold suburb"

steps:
- name: Check out the full source fork
Expand Down Expand Up @@ -54,24 +59,77 @@ jobs:
make -C pkg/enet/enet clean
make -C pkg/enet/enet

- name: Build game assets
env:
ASSET_MAPS: "complex dust2 turbine"
- name: Build and verify curated game maps
run: ./scripts/build assets

- name: Verify map manifest and server rotations
run: |
python3 - <<'PY'
import json
import re
from pathlib import Path

manifest = json.loads(Path("assets/dist/packaged-maps.json").read_text())
packaged = manifest["maps"]
if len(packaged) != 20:
raise SystemExit(f"expected exactly 20 packaged maps, got {len(packaged)}")
if len(packaged) != len(set(packaged)):
raise SystemExit("packaged map manifest contains duplicates")

# Validate only actual map fields. A quoted list item consisting solely
# of a Sauerbraten-style identifier is a map rotation entry; asset URLs
# and mapping entries such as '- preset:' cannot match this pattern.
referenced = set()
for line in Path("pterodactyl.yaml").read_text().splitlines():
default_match = re.fullmatch(r'\s*defaultMap:\s*"([a-z0-9_-]+)"\s*', line)
if default_match:
referenced.add(default_match.group(1))
list_match = re.fullmatch(r'\s*-\s*"([a-z0-9_-]+)"\s*', line)
if list_match:
referenced.add(list_match.group(1))

missing = sorted(referenced - set(packaged))
if missing:
raise SystemExit("server configuration references unpackaged maps: " + ", ".join(missing))

expected_categories = {
"ffa": 17,
"capture": 12,
"ctf": 5,
}
for category, expected in expected_categories.items():
actual = len(manifest[category])
if actual != expected:
raise SystemExit(f"expected {expected} {category} maps, got {actual}")

print(f"verified {len(packaged)} real packaged maps and {len(referenced)} configured map references")
PY

- name: Build WebAssembly game engine
run: ./scripts/build game

- name: Build web client
run: ./scripts/build web

- name: Set up Go for multiplayer regression tests
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Run multiplayer protocol and relay regressions
run: |
go test ./pkg/gameserver/relay -count=20
go test ./pkg/game/protocol -run TestBotPositionUsesUnsignedClientNumber -count=20

- name: Build Go multiplayer backend
run: ./scripts/build server

- name: Verify the Linux server binary
run: |
test -x bin/sour
test -f assets/dist/.index.source
test -f assets/dist/packaged-maps.json
test -f pkg/server/static/site/index.html
file bin/sour
ldd bin/sour || true
Expand Down Expand Up @@ -116,7 +174,7 @@ jobs:

cp bin/sour release/void-arena/bin/sour
cp -a assets/dist release/void-arena/assets/dist
cp start.sh package.json pterodactyl.yaml PTERODACTYL.md LICENSE release/void-arena/
cp start.sh package.json pterodactyl.yaml PTERODACTYL.md MAPS.md LICENSE release/void-arena/
chmod +x release/void-arena/bin/sour release/void-arena/start.sh

(
Expand Down
48 changes: 48 additions & 0 deletions MAPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Void Arena map pack

This build packages 20 maps from the stock Cube 2: Sauerbraten multiplayer catalog used by the pinned r6481 game assets. The build rejects unknown names and fails if any requested `.ogz` map cannot actually be packaged, so a release cannot silently ship a menu entry without its map data.

## Free For All / Instagib / Efficiency

- academy
- complex
- dust2
- frostbyte
- gorge
- hades
- injustice
- lostinspace
- metl3
- metl4
- oasis
- paradigm
- phosgene
- ruby
- stronghold
- suburb
- turbine

## Capture

- dust2
- forge
- frostbyte
- gorge
- hades
- lostinspace
- paradigm
- reissen
- ruby
- stronghold
- suburb
- venice

## Capture the Flag

- dust2
- forge
- reissen
- stronghold
- suburb

The native in-game map menus are generated from these same lists during the asset build. `assets/dist/packaged-maps.json` records the exact manifest included in a release.
152 changes: 120 additions & 32 deletions assets/ci
Original file line number Diff line number Diff line change
Expand Up @@ -14,64 +14,152 @@ mkdir -p cache

./setup

outdir=${ASSET_OUTPUT_DIR:-output}
asset_maps=${ASSET_MAPS:-"complex dust2 turbine"}
read -r -a packaged_maps <<< "$asset_maps"

if [ "${#packaged_maps[@]}" -eq 0 ]; then
echo "ASSET_MAPS did not contain any maps" >&2
exit 1
fi

# Sauerbraten loads autoexec.cfg after its stock menu catalog. Generate that
# native config from the same exact map list passed to the asset packager, so
# every in-game map picker only exposes maps that are actually in this build.
PACKAGED_MAPS="$asset_maps" python3 - <<'PY'
outdir=${ASSET_OUTPUT_DIR:-dist}
asset_maps=${ASSET_MAPS:-"complex turbine dust2"}
ffa_maps=${ASSET_FFA_MAPS:-"$asset_maps"}
capture_maps=${ASSET_CAPTURE_MAPS:-""}
ctf_maps=${ASSET_CTF_MAPS:-""}

# Validate the curated manifest against the stock Sauerbraten catalog before
# downloading anything. This prevents typos or invented map names from silently
# entering a release. Mode-specific lists must also be subsets of ASSET_MAPS.
PACKAGED_MAPS="$asset_maps" \
FFA_MAPS="$ffa_maps" \
CAPTURE_MAPS="$capture_maps" \
CTF_MAPS="$ctf_maps" \
python3 - <<'PY'
import os
import re
from pathlib import Path

maps = []
for value in os.environ["PACKAGED_MAPS"].split():
value = value.strip()
if value and value not in maps:
maps.append(value)

if not maps:
raise SystemExit("packaged map manifest is empty")
def manifest_words(name: str) -> list[str]:
raw = [value.strip() for value in os.environ.get(name, "").split() if value.strip()]
if len(raw) != len(set(raw)):
duplicates = sorted({value for value in raw if raw.count(value) > 1})
raise SystemExit(f"{name} contains duplicate map names: {', '.join(duplicates)}")
return raw


packaged = manifest_words("PACKAGED_MAPS")
ffa = manifest_words("FFA_MAPS")
capture = manifest_words("CAPTURE_MAPS")
ctf = manifest_words("CTF_MAPS")

if not packaged:
raise SystemExit("ASSET_MAPS did not contain any maps")

for label, values in (("FFA", ffa), ("Capture", capture), ("CTF", ctf)):
missing = sorted(set(values) - set(packaged))
if missing:
raise SystemExit(f"{label} map list contains maps not in ASSET_MAPS: {', '.join(missing)}")

menus = Path("sour/data/menus.cfg").read_text()
stock: set[str] = set()
for prefix in ("ffamaps", "capturemaps", "ctfmaps"):
for match in re.finditer(rf'^{prefix}\d*\s*=\s*"([^"]*)"', menus, re.MULTILINE):
stock.update(match.group(1).split())

unknown = sorted(set(packaged) - stock)
if unknown:
raise SystemExit(
"ASSET_MAPS contains names that are not in the stock Sauerbraten multiplayer catalog: "
+ ", ".join(unknown)
)

catalog = " ".join(maps)
catalog = " ".join(packaged)
lines = [
"// Generated by assets/ci from ASSET_MAPS. Do not edit manually.",
"// Generated by assets/ci from the validated packaged-map manifest.",
"// Do not edit manually.",
f'packagedmaps = "{catalog}"',
'ffamaps1 = $packagedmaps',
f'ffamaps1 = "{" ".join(ffa)}"',
]

for index in range(2, 12):
lines.append(f'ffamaps{index} = ""')
for index in range(1, 8):

lines.append(f'capturemaps1 = "{" ".join(capture)}"')
for index in range(2, 8):
lines.append(f'capturemaps{index} = ""')
for index in range(1, 8):

lines.append(f'ctfmaps1 = "{" ".join(ctf)}"')
for index in range(2, 8):
lines.append(f'ctfmaps{index} = ""')

lines.extend([
'conceptmaps = ""',
'spmaps = ""',
'rpgmaps = ""',
'allffamaps = $packagedmaps',
'allcapturemaps = ""',
'allctfmaps = ""',
'allffamaps = $ffamaps1',
'allcapturemaps = $capturemaps1',
'allctfmaps = $ctfmaps1',
'allmaps = $packagedmaps',
'',
])

path = Path("sour/autoexec.cfg")
path.write_text("\n".join(lines))
print(f"native packaged map catalog ({len(maps)}): {catalog}")
Path("sour/autoexec.cfg").write_text("\n".join(lines))
print(f"validated packaged maps ({len(packaged)}): {catalog}")
print(f"FFA ({len(ffa)}): {' '.join(ffa)}")
print(f"Capture ({len(capture)}): {' '.join(capture)}")
print(f"CTF ({len(ctf)}): {' '.join(ctf)}")
PY

read -r -a packaged_maps <<< "$asset_maps"

# A clean output directory makes every release reproducible and prevents stale
# map bundles from an older build from surviving after the manifest changes.
rm -rf "$outdir"
mkdir -p "$outdir"

python3 base.py \
--root https://static.sourga.me/blobs/6481/.index.source \
--models \
--download \
--outdir dist \
--outdir "$outdir" \
"${packaged_maps[@]}"

PACKAGED_MAPS="$asset_maps" \
FFA_MAPS="$ffa_maps" \
CAPTURE_MAPS="$capture_maps" \
CTF_MAPS="$ctf_maps" \
MAP_OUTPUT_DIR="$outdir" \
python3 - <<'PY'
import json
import os
from pathlib import Path

import cbor2


def words(name: str) -> list[str]:
return [value for value in os.environ.get(name, "").split() if value]


output = Path(os.environ["MAP_OUTPUT_DIR"])
requested = words("PACKAGED_MAPS")
with (output / ".index.source").open("rb") as handle:
index = cbor2.load(handle)

built = [entry["name"] for entry in index.get("maps", [])]
missing = [name for name in requested if name not in built]
unexpected = [name for name in built if name not in requested]
if missing or unexpected or len(built) != len(requested):
details = []
if missing:
details.append("missing: " + ", ".join(missing))
if unexpected:
details.append("unexpected: " + ", ".join(unexpected))
if len(built) != len(requested):
details.append(f"requested {len(requested)} but built {len(built)}")
raise SystemExit("packaged map verification failed (" + "; ".join(details) + ")")

manifest = {
"maps": requested,
"ffa": words("FFA_MAPS"),
"capture": words("CAPTURE_MAPS"),
"ctf": words("CTF_MAPS"),
}
path = output / "packaged-maps.json"
path.write_text(json.dumps(manifest, indent=2) + "\n")
print(f"verified all {len(built)} requested maps and wrote {path}")
PY
Loading
Loading