ajhahn.de
← Theria commits

Commit

Theria

feat: self-updating client, themed title screen, rolling pck delivery

ajhahnde · Jun 2026 · 909a9035cd8d2acbed2edc5669cca9d29e5c4931 · parent: a6b88f2 · view on GitHub →

modified .github/workflows/ci.yml
@@ -38,3 +38,59 @@ jobs:
run: ./godot --headless --import
- name: Run tests
run: ./godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test -ginclude_subdirs -gexit
publish-pck:
name: publish playtest pck
needs: [lint, test]
# Only after green lint+test, and only for a push to main — never on a PR. This is the
# rolling channel: every accepted commit re-publishes game.pck, and the in-client updater
# pulls it on the next launch. Exporting a pck needs only the Godot binary, no export
# templates (those are for the full launcher builds in release-build.yml).
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
env:
GODOT_VERSION: "4.6.3"
steps:
- uses: actions/checkout@v5
- name: Download Godot ${{ env.GODOT_VERSION }} (headless)
run: |
base="https://github.com/godotengine/godot-builds/releases/download"
file="Godot_v${GODOT_VERSION}-stable_linux.x86_64"
wget -q "${base}/${GODOT_VERSION}-stable/${file}.zip" -O godot.zip
unzip -q godot.zip
mv "${file}" godot
chmod +x godot
- name: Import project
run: ./godot --headless --import
- name: Export game.pck
run: |
mkdir -p build
./godot --headless --export-pack "Windows Desktop" build/game.pck
test -s build/game.pck # fail loudly if the export produced nothing
- name: Write manifest.json
run: |
# min_client is the OLDEST launcher that can load this pck. Bump it ONLY when the
# launcher contract breaks (the engine line or the autoload/class set changes) — never
# per release — or testers would have to re-download the launcher on every build.
MIN_CLIENT="0.1.0"
VERSION="$(cat VERSION)"
cat > build/manifest.json <<EOF
{
"version": "${VERSION#v}",
"sha": "${GITHUB_SHA}",
"pck": "game.pck",
"min_client": "${MIN_CLIENT}"
}
EOF
cat build/manifest.json
- name: Publish to the rolling playtest pre-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view playtest >/dev/null 2>&1 || \
gh release create playtest --prerelease \
--title "Theria playtest (rolling)" \
--notes "Latest main build, auto-published. The in-client updater pulls game.pck from here on launch."
gh release upload playtest build/game.pck build/manifest.json --clobber
added .github/workflows/release-build.yml
@@ -0,0 +1,64 @@
name: Release build
# Builds the player-facing launcher binaries (Windows + macOS) and attaches them to the
# GitHub Release for the tag. The launcher is the seed build: a full export with boot.tscn as
# its main scene, so it runs offline out of the box and then self-updates by pulling game.pck
# from the rolling `playtest` pre-release (published by ci.yml). Testers download a launcher
# ONCE; new builds arrive as pck updates. Re-run this only when the launcher contract changes
# (the engine line, the autoload/class set) and min_client is bumped — not every release.
#
# Unlike the pck export, full executable exports need the Godot export templates, so this job
# downloads them. The macOS app is unsigned (codesign disabled in export_presets.cfg); testers
# clear the Gatekeeper quarantine per the README. macOS exports fine from a Linux runner.
on:
push:
tags: ["v*"]
workflow_dispatch: # allow a manual launcher build without cutting a new tag
jobs:
launchers:
name: build launchers
runs-on: ubuntu-latest
permissions:
contents: write
env:
GODOT_VERSION: "4.6.3"
steps:
- uses: actions/checkout@v5
- name: Download Godot ${{ env.GODOT_VERSION }} + export templates
run: |
base="https://github.com/godotengine/godot-builds/releases/download"
ver="${GODOT_VERSION}-stable"
wget -q "${base}/${ver}/Godot_v${ver}_linux.x86_64.zip" -O godot.zip
unzip -q godot.zip
mv "Godot_v${ver}_linux.x86_64" godot
chmod +x godot
wget -q "${base}/${ver}/Godot_v${ver}_export_templates.tpz" -O templates.tpz
mkdir -p "$HOME/.local/share/godot/export_templates/${GODOT_VERSION}.stable"
unzip -q templates.tpz
mv templates/* "$HOME/.local/share/godot/export_templates/${GODOT_VERSION}.stable/"
- name: Import project
run: ./godot --headless --import
- name: Export launchers
run: |
mkdir -p build
./godot --headless --export-release "Windows Desktop" build/Theria.exe
./godot --headless --export-release "macOS" build/Theria-macos.zip
test -s build/Theria.exe
test -s build/Theria-macos.zip
ls -la build
- name: Package the Windows launcher
run: |
# embed_pck is on, so Theria.exe is the whole launcher — zip it for a clean download.
cd build && zip -j Theria-windows.zip Theria.exe
- name: Attach launchers to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF_NAME}"
# workflow_dispatch has no tag ref name; fall back to the latest release tag.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
fi
gh release upload "$tag" build/Theria-windows.zip build/Theria-macos.zip --clobber
modified .gitignore
@@ -14,7 +14,9 @@ ajhahnde/
# Build / export artifacts
build/
dist/
export_presets.cfg
# export_presets.cfg is tracked: the CI publish/build jobs export from it, so it must
# live in the repo. It carries no secrets (the macOS preset is unsigned). If a signed
# build is ever added, keep its keystore password out of this file.
# Map editor still-snapshot output (regenerated by tools/map_shot.gd; not source)
map_preview.png
modified CHANGELOG.md
@@ -25,6 +25,27 @@ protocol version.
## [Unreleased]
### Added
- An **in-client auto-updater**, so playtesters install Theria once and get new builds
automatically. The client now opens on a short update screen that checks for the latest
build and, when a newer one is published, downloads it and loads it over the bundled copy
before the match starts — no one re-downloads by hand to stay current. It is offline-safe:
with no connection, a failed download, or an unreachable server it simply starts the build
you already have, and it never touches your settings or saved data. Launches from the command
line (and headless runs) skip the check and go straight into the match.
- A **title screen** in place of the bare connect menu: the Theria wordmark heads a consistent,
themed look shared with the update screen, and a footer shows your build id and update status
— so a bug report can name exactly which build you were on — alongside a Settings button
(video and audio options to come).
### Changed
- Builds are now published automatically. Every accepted change to `main` republishes the
downloadable game package the in-client updater pulls; a tagged release additionally produces
the downloadable Windows and macOS launchers. (macOS builds are unsigned for now — see the
README for the one-time Gatekeeper step.)
## [v0.1.0] — 2026-06-16
### Changed
modified README.md
@@ -44,6 +44,28 @@ rosters are on the field at once. The Verdani fight by attrition: their venom
lingers as damage over time and their webs slow what they catch, a foil to the
Solane's burst. Multi-hero teams over the wire and the art direction come next.
## Playtesting
Theria self-updates, so you install it once and always launch the latest build.
1. Download the launcher for your platform from the
[latest release](https://github.com/ajhahnde/Theria/releases) — `Theria-windows.zip`
(Windows) or `Theria-macos.zip` (macOS).
2. Unzip and run it. On launch it briefly checks for a newer build, downloads it if there is
one, and starts. Every build after that arrives automatically — you never re-download.
It is offline-safe: with no connection it simply starts the build you already have, and an
update never touches your settings or saved data.
**macOS** builds are unsigned for now, so Gatekeeper blocks the first launch. Clear the
quarantine flag once, then open the app normally:
```sh
xattr -dr com.apple.quarantine /path/to/Theria.app
```
Building Theria yourself instead of playtesting a release? See [Running](#running).
## Architecture
The simulation is the single source of truth. `SimCore` is a deterministic,
@@ -76,7 +98,8 @@ delay adapts to the connection's measured jitter rather than being fixed.
| `src/sim` | The authoritative simulation core, its data types, and the data-driven hero ability layer. |
| `src/bot` | Bot input derived from the world state. |
| `src/net` | Listen-server transport, the client/server wire protocol, remote-entity interpolation, and the playtest link-condition simulator. |
| `src/client` | The connect menu, local input sampling, and rendering. |
| `src/client` | The title screen, the boot/update screen, local input sampling, and rendering. |
| `src/update` | The in-client auto-updater — manifest logic and the build download/swap. |
| `test/unit` | Headless tests of the simulation and the wire protocol. |
| `scenes` | Godot scenes. |
| `assets` | Art assets — the placeholder hero models (see [`CREDITS.md`](CREDITS.md)). |
@@ -161,6 +184,13 @@ Both run in continuous integration on every push and pull request.
Apache License 2.0 — see [`LICENSE`](LICENSE). Bundled third-party art assets carry
their own licenses, credited in [`CREDITS.md`](CREDITS.md).
## See also
- [FlashOS](https://github.com/ajhahnde/FlashOS) — AArch64 bare-metal kernel for the Raspberry Pi 4B and QEMU.
- [Flash](https://github.com/ajhahnde/Flash) — a systems language and Zig transpiler.
- [the-way-out](https://github.com/ajhahnde/the-way-out) — top-down pixel-art escape-room shooter.
- [eeco](https://github.com/ajhahnde/eeco) — self-maintaining workflow ecosystem.
---
[Next: Changelog →](CHANGELOG.md)
added export_presets.cfg
@@ -0,0 +1,69 @@
; Export presets for Theria — committed so the CI publish/build jobs can export
; without a developer's editor state. No secrets live here: the macOS preset is
; unsigned (codesign disabled), so testers clear the Gatekeeper quarantine by hand
; (see the README Playtesting section). Sparse [options] blocks lean on each
; platform exporter's defaults; the keys set here are the ones that must not drift.
;
; preset.0 "Windows Desktop" -> Theria.exe (full seed build; the launcher)
; preset.1 "macOS" -> Theria.zip (full seed build; the launcher)
; The rolling game.pck is exported with `--export-pack "Windows Desktop" game.pck`;
; its resources (SVG/PNG lossless, scripts, shaders) carry no platform-specific
; compression, so the one pck loads on both launchers.
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/Theria.exe"
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
binary_format/architecture="x86_64"
binary_format/embed_pck=true
application/product_name="Theria"
application/file_version="0.1.0"
application/product_version="0.1.0"
application/icon="res://icon.svg"
[preset.1]
name="macOS"
platform="macOS"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/Theria.zip"
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
export/distribution_type=0
binary_format/architecture="universal"
application/icon="res://icon.svg"
application/bundle_identifier="de.ajhahn.theria"
application/short_version="0.1.0"
application/version="0.1.0"
codesign/codesign=0
notarization/notarization=0
modified project.godot
@@ -11,7 +11,7 @@ config_version=5
config/name="Theria"
config/version="0.1.0"
run/main_scene="res://scenes/main.tscn"
run/main_scene="res://scenes/boot.tscn"
config/features=PackedStringArray("4.6")
config/icon="res://icon.svg"
added scenes/boot.tscn
@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/client/boot.gd" id="1_boot"]
[node name="Boot" type="Control"]
layout_mode = 3
anchors_preset = 15
script = ExtResource("1_boot")
added src/client/boot.gd
@@ -0,0 +1,171 @@
extends Control
## The client's entry point: a small update screen that runs before the game. On a plain
## windowed launch it checks the rolling `playtest` channel, pulls a newer `game.pck` if one
## is published, then loads that payload over the bundled seed and hands off to the match. It
## is the Godot-native form of the-way-out's launcher loop — the client *is* the launcher, so
## "author pushes, player gets it" needs no second app.
##
## It deliberately gets out of the way for every non-player launch. Headless runs, the
## flag-driven modes (`--host`/`--join`/`--local`), and an explicit `--no-update` all skip the
## check and the UI entirely and go straight to the match, so the CI smokes, the GUT suite,
## and a developer launching a mode directly are untouched by the updater. Only a bare
## double-click of the app sees the update screen.
##
## The hand-off is the load-order contract that makes the overriding pck safe: this scene
## touches only the update/launcher classes, so no game class is cached before
## `load_resource_pack` overrides the bundled files; the game's scripts and scenes are loaded
## fresh from the pck the moment `change_scene_to_file` runs.
## The mode flags `main.gd` reads. Their presence means "a specific run was requested" — skip
## the updater and let `main.gd` pick the mode up itself from the same command line.
const MODE_FLAGS: PackedStringArray = ["--host", "--join", "--local"]
const MAIN_SCENE := "res://scenes/main.tscn"
## How long a transient status (up to date, offline, failed) lingers before the hand-off, so
## the player can read it instead of seeing a flash. Kept short — this is a launch, not a wait.
const STATUS_LINGER_S := 0.7
var _status: Label
var _bar: ProgressBar
var _updater: Updater
func _ready() -> void:
_build_ui()
if _should_skip():
_hand_off()
return
_updater = Updater.new()
add_child(_updater)
if not _updater.should_check():
# Within the throttle window: run the installed payload without a network probe, so a
# slow or captive link does not stall the launch by the request timeout.
_hand_off()
return
_updater.check_done.connect(_on_check_done)
_updater.download_progress.connect(func(r: float): _bar.value = r)
_updater.applied.connect(_on_applied)
_status.text = "Checking for updates…"
_updater.check()
## True when this launch should bypass the updater: an editor run (the developer path — F5 or
## `godot --path .` — never self-updates), a headless run (no display, the automated smokes), an
## explicit `--no-update`, or any mode flag (a deliberate flag-driven launch that wants the match
## now). Only a plain launch of an exported build reaches the update check.
func _should_skip() -> bool:
if OS.has_feature("editor"):
return true
if DisplayServer.get_name() == "headless":
return true
var args := OS.get_cmdline_user_args()
if args.has("--no-update"):
return true
for flag in MODE_FLAGS:
if args.has(flag):
return true
return false
## The check finished. Offline or a client too old to load the build runs the installed
## payload; a newer, loadable build is applied (with a progress bar); otherwise we are up to
## date. Each terminal branch records a one-word status for the title-screen footer and hands
## off; the apply branch waits for `applied` instead.
func _on_check_done(available: bool, info: Dictionary) -> void:
if info.get("offline", false):
_finish("offline", "Offline — starting installed build")
return
if info.get("needs_client_upgrade", false):
_finish("client update required", "A new Theria is out — please re-download the client")
return
if available:
_status.text = "Updating to %s…" % _label_for(info)
_bar.visible = true
_updater.apply(info)
return
_finish("up to date", "Up to date")
## An apply attempt finished. On success the freshly swapped pck is live and the footer reads
## "updated"; on failure the install is untouched (the updater left it as it was) and we start
## what we have. Either way the hand-off loads whatever pck is now installed.
func _on_applied(ok: bool) -> void:
if ok:
_finish("updated", "Updated — starting Theria")
else:
_finish("update failed", "Update failed — starting installed build")
## Records the footer status, shows the message briefly, then hands off — the single exit for
## every terminal branch so the screen always lingers the same beat before the match.
func _finish(status: String, message: String) -> void:
Engine.set_meta(UiTheme.STATUS_META, status)
_status.text = message
_bar.visible = false
await get_tree().create_timer(STATUS_LINGER_S).timeout
_hand_off()
## Loads the installed payload over the bundled seed, then changes to the match scene. With no
## payload (a fresh install that has not updated, or an offline first run) the client simply
## runs the seed it shipped with. A failed pack load is non-fatal for the same reason — the
## bundled scene is still there to fall back to.
func _hand_off() -> void:
if UpdateManifest.has_payload():
ProjectSettings.load_resource_pack(UpdateManifest.PCK_PATH)
# Deferred: a skip-path hand-off runs inside `_ready`, when the tree is mid-add and a
# synchronous scene change is refused; deferring runs it on the next idle frame instead.
get_tree().change_scene_to_file.call_deferred(MAIN_SCENE)
## A human label for the build being installed: its version when the manifest carried one,
## else a short sha, so the "Updating to …" line always names something.
func _label_for(info: Dictionary) -> String:
var version: String = info.get("version", "")
if not version.is_empty():
return version
return (info.get("sha", "") as String).substr(0, 7)
## Lays out the update screen: a full-bleed jungle backdrop, the wordmark, a status line, and
## a hidden download bar that only shows while a pck is being fetched. Built in code with the
## shared theme so it reads as the same product as the title screen behind it.
func _build_ui() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
theme = UiTheme.make()
var backdrop := ColorRect.new()
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
backdrop.color = UiTheme.BG
backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(backdrop)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 28)
box.alignment = BoxContainer.ALIGNMENT_CENTER
center.add_child(box)
var wordmark := UiTheme.wordmark()
if wordmark != null:
var logo := TextureRect.new()
logo.texture = wordmark
logo.custom_minimum_size = Vector2(520, 0)
logo.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
logo.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
box.add_child(logo)
_status = Label.new()
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
box.add_child(_status)
_bar = ProgressBar.new()
_bar.custom_minimum_size = Vector2(420, 0)
_bar.min_value = 0.0
_bar.max_value = 1.0
_bar.show_percentage = false
_bar.visible = false
box.add_child(_bar)
added src/client/boot.gd.uid
@@ -0,0 +1 @@
uid://bplq0f5sl7jgg
modified src/client/connect_menu.gd
@@ -24,16 +24,13 @@ signal practice_requested(hero: String, difficulty: String)
## Menu styling. An opaque backdrop covers the whole viewport so the debug map and its
## jungle camps — drawn behind the menu in world space — do not bleed through the otherwise
## transparent controls; the card sits on top as a framed panel, so the menu reads as UI
## rather than floating text over the arena.
const BACKDROP_COLOR := Color(0.07, 0.08, 0.10)
const CARD_COLOR := Color(0.13, 0.14, 0.17)
const CARD_PADDING := 52.0
const CARD_CORNER := 14
## transparent controls; the card sits on top as a framed panel drawn with the shared UiTheme,
## so the menu reads as one product with the boot screen rather than as floating text over the
## arena. The header is the Theria wordmark in place of a text title.
const CARD_MIN_WIDTH := 680.0
## The menu-wide base font size; every control inherits it, the title overrides larger.
const BASE_FONT_SIZE := 28
const TITLE_FONT_SIZE := 72
const WORDMARK_WIDTH := 520.0
const TITLE_FALLBACK_SIZE := 72
const FOOTER_FONT_SIZE := 18
const BUTTON_MIN_SIZE := Vector2(560, 76)
const ADDRESS_MIN_WIDTH := 380.0
@@ -64,23 +61,23 @@ var _hero_picker: OptionButton
## Picks the bot skill level for a practice match; each item carries its level name as
## metadata, emitted on the Practice choice.
var _difficulty_picker: OptionButton
## The Settings dialog, built on first open — a placeholder panel until video/audio options
## land, so the ⚙ Settings affordance exists without yet owning any settings.
var _settings_dialog: AcceptDialog
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
# A menu-wide theme bumps the base font so every control — the labels, the dropdown, the
# buttons, the address field — scales up together; the title overrides larger still.
var ui_theme := Theme.new()
ui_theme.default_font_size = BASE_FONT_SIZE
theme = ui_theme
# The shared theme styles every control — the wordmark header, the labels, the dropdowns,
# the buttons, the address field — so the menu reads as one product with the boot screen.
theme = UiTheme.make()
# An opaque backdrop, behind everything, so the world drawn in screen space behind the
# menu does not show through the transparent controls. Ignores the mouse so it never
# eats a click meant for a button below it in the tree.
var backdrop := ColorRect.new()
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
backdrop.color = BACKDROP_COLOR
backdrop.color = UiTheme.BG
backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(backdrop)
@@ -88,10 +85,10 @@ func _ready() -> void:
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
# A framed card so the controls read against a solid panel rather than the arena. The
# stylebox is set explicitly so the look does not depend on the active editor theme.
# A framed card so the controls read against a solid panel rather than the arena, drawn
# with the shared card style so it matches the boot screen.
var card := PanelContainer.new()
card.add_theme_stylebox_override("panel", _card_style())
card.add_theme_stylebox_override("panel", UiTheme.card_style())
card.custom_minimum_size = Vector2(CARD_MIN_WIDTH, 0)
center.add_child(card)
@@ -99,11 +96,7 @@ func _ready() -> void:
box.add_theme_constant_override("separation", 18)
card.add_child(box)
var title := Label.new()
title.text = "Theria"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", TITLE_FONT_SIZE)
box.add_child(title)
box.add_child(_header())
var pick_label := Label.new()
pick_label.text = "Practice hero"
@@ -147,15 +140,72 @@ func _ready() -> void:
join_button.pressed.connect(_on_join_pressed)
join_row.add_child(join_button)
## The card's background: a solid dark panel with rounded corners and inner padding, so the
## controls sit on their own surface clear of the arena behind the menu.
func _card_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = CARD_COLOR
style.set_corner_radius_all(CARD_CORNER)
style.set_content_margin_all(CARD_PADDING)
return style
box.add_child(_footer())
## The card header: the Theria wordmark texture, falling back to a large text title if the
## asset is somehow missing, so the menu always names itself.
func _header() -> Control:
var mark := UiTheme.wordmark()
if mark == null:
var title := Label.new()
title.text = "Theria"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", TITLE_FALLBACK_SIZE)
return title
var logo := TextureRect.new()
logo.texture = mark
logo.custom_minimum_size = Vector2(WORDMARK_WIDTH, 0)
logo.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
logo.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
return logo
## The card footer: a muted build line on the left (so a tester can report exactly which
## build they are on) and the Settings affordance on the right, under a divider.
func _footer() -> Control:
var wrap := VBoxContainer.new()
wrap.add_theme_constant_override("separation", 14)
wrap.add_child(HSeparator.new())
var row := HBoxContainer.new()
var build := Label.new()
build.text = _build_id()
build.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
build.add_theme_font_size_override("font_size", FOOTER_FONT_SIZE)
build.size_flags_horizontal = Control.SIZE_EXPAND_FILL
build.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_child(build)
var settings := Button.new()
settings.text = "⚙ Settings"
settings.pressed.connect(_open_settings)
row.add_child(settings)
wrap.add_child(row)
return wrap
## The build line: the client version, the installed pck's short sha (or "seed" when running
## the bundled build), and the boot screen's update outcome when it left one. The footer is
## how a playtester names their build in a report, so it reads off the same sources the
## updater wrote.
func _build_id() -> String:
var sha := UpdateManifest.local_sha()
var build := sha.substr(0, 7) if not sha.is_empty() else "seed"
var parts := PackedStringArray(["v%s" % UpdateManifest.client_version(), "build %s" % build])
var status := str(Engine.get_meta(UiTheme.STATUS_META, ""))
if not status.is_empty():
parts.append(status)
return " · ".join(parts)
## Opens the Settings dialog, building it on first use. A placeholder until video/audio
## options land — the affordance is here so the menu has somewhere to grow them.
func _open_settings() -> void:
if _settings_dialog == null:
_settings_dialog = AcceptDialog.new()
_settings_dialog.title = "Settings"
_settings_dialog.dialog_text = "Settings (video and audio) are coming soon."
add_child(_settings_dialog)
_settings_dialog.popup_centered()
## Fills the hero picker from the tribe rosters — one item per hero, labelled
added src/client/ui_theme.gd
@@ -0,0 +1,113 @@
class_name UiTheme
extends RefCounted
## The client's shared look: one palette and one `Theme` factory the menu screens build
## on, so the boot/update screen and the title screen read as one product rather than two
## code-built panels that drifted apart. Authored in code (not a `.tres`), matching the way
## the rest of the client builds its scenes in code — so the Godot editor, which rewrites
## `project.godot`, is never needed to touch the UI.
##
## The palette is a dark jungle base with a single warm savanna-amber accent — Theria's
## two biomes in two roles: the world is the deep green/charcoal ground the controls sit on,
## the accent is the one colour that marks focus and the active control. Keeping the accent to
## one hue is what makes a code-built UI read as designed rather than busy.
const BG := Color(0.07, 0.09, 0.08) # the deep jungle backdrop behind everything
const PANEL := Color(0.12, 0.14, 0.15) # the card the controls sit on
const PANEL_BORDER := Color(0.20, 0.23, 0.22)
const BUTTON := Color(0.16, 0.19, 0.20)
const BUTTON_HOVER := Color(0.22, 0.26, 0.26)
const BUTTON_PRESSED := Color(0.10, 0.12, 0.13)
const FIELD := Color(0.09, 0.11, 0.11)
const ACCENT := Color(0.95, 0.69, 0.26) # savanna amber — focus, the active edge
const TEXT := Color(0.92, 0.93, 0.94)
const TEXT_MUTED := Color(0.58, 0.62, 0.62) # the footer build/status line
const BASE_FONT_SIZE := 26
const CORNER := 10
const BUTTON_PAD := 16.0
## The wordmark logo, the title screens' header in place of a text title. Loaded by path
## so a caller need not know it is an imported SVG texture.
const WORDMARK_PATH := "res://wordmark.svg"
## Engine-meta key the boot screen writes its update outcome to and the title screen reads
## for its footer. Engine meta outlives a `change_scene_to_file`, so the one word the boot
## learned ("up to date" / "updated" / "offline") survives the hand-off to the menu without
## a file write or a shared autoload.
const STATUS_META := "theria_update_status"
## A fully configured Theme every menu control inherits: button states, the framed panel,
## the address field, the dropdown, and the base font size. Built fresh per caller so a
## screen may tweak its own copy without bleeding into the other.
static func make() -> Theme:
var theme := Theme.new()
theme.default_font_size = BASE_FONT_SIZE
theme.set_stylebox("normal", "Button", _button_style(BUTTON, false))
theme.set_stylebox("hover", "Button", _button_style(BUTTON_HOVER, false))
theme.set_stylebox("pressed", "Button", _button_style(BUTTON_PRESSED, false))
theme.set_stylebox("focus", "Button", _button_style(BUTTON_HOVER, true))
theme.set_color("font_color", "Button", TEXT)
theme.set_color("font_hover_color", "Button", ACCENT)
# OptionButton draws as a Button but keeps its own theme type, so it needs the same
# styleboxes or it falls back to the flat default and looks unthemed next to the buttons.
theme.set_stylebox("normal", "OptionButton", _button_style(BUTTON, false))
theme.set_stylebox("hover", "OptionButton", _button_style(BUTTON_HOVER, false))
theme.set_stylebox("pressed", "OptionButton", _button_style(BUTTON_PRESSED, false))
theme.set_stylebox("focus", "OptionButton", _button_style(BUTTON_HOVER, true))
theme.set_color("font_color", "OptionButton", TEXT)
theme.set_stylebox("normal", "LineEdit", _field_style())
theme.set_color("font_color", "LineEdit", TEXT)
theme.set_color("font_placeholder_color", "LineEdit", TEXT_MUTED)
theme.set_color("caret_color", "LineEdit", ACCENT)
theme.set_stylebox("panel", "PanelContainer", card_style())
return theme
## The framed card the menu controls sit on: a solid dark panel with rounded corners, a
## faint border, and generous inner padding. Public so a screen can reuse the exact card.
static func card_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = PANEL
style.set_corner_radius_all(CORNER + 4)
style.set_border_width_all(1)
style.border_color = PANEL_BORDER
style.set_content_margin_all(48.0)
return style
## A button face in `color`. With `focused`, an amber edge marks the keyboard-focused
## control so the menu is navigable without a mouse.
static func _button_style(color: Color, focused: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.set_corner_radius_all(CORNER)
style.set_content_margin_all(BUTTON_PAD)
if focused:
style.set_border_width_all(2)
style.border_color = ACCENT
return style
## The address field's face: a recessed dark slot with rounded corners, distinct from the
## raised buttons so it reads as an input rather than another button.
static func _field_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = FIELD
style.set_corner_radius_all(CORNER)
style.set_border_width_all(1)
style.border_color = PANEL_BORDER
style.set_content_margin_all(12.0)
return style
## The wordmark texture, or null if it is somehow missing (a caller falls back to a text
## title), so a screen never crashes for a stripped asset.
static func wordmark() -> Texture2D:
if not ResourceLoader.exists(WORDMARK_PATH):
return null
return load(WORDMARK_PATH) as Texture2D
added src/client/ui_theme.gd.uid
@@ -0,0 +1 @@
uid://dmvoayicxkcg8
added src/update/update_manifest.gd
@@ -0,0 +1,156 @@
class_name UpdateManifest
extends RefCounted
## The pure, network-free half of the auto-updater: parsing the published
## `manifest.json`, deciding whether the remote build is newer than the installed
## one, gating an install behind the client's own version, and naming every path
## the updater touches. Split out from `Updater` so all the decision logic stays
## unit-testable with no HTTP, no display, and no scene tree — `Updater` owns the
## `HTTPRequest` and the file swaps and leans on this for the judgements.
##
## A published build is identified by its git commit `sha` (the rolling `main`
## channel re-publishes a fresh `game.pck` per green push). The installed sha is
## written to `.version` after a successful swap, so "is there an update" is a plain
## string compare — exactly the model the-way-out's updater uses, adapted from a
## branch tip to a per-build sha.
##
## Hard safety rule mirrored from that updater: every path here lives under
## `PAYLOAD_DIR` (`user://payload/`). Player data (settings, future saves) lives at
## the `user://` root, a *sibling* of the payload dir, so a pck swap can never reach
## it. Nothing in the updater ever writes outside `PAYLOAD_DIR`.
## The sandbox the updater owns. The live pck, the staged download, the kept-back
## previous pck, and the installed-sha marker all live here; player data never does.
const PAYLOAD_DIR := "user://payload"
## The live game payload the boot scene loads over the bundled seed.
const PCK_PATH := PAYLOAD_DIR + "/game.pck"
## Where a download lands before it is promoted to the live pck — so a failed or
## partial download never replaces a working install.
const PCK_NEW_PATH := PAYLOAD_DIR + "/game.pck.new"
## The previous live pck, kept after a successful swap for manual rollback.
const PCK_PREV_PATH := PAYLOAD_DIR + "/game.pck.prev"
## The git sha of the installed pck, written after a swap; empty/absent before the
## first successful update (the client then runs its bundled seed).
const VERSION_PATH := PAYLOAD_DIR + "/.version"
## Touched after every successful reach to the channel; its mtime throttles the
## cold-start probe (see `Updater.should_check`).
const LAST_CHECK_PATH := PAYLOAD_DIR + "/.last_check"
## The client's own version, the seed it was built from. Mirrors the canonical
## VERSION file; read to gate a pck whose `min_client` outruns this binary.
const CLIENT_VERSION_PATH := "res://VERSION"
## Parses the published manifest JSON into a typed dictionary, tolerating anything
## malformed: a parse failure or a non-object yields `ok = false` and empty fields,
## so a corrupt or truncated manifest degrades to "no update" rather than a crash.
## Returns `{ok, version, sha, pck, min_client}` with string fields defaulted empty.
static func parse(json_text: String) -> Dictionary:
var blank := {"ok": false, "version": "", "sha": "", "pck": "", "min_client": ""}
# JSON.new().parse() returns the error code without pushing an engine error, unlike
# the static JSON.parse_string() — so a malformed manifest stays a quiet "no update".
var json := JSON.new()
if json.parse(json_text) != OK:
return blank
var data: Variant = json.data
if typeof(data) != TYPE_DICTIONARY:
return blank
return {
"ok": true,
"version": _string_field(data, "version"),
"sha": _string_field(data, "sha"),
"pck": _string_field(data, "pck"),
"min_client": _string_field(data, "min_client"),
}
## Reads `key` from a parsed manifest as a trimmed string, defaulting empty when the
## key is missing or not a string — so a number or null in the JSON never propagates.
static func _string_field(data: Dictionary, key: String) -> String:
var value: Variant = data.get(key, "")
if typeof(value) != TYPE_STRING:
return ""
return (value as String).strip_edges()
## True when `remote_sha` names a build we should install: it is non-empty and
## differs from `local_sha`. An empty remote (manifest missing the sha, or offline)
## is never an update; an empty local (nothing installed yet) makes any remote one.
static func is_newer(remote_sha: String, local_sha: String) -> bool:
if remote_sha.is_empty():
return false
return remote_sha != local_sha
## True when this client is new enough to run a pck declaring `min_client`. An empty
## or unparseable `min_client` imposes no floor (true). The gate is the escape hatch
## for a pck built against a changed engine/autoload set: bump `min_client` and old
## launchers refuse the load and ask the player to re-download instead of crashing.
static func client_supported(min_client: String, client_version: String) -> bool:
if min_client.strip_edges().is_empty():
return true
return semver_compare(client_version, min_client) >= 0
## Compares two dotted version strings numerically, ignoring a leading `v` and any
## non-numeric suffix on a part. Returns -1 / 0 / +1 for a < b / a == b / a > b.
## Missing trailing parts read as zero, so "0.1" == "0.1.0".
static func semver_compare(a: String, b: String) -> int:
var pa := _version_parts(a)
var pb := _version_parts(b)
var n: int = maxi(pa.size(), pb.size())
for i in n:
var ai: int = pa[i] if i < pa.size() else 0
var bi: int = pb[i] if i < pb.size() else 0
if ai != bi:
return -1 if ai < bi else 1
return 0
## Splits a version string into integer parts, dropping a leading `v` and reading the
## leading digits of each dotted segment (so "1.2.0-rc1" -> [1, 2, 0]).
static func _version_parts(version: String) -> Array[int]:
var trimmed := version.strip_edges().lstrip("v")
var parts: Array[int] = []
for segment in trimmed.split("."):
parts.append(_leading_int(segment))
return parts
## The integer value of the leading digit run of `segment`, or 0 if it starts with no
## digit — so a tagged or suffixed segment compares on its numeric head alone.
static func _leading_int(segment: String) -> int:
var digits := ""
for c in segment:
if c < "0" or c > "9":
break
digits += c
return digits.to_int() if not digits.is_empty() else 0
## The git sha of the installed pck, read from `.version`, or empty when nothing has
## been installed yet (the client is running its bundled seed). Best-effort: any read
## failure reads as "nothing installed", which simply makes the next check offer an update.
static func local_sha() -> String:
if not FileAccess.file_exists(VERSION_PATH):
return ""
var f := FileAccess.open(VERSION_PATH, FileAccess.READ)
if f == null:
return ""
return f.get_as_text().strip_edges()
## This client's own version, read from the canonical VERSION file baked into the
## build, with a leading `v` stripped so it compares directly against a manifest's
## `min_client`. Empty if the file is somehow absent (treated as the lowest version).
static func client_version() -> String:
if not FileAccess.file_exists(CLIENT_VERSION_PATH):
return ""
var f := FileAccess.open(CLIENT_VERSION_PATH, FileAccess.READ)
if f == null:
return ""
return f.get_as_text().strip_edges().lstrip("v")
## True when a runnable game payload is installed — the boot scene loads it over the
## bundled seed; absent, the client runs the seed it shipped with.
static func has_payload() -> bool:
return FileAccess.file_exists(PCK_PATH)
added src/update/update_manifest.gd.uid
@@ -0,0 +1 @@
uid://det6qe18t327q
added src/update/updater.gd
@@ -0,0 +1,256 @@
class_name Updater
extends Node
## The network half of the in-client auto-updater: it reaches the rolling `playtest`
## release on GitHub, decides whether a newer `game.pck` is published, downloads it,
## and atomically swaps it into the payload sandbox. The boot scene drives it; all the
## judgements (is-it-newer, is-this-client-new-enough, where-do-files-go) live in the
## network-free `UpdateManifest`, kept apart so they stay unit-testable.
##
## This is the Godot-native form of the-way-out's `updater.py` "author pushes, player
## gets it" loop. There the thin launcher pulled a source zip and swapped an `app/`
## dir; here the client *is* the launcher and the churning part is a `game.pck` the
## boot scene loads over the bundled seed. The semantics are deliberately the same:
## throttled cold-start probe, atomic swap via staged download, a kept-back `.prev`
## for rollback, and fail-soft — any network or integrity failure leaves the working
## install untouched and the client runs whatever it already has.
##
## Hard safety rule (from that updater): every write lands under
## `UpdateManifest.PAYLOAD_DIR`. Player data lives at the `user://` root, a sibling,
## and is never touched — a swap cannot wipe settings or a future save.
## A finished update check. `available` is true only when a newer pck is published
## *and* this client is new enough to load it; `info` carries the build details
## (`sha`, `version`, `pck_url`, `min_client`, `needs_client_upgrade`, `offline`).
signal check_done(available: bool, info: Dictionary)
## Download progress as a 0..1 fraction while a pck is being fetched, for the boot bar.
signal download_progress(ratio: float)
## An apply attempt finished — true when the new pck is live, false when the install
## was left as it was (download, integrity, or swap failure).
signal applied(ok: bool)
## The public GitHub repo, owner and name kept apart (joined only in the API URL) so the
## release path is built from parts rather than a bare "owner/name" slug.
const REPO_OWNER := "ajhahnde"
const REPO_NAME := "Theria"
## The rolling channel: a single pre-release re-published on every green push to main,
## so its assets always describe the latest build (see the publish-pck CI job).
const CHANNEL_TAG := "playtest"
const MANIFEST_ASSET := "manifest.json"
## GitHub's API wants a User-Agent or it 403s; Accept pins the stable API media type.
const HEADERS: PackedStringArray = [
"User-Agent: theria-updater",
"Accept: application/vnd.github+json",
]
## How long a Godot .pck's header magic reads as, used to reject a truncated or
## error-page download before it is ever promoted to the live pck.
const PCK_MAGIC := "GDPC"
## Cold-start probe throttle: after a successful reach, skip the launch-time check
## until this many seconds pass, so a slow link does not stall every launch. The
## in-menu "Check now" path bypasses it.
const CHECK_INTERVAL_S := 86400.0
## Per-request ceiling. A JSON call is tiny; the pck download gets the longer window.
const REQUEST_TIMEOUT_S := 10.0
const DOWNLOAD_TIMEOUT_S := 120.0
var _http: HTTPRequest
## True while a pck download is in flight, so `_process` emits progress only then.
var _downloading := false
func _ready() -> void:
_http = HTTPRequest.new()
_http.timeout = REQUEST_TIMEOUT_S
add_child(_http)
## Emits a download fraction each frame while a pck is being fetched. Body size is
## unknown until the server's headers arrive, so it stays quiet until then.
func _process(_delta: float) -> void:
if not _downloading:
return
var total := _http.get_body_size()
if total > 0:
download_progress.emit(clampf(float(_http.get_downloaded_bytes()) / float(total), 0.0, 1.0))
## True when a launch-time probe is worth doing: always on a fresh install (no payload
## yet), otherwise only once `CHECK_INTERVAL_S` has elapsed since the last successful
## reach. Mirrors the-way-out's `should_check` so a captive or slow network does not
## pause every launch by the request timeout.
func should_check() -> bool:
if not UpdateManifest.has_payload():
return true
if not FileAccess.file_exists(UpdateManifest.LAST_CHECK_PATH):
return true
var last := FileAccess.get_modified_time(UpdateManifest.LAST_CHECK_PATH)
return (Time.get_unix_time_from_system() - float(last)) >= CHECK_INTERVAL_S
## Reaches the channel and reports whether an installable update exists, via
## `check_done`. The flow: read the release (for its asset list), read the
## `manifest.json` asset, resolve the pck's download URL from the assets, then judge
## newer-than-installed and client-new-enough. Any unreachable step reports
## `available = false` with `offline = true` so the caller just runs the install it has.
func check() -> void:
var release := await _get_json(_release_url())
if not release["ok"]:
check_done.emit(false, {"offline": true})
return
var assets := _asset_urls(release["data"])
if not assets.has(MANIFEST_ASSET):
# Reached the channel but it carries no manifest yet (e.g. before the first
# publish): a clean "nothing to install", not an error.
_mark_checked()
check_done.emit(false, {})
return
var manifest_resp := await _get_json(assets[MANIFEST_ASSET])
if not manifest_resp["ok"]:
check_done.emit(false, {"offline": true})
return
_mark_checked()
var verdict := _judge(manifest_resp["data"], assets)
check_done.emit(verdict[0], verdict[1])
## Turns a parsed manifest and the release's asset URLs into the `check_done`
## arguments `[available, info]`. An update is offered only when the build is newer
## than the installed sha, its pck asset is actually present, and this client clears
## the pck's `min_client` floor; a newer build this client is too old to load reports
## `available = false` with `needs_client_upgrade = true` so the boot screen can ask
## the player to re-download the client rather than silently doing nothing.
func _judge(manifest_data: Variant, assets: Dictionary) -> Array:
var m := UpdateManifest.parse(JSON.stringify(manifest_data))
var info := {
"sha": m["sha"],
"version": m["version"],
"pck_url": assets.get(m["pck"], ""),
"min_client": m["min_client"],
"needs_client_upgrade": false,
}
var newer := UpdateManifest.is_newer(m["sha"], UpdateManifest.local_sha())
var has_pck := not (info["pck_url"] as String).is_empty()
if newer and not UpdateManifest.client_supported(m["min_client"], UpdateManifest.client_version()):
info["needs_client_upgrade"] = true
return [false, info]
return [newer and has_pck, info]
## Downloads the pck named in `info` into the staging slot, verifies it, and swaps it
## live, reporting the outcome via `applied`. On any failure the existing install is
## left exactly as it was — the download lands in `.new` and is only promoted once it
## verifies. Emits `applied(false)` and returns early without touching the live pck.
func apply(info: Dictionary) -> void:
var url: String = info.get("pck_url", "")
if url.is_empty():
applied.emit(false)
return
if not _ensure_payload_dir():
applied.emit(false)
return
if not await _download(url, UpdateManifest.PCK_NEW_PATH):
applied.emit(false)
return
if not _is_valid_pck(UpdateManifest.PCK_NEW_PATH):
DirAccess.remove_absolute(UpdateManifest.PCK_NEW_PATH)
applied.emit(false)
return
applied.emit(_swap_in(info.get("sha", "")))
## Promotes the staged `.new` pck to the live slot: roll the current live pck to
## `.prev` (for rollback), move `.new` into place, and record the installed sha. The
## live pck does not exist only for the instant between the two renames; both are
## within the payload dir (same filesystem) so each is atomic. Returns false on any
## filesystem error, leaving the staged file behind for the next attempt.
func _swap_in(sha: String) -> bool:
if FileAccess.file_exists(UpdateManifest.PCK_PATH):
DirAccess.remove_absolute(UpdateManifest.PCK_PREV_PATH)
if DirAccess.rename_absolute(UpdateManifest.PCK_PATH, UpdateManifest.PCK_PREV_PATH) != OK:
return false
if DirAccess.rename_absolute(UpdateManifest.PCK_NEW_PATH, UpdateManifest.PCK_PATH) != OK:
return false
var f := FileAccess.open(UpdateManifest.VERSION_PATH, FileAccess.WRITE)
if f == null:
return false
f.store_string(sha)
return true
## A GET that returns its body parsed as JSON: `{ok, data}`. `ok` is false on a
## transport error, a non-200 status, or a body that is not valid JSON, so every
## unreachable or malformed response collapses to one "not ok" the callers handle.
func _get_json(url: String) -> Dictionary:
_http.download_file = "" # in-memory body, not a file
if _http.request(url, HEADERS) != OK:
return {"ok": false}
var result: Array = await _http.request_completed
if result[0] != HTTPRequest.RESULT_SUCCESS or result[1] != 200:
return {"ok": false}
var json := JSON.new()
if json.parse((result[3] as PackedByteArray).get_string_from_utf8()) != OK:
return {"ok": false}
return {"ok": true, "data": json.data}
## Downloads `url` straight to `dest`, returning true on a 200. The longer timeout
## covers the pck (a JSON call uses the default); progress is emitted from `_process`
## while `_downloading` holds. Restores the request timeout on the way out.
func _download(url: String, dest: String) -> bool:
_http.download_file = dest
_http.timeout = DOWNLOAD_TIMEOUT_S
_downloading = true
var ok := false
if _http.request(url, HEADERS) == OK:
var result: Array = await _http.request_completed
ok = result[0] == HTTPRequest.RESULT_SUCCESS and result[1] == 200
_downloading = false
_http.timeout = REQUEST_TIMEOUT_S
_http.download_file = ""
return ok
## True when `path` starts with the Godot pack magic, so a truncated download or an
## HTML error page served in place of the pck is rejected before it is swapped live.
func _is_valid_pck(path: String) -> bool:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
return f.get_buffer(4).get_string_from_ascii() == PCK_MAGIC
## Maps each release asset's file name to its download URL, so a manifest's `pck`
## filename and the manifest asset itself resolve to fetchable URLs. Empty when the
## release JSON carries no asset list.
func _asset_urls(release_data: Variant) -> Dictionary:
var urls := {}
if typeof(release_data) != TYPE_DICTIONARY:
return urls
var assets: Variant = (release_data as Dictionary).get("assets", [])
if typeof(assets) != TYPE_ARRAY:
return urls
for asset in assets:
if typeof(asset) == TYPE_DICTIONARY and asset.has("name"):
urls[asset["name"]] = asset.get("browser_download_url", "")
return urls
func _release_url() -> String:
return "https://api.github.com/repos/%s/%s/releases/tags/%s" % [REPO_OWNER, REPO_NAME, CHANNEL_TAG]
## Creates the payload sandbox if absent. Returns false on a filesystem error, which
## aborts the apply rather than writing outside the dir.
func _ensure_payload_dir() -> bool:
if DirAccess.dir_exists_absolute(UpdateManifest.PAYLOAD_DIR):
return true
return DirAccess.make_dir_recursive_absolute(UpdateManifest.PAYLOAD_DIR) == OK
## Records a successful reach to the channel by touching the throttle marker; best
## effort, since a write failure only means the next launch probes again.
func _mark_checked() -> void:
if not _ensure_payload_dir():
return
var f := FileAccess.open(UpdateManifest.LAST_CHECK_PATH, FileAccess.WRITE)
if f != null:
f.store_string("")
added src/update/updater.gd.uid
@@ -0,0 +1 @@
uid://dils677qxvoma
added test/unit/test_update_manifest.gd
@@ -0,0 +1,85 @@
extends GutTest
## Checks on the updater's decision logic — the network-free half. They cover the
## three judgements the live `Updater` leans on: parsing a published manifest without
## trusting it, deciding a remote build is newer, and gating a pck behind the client's
## own version. No HTTP, no display, no file swaps — `Updater` owns those; this is the
## whole of what can be tested deterministically off the wire.
func test_parse_reads_a_well_formed_manifest() -> void:
var text := '{"version": "0.2.0", "sha": "abc123", "pck": "game.pck", "min_client": "0.1.0"}'
var m := UpdateManifest.parse(text)
assert_true(m["ok"], "a valid object parses ok")
assert_eq(m["sha"], "abc123")
assert_eq(m["version"], "0.2.0")
assert_eq(m["pck"], "game.pck")
assert_eq(m["min_client"], "0.1.0")
func test_parse_rejects_garbage() -> void:
var m := UpdateManifest.parse("not json at all {{{")
assert_false(m["ok"], "unparseable text is not ok")
assert_eq(m["sha"], "", "garbage yields an empty sha, so no update is offered")
func test_parse_rejects_a_non_object() -> void:
# Valid JSON, but an array — a manifest must be an object to read fields from.
var m := UpdateManifest.parse('["abc123"]')
assert_false(m["ok"], "a JSON array is not a manifest")
func test_parse_tolerates_missing_and_mistyped_fields() -> void:
# sha as a number, no min_client at all — both degrade to empty strings rather
# than propagating a wrong type into the swap logic.
var m := UpdateManifest.parse('{"version": "0.2.0", "sha": 5}')
assert_true(m["ok"], "a partial object still parses")
assert_eq(m["sha"], "", "a non-string sha reads as empty")
assert_eq(m["min_client"], "", "an absent field reads as empty")
func test_is_newer_when_shas_differ() -> void:
assert_true(UpdateManifest.is_newer("def456", "abc123"), "a different remote sha is an update")
func test_is_newer_false_when_shas_match() -> void:
assert_false(UpdateManifest.is_newer("abc123", "abc123"), "the same sha is not an update")
func test_is_newer_false_when_remote_empty() -> void:
# Offline or a manifest with no sha: never an update, so the client keeps running
# whatever is installed.
assert_false(UpdateManifest.is_newer("", "abc123"), "an empty remote sha is never an update")
func test_is_newer_when_nothing_installed() -> void:
assert_true(UpdateManifest.is_newer("abc123", ""), "any remote build updates a fresh install")
func test_client_supported_when_floor_met() -> void:
assert_true(UpdateManifest.client_supported("0.1.0", "0.1.0"), "an equal version meets the floor")
assert_true(UpdateManifest.client_supported("0.1.0", "0.2.0"), "a newer client clears the floor")
func test_client_supported_false_when_client_too_old() -> void:
assert_false(
UpdateManifest.client_supported("0.3.0", "0.1.0"),
"a pck needing a newer client is refused, asking the player to re-download"
)
func test_client_supported_with_no_floor() -> void:
assert_true(UpdateManifest.client_supported("", "0.1.0"), "an empty min_client imposes no floor")
func test_semver_compare_orders_and_pads() -> void:
assert_eq(UpdateManifest.semver_compare("0.2.0", "0.1.0"), 1, "a > b")
assert_eq(UpdateManifest.semver_compare("0.1.0", "0.2.0"), -1, "a < b")
assert_eq(UpdateManifest.semver_compare("0.1", "0.1.0"), 0, "missing parts read as zero")
assert_eq(UpdateManifest.semver_compare("v1.2.0", "1.2.0"), 0, "a leading v is ignored")
func test_client_version_reads_the_canonical_file() -> void:
# The build ships res://VERSION ("v0.1.0" today); the leading v is stripped so it
# compares directly against a manifest min_client.
assert_false(UpdateManifest.client_version().is_empty(), "the bundled VERSION is readable")
assert_false(UpdateManifest.client_version().begins_with("v"), "the leading v is stripped")
added test/unit/test_update_manifest.gd.uid
@@ -0,0 +1 @@
uid://duckhl6nlouu5