Commit
Theria
feat: stable/beta update channel toggle, saved and applied on next launch
modified .github/workflows/release-build.yml
@@ -48,11 +48,36 @@ jobs:
test -s build/Theria.exe
test -s build/Theria-macos.zip
ls -la build
- name: Export game.pck
run: |
# The Stable channel pulls game.pck + manifest.json from the TAGGED release (the Beta
# channel pulls them from the rolling playtest pre-release, published by ci.yml). A
# tagged release that carried only launchers would leave Stable with nothing to pull,
# so it ships the same payload pair the rolling channel does — exported the same way.
./godot --headless --export-pack "Windows Desktop" build/game.pck
test -s build/game.pck
- 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. Kept
# in lockstep with the same literal in ci.yml's publish-pck job.
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: 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
- name: Attach launchers and the Stable payload to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -61,4 +86,6 @@ jobs:
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
gh release upload "$tag" \
build/Theria-windows.zip build/Theria-macos.zip \
build/game.pck build/manifest.json --clobber
modified CHANGELOG.md
@@ -25,6 +25,14 @@ protocol version.
## [Unreleased]
### Added
- A **Stable / Beta update channel** toggle in Settings. Beta (the default) keeps every new
build arriving automatically, as before; Stable follows only cut releases, for testers who
want a steadier client between releases. The choice is saved and applies on the next launch,
and a tagged release now ships the same auto-update payload the rolling channel does, so the
Stable channel has a build to follow.
## [v0.2.0] — 2026-06-16
### Added
modified src/client/boot.gd
@@ -1,7 +1,7 @@
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
## windowed launch it checks the player's chosen update 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.
##
@@ -35,6 +35,9 @@ func _ready() -> void:
_hand_off()
return
_updater = Updater.new()
# Point the updater at the player's saved channel (Stable or Beta) before it probes; an
# unset or corrupt choice falls back to the default inside Settings.
_updater.channel = Settings.update_channel()
add_child(_updater)
if not _updater.should_check():
# Within the throttle window: run the installed payload without a network probe, so a
modified src/client/connect_menu.gd
@@ -39,6 +39,11 @@ const ADDRESS_MIN_WIDTH := 380.0
## names `--bot-difficulty` accepts). Self-contained so the menu stays pure presentation.
const DIFFICULTY_OPTIONS := [["Easy", "easy"], ["Normal", "normal"], ["Hard", "hard"]]
## The update-channel choices, as `[label, channel id]` pairs for the Settings picker. The
## ids mirror `UpdateManifest.CHANNEL_STABLE`/`CHANNEL_BETA`; `Settings` normalises whatever
## is selected, so a label change here can never write an unknown channel.
const CHANNEL_OPTIONS := [["Stable", "stable"], ["Beta (testing)", "beta"]]
## The address used when the player leaves the field blank. The driver injects its
## own default so the menu and the `--join` flag resolve to one value.
var default_address := "127.0.0.1"
@@ -61,8 +66,8 @@ 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.
## The Settings dialog, built on first open. Carries the update-channel toggle today;
## video/audio options join it as they land.
var _settings_dialog: AcceptDialog
@@ -197,17 +202,62 @@ func _build_id() -> String:
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.
## Opens the Settings dialog, building it on first use. Today it carries the update-channel
## toggle — Stable (tagged releases only) or Beta (every main build) — written straight to
## `user://settings.cfg` and applied on the next launch; video/audio options join it later.
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."
_settings_dialog = _build_settings_dialog()
add_child(_settings_dialog)
_settings_dialog.popup_centered()
## Builds the Settings dialog: a labelled update-channel picker over a hint that the choice
## takes effect on the next launch. The picker starts on the saved channel (via `Settings`)
## and writes each new choice straight back, so closing the dialog needs no Save step. Themed
## with the shared UiTheme so the popup reads as the same product as the menu behind it.
func _build_settings_dialog() -> AcceptDialog:
var dialog := AcceptDialog.new()
dialog.title = "Settings"
dialog.theme = UiTheme.make()
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 12)
var label := Label.new()
label.text = "Update channel"
box.add_child(label)
var picker := OptionButton.new()
for option in CHANNEL_OPTIONS:
picker.add_item(option[0])
picker.set_item_metadata(picker.item_count - 1, option[1])
var saved := Settings.update_channel()
for i in picker.item_count:
if picker.get_item_metadata(i) == saved:
picker.select(i)
break
picker.item_selected.connect(_on_channel_selected.bind(picker))
box.add_child(picker)
var hint := Label.new()
hint.text = "Stable: tagged releases only. Beta: every new build. Applies on next launch."
hint.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
hint.add_theme_font_size_override("font_size", FOOTER_FONT_SIZE)
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hint.custom_minimum_size = Vector2(ADDRESS_MIN_WIDTH, 0)
box.add_child(hint)
dialog.add_child(box)
return dialog
## Persists the picked update channel. `Settings` normalises the id, so the metadata carried
## by the selected item is written verbatim; the boot scene reads it on the next launch.
func _on_channel_selected(index: int, picker: OptionButton) -> void:
Settings.set_update_channel(picker.get_item_metadata(index))
## Fills the hero picker from the tribe rosters — one item per hero, labelled
## "Tribe — Hero", carrying its kit id as metadata — and selects `default_hero` (or the
## first hero when none was injected). Reading `AbilityData.TRIBE` keeps the menu's roster
added src/client/settings.gd
@@ -0,0 +1,39 @@
class_name Settings
extends RefCounted
## Player-chosen options that outlive a launch, kept in a single `ConfigFile` at the
## `user://` root — a sibling of the updater's payload sandbox, so a pck swap (which only
## ever writes under `UpdateManifest.PAYLOAD_DIR`) can never wipe them. Today it holds the
## one update-channel choice; video and audio options join it as they land.
##
## Static-only, like `UpdateManifest`: no instance, no node, just typed reads and writes
## over the config file, so callers (the boot scene, the Settings panel) touch settings
## without owning any state. Every read tolerates a missing or corrupt file by returning the
## default — settings are a convenience, never a thing whose absence should stop a launch.
## The settings file, at the user:// root so it survives a payload swap and a reinstall of
## the same client. Created on the first write; absent until the player changes something.
const PATH := "user://settings.cfg"
const UPDATE_SECTION := "update"
const CHANNEL_KEY := "channel"
## The player's saved update channel, normalised to a known id, defaulting to
## `UpdateManifest.CHANNEL_DEFAULT` when nothing is saved yet or the file is unreadable.
## The boot scene reads this to point the updater at the right channel before it checks.
static func update_channel() -> String:
var cfg := ConfigFile.new()
if cfg.load(PATH) != OK:
return UpdateManifest.CHANNEL_DEFAULT
var stored: Variant = cfg.get_value(UPDATE_SECTION, CHANNEL_KEY, UpdateManifest.CHANNEL_DEFAULT)
return UpdateManifest.normalize_channel(str(stored))
## Persists the chosen update channel, normalised so only a known id is ever written.
## Loads first so any other section (future video/audio settings) is preserved across the
## write; a missing file simply starts empty. The change takes effect on the next launch,
## when the boot scene reads it back.
static func set_update_channel(channel: String) -> void:
var cfg := ConfigFile.new()
cfg.load(PATH) # ignore the result: a missing file just leaves the config empty
cfg.set_value(UPDATE_SECTION, CHANNEL_KEY, UpdateManifest.normalize_channel(channel))
cfg.save(PATH)
added src/client/settings.gd.uid
@@ -0,0 +1 @@
uid://b407w7ah1pe1p
modified src/update/update_manifest.gd
@@ -38,6 +38,17 @@ const LAST_CHECK_PATH := PAYLOAD_DIR + "/.last_check"
## VERSION file; read to gate a pck whose `min_client` outruns this binary.
const CLIENT_VERSION_PATH := "res://VERSION"
## The two update channels the player can pick between (persisted by `Settings`). Beta
## pulls the rolling `playtest` pre-release — a fresh pck per green push to main; Stable
## pulls the latest tagged, non-prerelease GitHub Release, so it moves only on a cut
## release. Beta is the default: it is the channel the updater shipped on, and the one
## with a payload before the first Stable target is tagged.
const CHANNEL_BETA := "beta"
const CHANNEL_STABLE := "stable"
const CHANNEL_DEFAULT := CHANNEL_BETA
## The rolling pre-release tag the Beta channel pulls (the only channel before v0.2.0).
const BETA_TAG := "playtest"
## 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,
@@ -154,3 +165,21 @@ static func client_version() -> String:
## bundled seed; absent, the client runs the seed it shipped with.
static func has_payload() -> bool:
return FileAccess.file_exists(PCK_PATH)
## The GitHub releases-API path segment a channel resolves to, appended to
## `repos/<owner>/<name>/`. Beta names the rolling pre-release by its tag; Stable asks for
## `releases/latest`, which GitHub defines as the newest non-prerelease, non-draft release
## — so the `playtest` pre-release is invisible to Stable and only a cut tag moves it. An
## unrecognised channel falls back to Beta's path, so a corrupt setting still checks.
static func release_path(channel: String) -> String:
if channel == CHANNEL_STABLE:
return "releases/latest"
return "releases/tags/" + BETA_TAG
## Coerces any stored or passed channel string to a known channel id, defaulting to Beta
## for anything but an exact Stable — so a hand-edited or future-written settings value can
## never put the updater in an undefined channel.
static func normalize_channel(channel: String) -> String:
return CHANNEL_STABLE if channel == CHANNEL_STABLE else CHANNEL_BETA
modified src/update/updater.gd
@@ -1,7 +1,7 @@
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,
## The network half of the in-client auto-updater: it reaches the player's chosen release
## channel 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.
@@ -32,9 +32,6 @@ signal applied(ok: bool)
## 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 = [
@@ -52,6 +49,12 @@ const CHECK_INTERVAL_S := 86400.0
const REQUEST_TIMEOUT_S := 10.0
const DOWNLOAD_TIMEOUT_S := 120.0
## Which release channel the updater pulls, set by the boot scene from the player's saved
## choice before the check runs. Beta (the default) pulls the rolling `playtest` pre-release
## — a fresh pck per push to main; Stable pulls the latest tagged release. Mapped to the
## GitHub releases-API path by `UpdateManifest.release_path`.
var channel := UpdateManifest.CHANNEL_DEFAULT
var _http: HTTPRequest
## True while a pck download is in flight, so `_process` emits progress only then.
var _downloading := false
@@ -235,7 +238,8 @@ func _asset_urls(release_data: Variant) -> Dictionary:
func _release_url() -> String:
return "https://api.github.com/repos/%s/%s/releases/tags/%s" % [REPO_OWNER, REPO_NAME, CHANNEL_TAG]
var path := UpdateManifest.release_path(channel)
return "https://api.github.com/repos/%s/%s/%s" % [REPO_OWNER, REPO_NAME, path]
## Creates the payload sandbox if absent. Returns false on a filesystem error, which
modified test/unit/test_connect_menu.gd
@@ -107,3 +107,17 @@ func test_blank_address_falls_back_to_the_default() -> void:
watch_signals(menu)
menu._on_join_pressed()
assert_signal_emitted_with_parameters(menu, "join_requested", ["203.0.113.9"])
func test_settings_dialog_preselects_the_saved_channel() -> void:
# The Settings dialog builds its channel picker on the saved channel, so reopening it
# always shows the player's current choice rather than resetting to a default.
var menu := _menu()
var dialog := menu._build_settings_dialog()
add_child_autoqfree(dialog)
var picker: OptionButton = dialog.find_children("", "OptionButton", true, false)[0]
assert_eq(
picker.get_item_metadata(picker.selected),
Settings.update_channel(),
"the channel picker opens on the saved channel"
)
added test/unit/test_settings.gd
@@ -0,0 +1,49 @@
extends GutTest
## Round-trip checks on the persisted player settings — today the one update-channel choice.
## They write the real settings file under user://, so each test brackets itself by stashing
## and restoring whatever was there, leaving a developer's own saved channel untouched.
var _saved: String
# Stash and clear the real settings file so each test starts from "nothing saved".
func before_each() -> void:
if FileAccess.file_exists(Settings.PATH):
_saved = FileAccess.get_file_as_string(Settings.PATH)
DirAccess.remove_absolute(Settings.PATH)
else:
_saved = ""
# Restore the developer's file, or remove the one a test created.
func after_each() -> void:
if _saved.is_empty():
DirAccess.remove_absolute(Settings.PATH)
return
var f := FileAccess.open(Settings.PATH, FileAccess.WRITE)
if f != null:
f.store_string(_saved)
func test_default_channel_when_nothing_saved() -> void:
var default := UpdateManifest.CHANNEL_DEFAULT
assert_eq(Settings.update_channel(), default, "an unset channel reads as the default")
func test_round_trips_the_chosen_channel() -> void:
Settings.set_update_channel(UpdateManifest.CHANNEL_STABLE)
var stable := Settings.update_channel()
assert_eq(stable, UpdateManifest.CHANNEL_STABLE, "Stable persists and reads back")
Settings.set_update_channel(UpdateManifest.CHANNEL_BETA)
var beta := Settings.update_channel()
assert_eq(beta, UpdateManifest.CHANNEL_BETA, "switching back to Beta persists")
func test_a_corrupt_stored_channel_reads_as_the_default() -> void:
# Simulate a hand-edited file carrying an unknown channel; the read must coerce it to a
# known id rather than handing the updater an undefined channel.
var cfg := ConfigFile.new()
cfg.set_value(Settings.UPDATE_SECTION, Settings.CHANNEL_KEY, "garbage")
cfg.save(Settings.PATH)
var read := Settings.update_channel()
assert_eq(read, UpdateManifest.CHANNEL_BETA, "a corrupt stored id reads as the default")
added test/unit/test_settings.gd.uid
@@ -0,0 +1 @@
uid://c4e21ai4q0x6f
modified test/unit/test_update_manifest.gd
@@ -83,3 +83,38 @@ func test_client_version_reads_the_canonical_file() -> void:
# 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")
func test_release_path_maps_each_channel() -> void:
# Beta names the rolling pre-release by its tag; Stable asks GitHub for the latest
# non-prerelease release, which the playtest pre-release is invisible to.
assert_eq(
UpdateManifest.release_path(UpdateManifest.CHANNEL_BETA),
"releases/tags/playtest",
"Beta pulls the rolling playtest pre-release by tag"
)
assert_eq(
UpdateManifest.release_path(UpdateManifest.CHANNEL_STABLE),
"releases/latest",
"Stable pulls the latest non-prerelease release"
)
func test_release_path_falls_back_to_beta_for_an_unknown_channel() -> void:
# A corrupt or future-written settings value must still resolve to a real channel rather
# than an undefined API path, so the check never breaks on a bad string.
assert_eq(
UpdateManifest.release_path("garbage"),
"releases/tags/playtest",
"an unknown channel checks Beta rather than an undefined path"
)
func test_normalize_channel_coerces_to_a_known_id() -> void:
assert_eq(UpdateManifest.normalize_channel("stable"), UpdateManifest.CHANNEL_STABLE)
assert_eq(UpdateManifest.normalize_channel("beta"), UpdateManifest.CHANNEL_BETA)
assert_eq(
UpdateManifest.normalize_channel("anything else"),
UpdateManifest.CHANNEL_BETA,
"anything but an exact Stable defaults to Beta, never an undefined channel"
)