From 6ab72f2c058e6678f165f06992735b4e22e58f9b Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Thu, 5 Nov 2026 16:56:11 +0000 Subject: [PATCH] Update check: version feed from the git server's releases, title-screen banner, clearer version refusals --- docs/HOSTING.md | 1 + docs/ROADMAP.md | 20 ++++ project.godot | 6 ++ scripts/core/updates.gd | 177 +++++++++++++++++++++++++++++++++++ scripts/net/net.gd | 16 +++- scripts/net/server_config.gd | 1 + scripts/ui/pause_menu.gd | 8 ++ scripts/ui/title_screen.gd | 74 ++++++++++++++- tests/run_all.sh | 2 +- tests/test_updates.gd | 119 +++++++++++++++++++++++ tools/export.sh | 26 ++++- 11 files changed, 446 insertions(+), 4 deletions(-) create mode 100644 scripts/core/updates.gd create mode 100644 tests/test_updates.gd diff --git a/docs/HOSTING.md b/docs/HOSTING.md index f1196ca..00fd69d 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -76,6 +76,7 @@ the ones you'll want first: | `difficulty` | normal | easy / normal / hard | | `sleep` | all_online | or `half`, `anyone`, `off` | | `backups_kept`, `backup_hours` | 10, 6 | see below | +| `check_updates` | true | at start-up, say in the log if a newer Emberwild is out (and whether its protocol differs, so new players couldn't join) | | `allowlist`, `banned`, `admins` | | player ids (use `/ban`, `/admin` rather than editing) | ## Backups diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1d2a68a..4a51947 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -100,6 +100,26 @@ phases M2.0-M2.7). Needed because every network change bumps the protocol (12 as of P5), and an out-of-date game can't join an up-to-date server: today that means `git pull` and a restart on every machine. + +*Built so far (the check):* `scripts/core/updates.gd` asks the feed in +Project Settings (`emberwild/updates/feed`, now the git server's +`releases/latest` API; Gitea, Forgejo and GitHub answer alike) once at +start-up. It reads the release's `version.json` asset (written by +`tools/export.sh`: version, protocol, per-platform file, sha256 and size). +The title screen shows the version bottom left and, when a newer one is +out, a card with its notes and **Download** (this platform's zip, or the +release page). A "Different game version" refusal now names the server's +version and protocol and says whether you or the server needs updating; +a dedicated server says in its log when it's behind (`check_updates` in +server.json). Pause menu: **Check for updates at start**. Tests and bots +never touch the network. Still to do: downloading, verifying and +swapping it in (below). + +**To publish a release:** bump `config/version` in project.godot and the +four version numbers in export_presets.cfg, run +`NOTES="..." tools/export.sh`, and make a release on the git server (tag +`v`) with the two zips and build/version.json attached. The repo +(or at least its releases) must be readable without logging in. - **Knowing there's an update:** the game checks a small version file (version number, protocol, per-platform download URLs and checksums) at start-up and from the title screen. The server checks too. When joining diff --git a/project.godot b/project.godot index 29ddc88..fa24d6c 100644 --- a/project.godot +++ b/project.godot @@ -19,6 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho run/main_scene="res://scenes/title.tscn" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" +config/version="0.1.0" [autoload] @@ -27,6 +28,7 @@ ItemDB="*res://scripts/core/item_db.gd" Actions="*res://scripts/core/actions.gd" Guide="*res://scripts/core/guide.gd" Hints="*res://scripts/core/hints.gd" +Updates="*res://scripts/core/updates.gd" Saves="*res://scripts/core/saves.gd" Net="*res://scripts/net/net.gd" WorldNet="*res://scripts/net/world_net.gd" @@ -87,6 +89,10 @@ weather_wind={ "value": 1.0 } +[emberwild] + +updates/feed="https://git.psbailey.uk/api/v1/repos/Admin/valheimlike/releases/latest" + [rendering] textures/vram_compression/import_etc2_astc=true diff --git a/scripts/core/updates.gd b/scripts/core/updates.gd new file mode 100644 index 0000000..c455cc0 --- /dev/null +++ b/scripts/core/updates.gd @@ -0,0 +1,177 @@ +extends Node +## The update check (docs/ROADMAP.md, M4 "Updates", first step): is there a +## newer Emberwild than this one? Asked once at start-up, of the feed in +## Project Settings (`emberwild/updates/feed`). The feed is either +## - a release API answer (Gitea, Forgejo or GitHub `releases/latest`): its +## `version.json` asset is fetched next, and its other assets are the +## downloads, by file name; or +## - a `version.json` itself: +## {"version": "0.2.0", "protocol": 13, "notes": "...", "page": "https://...", +## "downloads": {"macos": {"file": "Emberwild-0.2.0-macos.zip", "sha256": "...", +## "size": 61164958, "url": "(optional; else the asset)"}, +## "windows": {...}}} +## tools/export.sh writes that file next to the zips, for the release. +## +## This only tells you (title screen, join refusals, the server's log); +## downloading and swapping in the new version is the next step. Off in +## headless runs (tests, bots) unless forced; a dedicated server forces it. +## Your setting (on or off) is in user://updates.json. + +signal checked(state: String) + +const TIMEOUT := 10.0 + +var version: String = String(ProjectSettings.get_setting("application/config/version", "0.0.0")) +var feed: String = String(ProjectSettings.get_setting("emberwild/updates/feed", "")) +var prefs_path := "user://updates.json" +var enabled := true +## idle | off | checking | current | available | failed +var state := "idle" +var latest: Dictionary = {} ## the version.json of the newest release +var assets: Dictionary = {} ## file name -> download URL, from the release +var page := "" ## a web page for the release (or the feed's "page") +var error := "" +## Tests: url -> response text, instead of the network. +var fetch_override: Callable + +func _ready() -> void: + load_prefs() + +func load_prefs() -> void: + enabled = true + if FileAccess.file_exists(prefs_path): + var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(prefs_path)) + if d is Dictionary: + enabled = bool(d.get("check", true)) + +func set_enabled(on: bool) -> void: + enabled = on + var f := FileAccess.open(prefs_path, FileAccess.WRITE) + if f != null: + f.store_string(JSON.stringify({"check": enabled}, "\t")) + +## Asks the feed once. `force`: even headless, even with the setting off (a +## dedicated server, which has its own setting). +func check(force: bool = false) -> void: + if state == "checking": + return + if not force and (not enabled or (DisplayServer.get_name() == "headless" and not fetch_override.is_valid())): + _finish("off") + return + if feed.is_empty(): + error = "no update feed set" + _finish("failed") + return + state = "checking" + latest = {} + assets = {} + page = "" + error = "" + var first: Variant = await _get_json(feed) + if not (first is Dictionary): + _finish("failed") + return + var d: Dictionary = first + if not d.has("version") and d.has("assets"): + # A release: its assets by name, then its version.json. + page = String(d.get("html_url", "")) + for a in d.get("assets", []): + if a is Dictionary and a.has("name") and a.has("browser_download_url"): + assets[String(a.name)] = String(a.browser_download_url) + if not assets.has("version.json"): + error = "the latest release has no version.json" + _finish("failed") + return + var v: Variant = await _get_json(assets["version.json"]) + if not (v is Dictionary): + _finish("failed") + return + d = v + if not d.has("version"): + error = "the feed has no version" + _finish("failed") + return + latest = d + if page.is_empty(): + page = String(d.get("page", "")) + _finish("available" if compare_versions(String(d.version), version) > 0 else "current") + +func _finish(s: String) -> void: + state = s + if s == "failed": + print("[updates] couldn't check for updates: ", error) + elif s == "available": + print("[updates] Emberwild %s is out (this is %s)" % [latest_version(), version]) + checked.emit(s) + +func _get_json(url: String) -> Variant: + var text := "" + if fetch_override.is_valid(): + text = String(fetch_override.call(url)) + else: + var http := HTTPRequest.new() + http.timeout = TIMEOUT + add_child(http) + var err := http.request(url, ["Accept: application/json", "User-Agent: Emberwild/" + version]) + if err != OK: + http.queue_free() + error = "couldn't ask %s (%d)" % [url, err] + return null + var r: Array = await http.request_completed + http.queue_free() + if int(r[0]) != HTTPRequest.RESULT_SUCCESS or int(r[1]) != 200: + error = "%s answered %s" % [url, "HTTP %d" % int(r[1]) if int(r[0]) == HTTPRequest.RESULT_SUCCESS else "nothing (result %d)" % int(r[0])] + return null + text = (r[3] as PackedByteArray).get_string_from_utf8() + var j := JSON.new() # (quietly: a broken feed isn't an engine error) + if j.parse(text) != OK or not (j.data is Dictionary): + error = "%s didn't answer with JSON" % url + return null + return j.data + +# --- what we found -------------------------------------------------------------------- + +func latest_version() -> String: + return String(latest.get("version", "")) + +func latest_protocol() -> int: + return int(latest.get("protocol", 0)) + +## "macos", "windows" or "linux": the key in version.json's downloads. +static func platform_key() -> String: + match OS.get_name(): + "macOS": + return "macos" + "Windows": + return "windows" + return "linux" + +## This platform's download in the newest release: {file, sha256, size, url}. +func download() -> Dictionary: + var dl: Variant = latest.get("downloads", {}).get(platform_key(), {}) + if not (dl is Dictionary) or dl.is_empty(): + return {} + var out: Dictionary = (dl as Dictionary).duplicate() + if String(out.get("url", "")).is_empty(): + out["url"] = String(assets.get(String(out.get("file", "")), "")) + return out + +## Where the Download button goes: this platform's file, else the release page. +func download_url() -> String: + var u := String(download().get("url", "")) + return u if not u.is_empty() else page + +## A packaged game (an exported build), as opposed to running from the source. +static func packaged() -> bool: + return OS.has_feature("template") + +## -1, 0 or 1. "v0.10.2" > "0.9", "1.0.0-beta" == "1.0.0" (suffixes ignored). +static func compare_versions(a: String, b: String) -> int: + var pa := a.strip_edges().trim_prefix("v").get_slice("-", 0).split(".") + var pb := b.strip_edges().trim_prefix("v").get_slice("-", 0).split(".") + for i in range(maxi(pa.size(), pb.size())): + var x := int(pa[i]) if i < pa.size() else 0 + var y := int(pb[i]) if i < pb.size() else 0 + if x != y: + return 1 if x > y else -1 + return 0 diff --git a/scripts/net/net.gd b/scripts/net/net.gd index ac4064a..ff6b4bf 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -167,6 +167,18 @@ func _start_dedicated() -> void: if not err.is_empty(): printerr("[server] ", err) get_tree().quit(1) + return + # A real dedicated server (not one a test or Host Game started) says in + # its log when there's a newer version; server.json "check_updates". + if not _args.has("exit-with") and bool(config.v("check_updates")): + Updates.checked.connect(_on_update_checked, CONNECT_ONE_SHOT) + Updates.check(true) + +func _on_update_checked(s: String) -> void: + if s == "available": + print("[server] Emberwild %s is out (protocol %d); this server runs %s (protocol %d). %s" % [ + Updates.latest_version(), Updates.latest_protocol(), Updates.version, PROTOCOL, + "Players with the new version can't join until it's updated." if Updates.latest_protocol() != PROTOCOL else ""]) ## Opens the server. Returns "" on success, otherwise what went wrong. func start_server(config_path: String, overrides: Dictionary = {}) -> String: @@ -577,7 +589,9 @@ func _c2s_hello(d: Dictionary) -> void: if not is_server() or not _pending.has(peer) or _pending[peer].has("hello"): return if int(d.get("protocol", 0)) != PROTOCOL: - _reject(peer, "Different game version (server %d, you %d): update the game" % [PROTOCOL, int(d.get("protocol", 0))]) + var theirs := int(d.get("protocol", 0)) + _reject(peer, "Different game version: this server runs Emberwild %s (protocol %d), you have protocol %d. %s" % [ + Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."]) return var pw := String(config.v("password")) if not pw.is_empty() and String(d.get("password", "")) != pw: diff --git a/scripts/net/server_config.gd b/scripts/net/server_config.gd index bfa839b..287240b 100644 --- a/scripts/net/server_config.gd +++ b/scripts/net/server_config.gd @@ -27,6 +27,7 @@ const DEFAULTS := { "autosave_minutes": 2, "backups_kept": 10, "backup_hours": 6, + "check_updates": true, "admins": [], "allowlist": [], "banned": [], diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index 30d681e..eaf9c21 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -100,6 +100,14 @@ func bind(p: CharacterBody3D) -> void: tips_row.add_child(again) _main.add_child(tips_row) + var upd := CheckButton.new() + upd.text = "Check for updates at start (Emberwild %s)" % Updates.version + upd.focus_mode = Control.FOCUS_NONE + upd.button_pressed = Updates.enabled + upd.add_theme_font_size_override("font_size", 16) + upd.toggled.connect(Updates.set_enabled) + _main.add_child(upd) + # The tutorial: skip a step, or leave it (saved, to carry on later). if Guide.active: _main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close())) diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index b2106d4..b8049a7 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -85,6 +85,7 @@ func _ready() -> void: var spacer := Control.new() spacer.custom_minimum_size = Vector2(0, 24) col.add_child(spacer) + _build_update_banner(root) _pages["main"] = _main_page() _pages["new"] = _new_page() @@ -498,7 +499,7 @@ func _on_join_failed(reason: String) -> void: _on_host_status(reason, true) return _join_btn.disabled = false - _join_msg.text = reason + _join_msg.text = reason + version_advice(reason) _join_msg.add_theme_color_override("font_color", UITheme.BAD) func _on_left(reason: String) -> void: @@ -697,3 +698,74 @@ func _play_tutorial() -> void: func _play(id: String) -> void: Saves.current_id = id get_tree().change_scene_to_file(WORLD_SCENE) + +# --- updates (scripts/core/updates.gd) ------------------------------------------------ + +## Asked once per run of the game, not every time you come back to the title. +static var _update_asked := false +var update_banner: PanelContainer +var _update_text: Label +var _update_btn: Button +var version_label: Label + +## The version, bottom left; and a card bottom right when a newer one is out. +func _build_update_banner(root: Control) -> void: + version_label = UITheme.label("Emberwild %s" % Updates.version, 13, UITheme.TEXT_DIM) + version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT) + version_label.offset_left = 16 + version_label.offset_top = -30 + root.add_child(version_label) + update_banner = UITheme.panel() + update_banner.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT) + update_banner.grow_horizontal = Control.GROW_DIRECTION_BEGIN + update_banner.grow_vertical = Control.GROW_DIRECTION_BEGIN + update_banner.offset_right = -24 + update_banner.offset_bottom = -24 + update_banner.visible = false + root.add_child(update_banner) + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 8) + v.custom_minimum_size = Vector2(360, 0) + update_banner.add_child(v) + _update_text = UITheme.label("", 16) + _update_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _update_text.custom_minimum_size = Vector2(360, 0) + v.add_child(_update_text) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 8) + v.add_child(h) + _update_btn = _button("Download", _on_update_download, 170) + h.add_child(_update_btn) + h.add_child(_button("Not now", func(): update_banner.visible = false, 120)) + Updates.checked.connect(_on_update_checked) + if Updates.state == "available": + _on_update_checked("available") + elif not _update_asked: + _update_asked = true + Updates.check() + +func _on_update_checked(s: String) -> void: + if s != "available" or update_banner == null: + return + var notes := String(Updates.latest.get("notes", "")) + var how := "" if Updates.packaged() else "\nRunning from the source: git pull in the game folder, then restart." + _update_text.text = "Emberwild %s is out (you have %s).%s%s" % [Updates.latest_version(), Updates.version, + ("\n" + notes) if not notes.is_empty() else "", how] + _update_btn.text = "Download" if Updates.packaged() and not String(Updates.download().get("url", "")).is_empty() else "Release page" + _update_btn.disabled = Updates.download_url().is_empty() + update_banner.visible = true + +func _on_update_download() -> void: + var u := Updates.download_url() + if not u.is_empty(): + OS.shell_open(u) + +## Extra words for a "Different game version" refusal: what to do about it. +static func version_advice(reason: String) -> String: + if not reason.begins_with("Different game version"): + return "" + if Updates.state == "available": + return "\nEmberwild %s is out: Download is on the title page." % Updates.latest_version() + if reason.ends_with("Update the game to join."): + return "\nThere's no newer release yet: the server may be running a newer copy from the source." + return "" diff --git a/tests/run_all.sh b/tests/run_all.sh index 81ed26b..2c50979 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_updates.gd b/tests/test_updates.gd new file mode 100644 index 0000000..bf12be5 --- /dev/null +++ b/tests/test_updates.gd @@ -0,0 +1,119 @@ +extends SceneTree +## The update check (scripts/core/updates.gd): comparing versions, a plain +## version.json feed, a Gitea/GitHub release feed (its version.json asset and +## downloads by name), "you're up to date", broken feeds, the setting, the +## title screen's banner, and the advice on a "Different game version" +## refusal. No network: a fake feed answers. +## Run: godot --headless --path . --script res://tests/test_updates.gd + +var _fails := 0 +var _finished := false +var U: Node +var pages: Dictionary = {} + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + U = root.get_node("Updates") + U.prefs_path = "user://test_updates.json" + DirAccess.remove_absolute(U.prefs_path) + U.load_prefs() + await _body() + check(_finished, "test ran to the end (no script errors)") + DirAccess.remove_absolute(U.prefs_path) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _fake(url: String) -> String: + return String(pages.get(url, "404")) + +func _check_now() -> String: + U.check() + while U.state == "checking": + await process_frame + return U.state + +func _body() -> void: + print("versions") + var C: Script = load("res://scripts/core/updates.gd") + check(C.compare_versions("0.2.0", "0.1.0") == 1 and C.compare_versions("0.1.0", "0.2.0") == -1, "0.2.0 is newer than 0.1.0") + check(C.compare_versions("v0.10.0", "0.9.5") == 1, "0.10 beats 0.9 (numbers, not letters), and a v in front is fine") + check(C.compare_versions("1.0", "1.0.0") == 0 and C.compare_versions("1.0.0-beta", "1.0.0") == 0, "1.0 = 1.0.0 = 1.0.0-beta") + check(U.version == ProjectSettings.get_setting("application/config/version") and not U.version.is_empty(), + "this game knows its own version (%s)" % U.version) + check(U.feed.begins_with("https://"), "and where to ask: %s" % U.feed) + + print("headless, no fake feed") + U.fetch_override = Callable() + check(await _check_now() == "off", "tests and bots never touch the network") + + U.fetch_override = _fake + U.feed = "https://example.test/version.json" + var plat: String = C.platform_key() + print("a version.json feed") + pages[U.feed] = JSON.stringify({"version": "9.1.0", "protocol": 99, "notes": "The Cove.", "page": "https://example.test/r/9.1.0", + "downloads": {plat: {"file": "Emberwild-9.1.0-%s.zip" % plat, "url": "https://example.test/dl/%s.zip" % plat, "sha256": "ab"}}}) + check(await _check_now() == "available", "9.1.0 is out: available") + check(U.latest_version() == "9.1.0" and U.latest_protocol() == 99, "its version and protocol") + check(U.download_url() == "https://example.test/dl/%s.zip" % plat and U.download().sha256 == "ab", "this platform's download (%s), with its checksum" % plat) + + print("a release feed (Gitea / Forgejo / GitHub)") + U.feed = "https://example.test/api/releases/latest" + pages[U.feed] = JSON.stringify({"tag_name": "v9.2.0", "html_url": "https://example.test/releases/v9.2.0", "assets": [ + {"name": "version.json", "browser_download_url": "https://example.test/a/version.json"}, + {"name": "Emberwild-9.2.0-%s.zip" % plat, "browser_download_url": "https://example.test/a/game-%s.zip" % plat}]}) + pages["https://example.test/a/version.json"] = JSON.stringify({"version": "9.2.0", "protocol": 99, + "downloads": {plat: {"file": "Emberwild-9.2.0-%s.zip" % plat, "sha256": "cd"}}, "windows_only_key": true}) + check(await _check_now() == "available" and U.latest_version() == "9.2.0", "the release's version.json is read: 9.2.0") + check(U.download_url() == "https://example.test/a/game-%s.zip" % plat, "the download is the release asset with that file name") + check(U.page == "https://example.test/releases/v9.2.0", "and the release page is kept") + U.latest.downloads.erase(plat) + check(U.download_url() == U.page, "no package for this platform: the release page instead") + + print("up to date, and broken feeds") + pages[U.feed] = JSON.stringify({"version": U.version, "protocol": 12}) + check(await _check_now() == "current", "the same version: up to date") + pages[U.feed] = JSON.stringify({"version": "0.0.1"}) + check(await _check_now() == "current", "an older one: up to date") + pages[U.feed] = "Bad gateway" + check(await _check_now() == "failed" and U.error.contains("JSON"), "a 502 page: failed, and says why (%s)" % U.error) + pages[U.feed] = JSON.stringify({"tag_name": "v1", "assets": []}) + check(await _check_now() == "failed" and U.error.contains("version.json"), "a release with no version.json: failed") + + print("the setting") + U.set_enabled(false) + pages[U.feed] = JSON.stringify({"version": "9.3.0"}) + check(await _check_now() == "off", "off: no check") + U.load_prefs() + check(not U.enabled, "and it's remembered") + U.set_enabled(true) + check(await _check_now() == "available", "on again: 9.3.0") + + print("the title screen") + var TS: Script = load("res://scripts/ui/title_screen.gd") + var msg := "Different game version: this server runs Emberwild 9.3.0 (protocol 99), you have protocol 12. Update the game to join." + check(TS.version_advice(msg).contains("9.3.0 is out"), "refused for an old version: it says the new one is out") + check(TS.version_advice("Server is full").is_empty(), "(other refusals: nothing added)") + pages[U.feed] = JSON.stringify({"version": "9.4.0", "notes": "Sea fishing."}) + U.state = "idle" + var title: Node = load("res://scenes/title.tscn").instantiate() + root.add_child(title) + for _i in range(5): + await process_frame + while U.state == "checking": + await process_frame + await process_frame + check(title.version_label != null and title.version_label.text.ends_with(U.version), "the version, bottom left (%s)" % title.version_label.text) + check(title.update_banner.visible and title._update_text.text.contains("9.4.0 is out") and title._update_text.text.contains("Sea fishing."), + "a banner: 9.4.0 is out, with its notes") + check(title._update_text.text.contains("git pull"), "(running from the source: it says git pull)") + title.queue_free() + await process_frame + U.fetch_override = Callable() + _finished = true diff --git a/tools/export.sh b/tools/export.sh index 82c510b..e497c29 100755 --- a/tools/export.sh +++ b/tools/export.sh @@ -15,7 +15,14 @@ cd "$(dirname "$0")/.." || exit 1 . tools/godot_env.sh -VERSION=$(sed -n 's/^application\/short_version="\(.*\)"/\1/p' export_presets.cfg | head -1) +VERSION=$(sed -n 's/^config\/version="\(.*\)"/\1/p' project.godot | head -1) +PROTOCOL=$(sed -n 's/^const PROTOCOL := \([0-9]*\).*/\1/p' scripts/net/net.gd | head -1) +# The packages' own version numbers must say the same as the game's. +for v in $(grep -E '^application/(short_version|version|file_version|product_version)=' export_presets.cfg | sed 's/.*="\(.*\)"/\1/'); do + if [ "$v" != "$VERSION" ]; then + echo "export_presets.cfg says $v but project.godot's config/version is $VERSION: make them match"; exit 1 + fi +done which=${1:-all} mkdir -p build "$GODOT" --headless --path . --import >/dev/null 2>&1 @@ -41,3 +48,20 @@ if [ "$which" = all ] || [ "$which" = Windows ]; then echo "Windows export failed: see build/export-windows.log"; exit 1 fi fi + +# build/version.json, for the release (scripts/core/updates.gd reads it): +# upload it with the zips. NOTES="..." tools/export.sh adds a line of notes. +python3 - "$VERSION" "$PROTOCOL" "${NOTES:-}" <<'EOF' +import hashlib, json, os, sys, datetime +version, protocol, notes = sys.argv[1], int(sys.argv[2]), sys.argv[3] +out = {"version": version, "protocol": protocol, "date": datetime.date.today().isoformat(), "downloads": {}} +if notes: + out["notes"] = notes +for plat in ("macos", "windows"): + f = "build/Emberwild-%s-%s.zip" % (version, plat) + if os.path.exists(f): + h = hashlib.sha256(open(f, "rb").read()).hexdigest() + out["downloads"][plat] = {"file": os.path.basename(f), "sha256": h, "size": os.path.getsize(f)} +json.dump(out, open("build/version.json", "w"), indent="\t") +print("wrote build/version.json (%s, protocol %d: %s)" % (version, protocol, ", ".join(out["downloads"]) or "no packages")) +EOF