From 75f9cea47f6e4cec560e52f3aa29640ecc0e0271 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 10:26:55 +0000 Subject: [PATCH 1/6] [M2] RunResult What one run amounted to: outcome, duration, kills, sacrifices, integrity, imbalance, soul ash earned. A plain value with no behaviour and no node references, so it survives the run scene being torn down and a test can build one by hand. soul_ash_earned is the one field the run does not fill: the run reports what happened, the meta layer prices it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- systems/run_result.gd | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 systems/run_result.gd diff --git a/systems/run_result.gd b/systems/run_result.gd new file mode 100644 index 0000000..6f1569e --- /dev/null +++ b/systems/run_result.gd @@ -0,0 +1,55 @@ +class_name RunResult +extends RefCounted +## What one run amounted to. Produced once by RunCoordinator when the run ends, +## consumed by the meta layer and by the result screen. +## +## Deliberately a plain value: no behaviour, no references to live nodes. That +## is what lets it survive the run scene being freed, and lets a test build one +## by hand. +## +## `soul_ash_earned` is the one field the run does not know. The run reports +## what happened; MetaConfig prices it. See docs/GHOST_MARKET_LOOP.md. + +var outcome: StringName = RunState.STATUS_DEFEAT +var duration_seconds: float = 0.0 +var kills: int = 0 +var sacrifices: int = 0 +var integrity: float = 1.0 +var imbalance: float = 0.0 +var soul_ash_earned: int = 0 + +func is_victory() -> bool: + return outcome == RunState.STATUS_VICTORY + +func to_dictionary() -> Dictionary: + return { + "outcome": outcome, + "duration_seconds": duration_seconds, + "kills": kills, + "sacrifices": sacrifices, + "integrity": integrity, + "imbalance": imbalance, + "soul_ash_earned": soul_ash_earned, + } + +## Compact one-line summary for the Ghost Market's "latest run" panel. +func summary() -> String: + return ( + "%s · %s · %s · %s · integrity %.3f · imbalance %.0f · +%d soul ash" + % [ + String(outcome).to_upper(), + format_duration(duration_seconds), + _plural(kills, "kill"), + _plural(sacrifices, "sacrifice"), + integrity, + imbalance, + soul_ash_earned, + ] + ) + +static func _plural(count: int, noun: String) -> String: + return "%d %s%s" % [count, noun, "" if count == 1 else "s"] + +static func format_duration(seconds: float) -> String: + var total := int(maxf(0.0, seconds)) + return "%d:%02d" % [total / 60, total % 60] From d0b03d84d325e8b3a08e5913ddbc9c138df966a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 10:26:55 +0000 Subject: [PATCH 2/6] [M2] MetaState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five persisted values and nothing else: soul ash, runs, deaths, victories, and whether the Tempered Blade is owned. Mirrors RunState's shape on purpose — private fields, reads through getters, writes through named commands — so the Ghost Market observes and asks rather than assigning, exactly as the HUD does with RunState. MetaConfig holds the meta tunables in data/meta_config.tres, deliberately apart from BalanceConfig: in-run balance belongs to combat design and must not shift because someone tuned the shop. Its soul_ash_for() is pure, so the reward rule is testable without a save file or a scene. Serialisation lives on MetaState because the field list and its JSON shape are one piece of knowledge. Unknown or missing keys fall back to a fresh profile's value rather than throwing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- core/meta_config.gd | 27 +++++++++++++++ core/meta_state.gd | 76 +++++++++++++++++++++++++++++++++++++++++++ data/meta_config.tres | 11 +++++++ 3 files changed, 114 insertions(+) create mode 100644 core/meta_config.gd create mode 100644 core/meta_state.gd create mode 100644 data/meta_config.tres diff --git a/core/meta_config.gd b/core/meta_config.gd new file mode 100644 index 0000000..46a168e --- /dev/null +++ b/core/meta_config.gd @@ -0,0 +1,27 @@ +class_name MetaConfig +extends Resource +## Tunables for the meta layer only. Kept apart from BalanceConfig on purpose: +## in-run balance is owned by the combat design and must not be disturbed by +## somebody tuning the shop. +## +## Shipped instance: res://data/meta_config.tres. + +@export_group("Soul Ash reward") +## Paid for finishing a run at all, so a bad run still moves the meta forward. +@export var soul_ash_base: int = 5 +@export var soul_ash_per_kill: int = 2 +@export var soul_ash_victory_bonus: int = 25 + +@export_group("Tempered Blade") +@export var tempered_blade_cost: int = 40 +## Flat starting attack added to every future run once bought. +@export var tempered_blade_attack_bonus: float = 2.0 + + +## Prices a finished run. Pure: same result in, same number out — which is why +## the reward rule can be tested without a save file or a scene. +func soul_ash_for(result: RunResult) -> int: + var earned := soul_ash_base + soul_ash_per_kill * maxi(0, result.kills) + if result.is_victory(): + earned += soul_ash_victory_bonus + return maxi(0, earned) diff --git a/core/meta_state.gd b/core/meta_state.gd new file mode 100644 index 0000000..31427bd --- /dev/null +++ b/core/meta_state.gd @@ -0,0 +1,76 @@ +class_name MetaState +extends RefCounted +## Everything that survives a run. Five values and nothing else — the Ghost +## Market is a loop closer in M2, not an economy. +## +## Mirrors RunState's shape deliberately: private fields, reads through getters, +## writes through named commands. UI reads and asks; it does not assign. +## +## Serialisation lives here rather than in MetaSave because the field list and +## its JSON shape are one piece of knowledge. MetaSave owns the file. + +var _soul_ash: int = 0 +var _runs: int = 0 +var _deaths: int = 0 +var _victories: int = 0 +var _tempered_blade_owned: bool = false + + +# --- reads ------------------------------------------------------------------ +func soul_ash() -> int: return _soul_ash +func runs() -> int: return _runs +func deaths() -> int: return _deaths +func victories() -> int: return _victories +func tempered_blade_owned() -> bool: return _tempered_blade_owned + +## Flat attack this profile grants to a new run. The only channel through which +## meta progression touches a run's numbers. +func attack_bonus(config: MetaConfig) -> float: + return config.tempered_blade_attack_bonus if _tempered_blade_owned else 0.0 + +func can_buy_tempered_blade(config: MetaConfig) -> bool: + return not _tempered_blade_owned and _soul_ash >= config.tempered_blade_cost + + +# --- commands --------------------------------------------------------------- +## Applies a finished run: prices it, banks the ash, and moves the counters. +## Mutates `result.soul_ash_earned` so the result screen and the market both +## show the figure that was actually banked. +func record_run(result: RunResult, config: MetaConfig) -> void: + result.soul_ash_earned = config.soul_ash_for(result) + _soul_ash += result.soul_ash_earned + _runs += 1 + if result.is_victory(): + _victories += 1 + else: + _deaths += 1 + +## Returns false and changes nothing when it is unaffordable or already owned. +func buy_tempered_blade(config: MetaConfig) -> bool: + if not can_buy_tempered_blade(config): + return false + _soul_ash -= config.tempered_blade_cost + _tempered_blade_owned = true + return true + + +# --- serialisation ---------------------------------------------------------- +func to_dictionary() -> Dictionary: + return { + "soul_ash": _soul_ash, + "runs": _runs, + "deaths": _deaths, + "victories": _victories, + "tempered_blade_owned": _tempered_blade_owned, + } + +## Missing or malformed keys fall back to a fresh profile's value rather than +## throwing: a save written by an older build should cost the player nothing. +static func from_dictionary(data: Dictionary) -> MetaState: + var meta := MetaState.new() + meta._soul_ash = maxi(0, int(data.get("soul_ash", 0))) + meta._runs = maxi(0, int(data.get("runs", 0))) + meta._deaths = maxi(0, int(data.get("deaths", 0))) + meta._victories = maxi(0, int(data.get("victories", 0))) + meta._tempered_blade_owned = bool(data.get("tempered_blade_owned", false)) + return meta diff --git a/data/meta_config.tres b/data/meta_config.tres new file mode 100644 index 0000000..8baff82 --- /dev/null +++ b/data/meta_config.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="MetaConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://core/meta_config.gd" id="1_meta_config"] + +[resource] +script = ExtResource("1_meta_config") +soul_ash_base = 5 +soul_ash_per_kill = 2 +soul_ash_victory_bonus = 25 +tempered_blade_cost = 40 +tempered_blade_attack_bonus = 2.0 From 8bfc24a6089dedf566d50c71ae72c2ce310df6f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 10:26:55 +0000 Subject: [PATCH 3/6] [M2] Save user://meta_save.json with load, save, reset and a version field. The path is injected rather than a constant, which is the whole reason this is an object: tests point it at a scratch file instead of the player's real save. A missing, unreadable or malformed save yields a fresh profile with a warning. Losing progress is bad; refusing to launch is worse. A save written by a newer version is also refused rather than half-read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- core/meta_save.gd | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 core/meta_save.gd diff --git a/core/meta_save.gd b/core/meta_save.gd new file mode 100644 index 0000000..7f2fa5f --- /dev/null +++ b/core/meta_save.gd @@ -0,0 +1,73 @@ +class_name MetaSave +extends RefCounted +## Reads and writes the meta profile as JSON. +## +## The path is injected so tests write to a scratch file instead of the player's +## real save. That is the whole reason this is an object rather than a set of +## statics: the filesystem is exactly the dependency you want to be able to +## point somewhere else. +## +## Failure policy: a missing, unreadable or malformed save yields a fresh +## profile. Losing progress is bad; refusing to launch is worse. + +const DEFAULT_PATH := "user://meta_save.json" +## Bump when the stored shape changes in a way `MetaState.from_dictionary` +## cannot absorb, and add the migration there. +const VERSION := 1 + +var _path: String + +func _init(path: String = DEFAULT_PATH) -> void: + _path = path + +func path() -> String: + return _path + +func exists() -> bool: + return FileAccess.file_exists(_path) + + +func load_state() -> MetaState: + if not FileAccess.file_exists(_path): + return MetaState.new() + var file := FileAccess.open(_path, FileAccess.READ) + if file == null: + push_warning("MetaSave: cannot read %s (%d) — starting fresh" % [_path, FileAccess.get_open_error()]) + return MetaState.new() + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("MetaSave: %s is not valid JSON — starting fresh" % _path) + return MetaState.new() + var payload: Dictionary = parsed + var stored_version := int(payload.get("version", 0)) + if stored_version > VERSION: + push_warning( + "MetaSave: %s was written by version %d, this build reads %d — starting fresh" + % [_path, stored_version, VERSION] + ) + return MetaState.new() + return MetaState.from_dictionary(payload.get("meta", {})) + + +func save_state(meta: MetaState) -> bool: + var file := FileAccess.open(_path, FileAccess.WRITE) + if file == null: + push_error("MetaSave: cannot write %s (%d)" % [_path, FileAccess.get_open_error()]) + return false + file.store_string(JSON.stringify({ + "version": VERSION, + "meta": meta.to_dictionary(), + }, "\t")) + file.close() + return true + + +## Deletes the file and returns a fresh profile. The caller decides what to do +## with it — this does not reach into anyone's state. +func reset() -> MetaState: + if FileAccess.file_exists(_path): + var error := DirAccess.remove_absolute(ProjectSettings.globalize_path(_path)) + if error != OK: + push_warning("MetaSave: could not delete %s (%d)" % [_path, error]) + return MetaState.new() From adccfbdbe93425e134333c9c14574301ce5d21eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 10:28:04 +0000 Subject: [PATCH 4/6] [M2] Ghost Market loop Closes the loop: Ghost Market -> run -> run end -> Ghost Market. app.tscn becomes the main scene and is the composition root outside a run. It owns the meta profile and the save file, and is the only place the two halves meet -- Main knows nothing about the market, GhostMarket knows nothing about combat. Run-end pipeline, identical for death and victory because both already converge on RunCoordinator._finish_run: run_finished(result) -> MetaState.record_run -> MetaSave.save_state -> result screen dismissed -> Ghost Market Banking happens on run end rather than on dismissal, so quitting at the result screen still keeps what was earned. Duplicate end requests are ignored twice over: the coordinator's phase guard, and App._run_recorded. Changes to existing code, kept as small as the goal allows: - RunCoordinator now tracks kills and elapsed time and builds the RunResult. run_finished carries that result instead of a bare outcome (ADR-012). Combat, enemy AI, the sacrifice system and balance are untouched. - RunState gains add_flat_attack, the single channel through which meta progression reaches a run. Offensive, not structural, so a permanent upgrade cannot make the body read as more or less whole. - ResultScreen now only reports and emits "dismissed". Who listens decides what happens next, which is how the market loop and standalone main.tscn both work without a mode flag. Exactly one child scene exists at a time. Hiding the idle one is not an option: both present their UI on CanvasLayers, and a CanvasLayer does not inherit visibility from a parent Node2D -- a "hidden" market kept drawing over the run. Market scenery reuses existing floor, brick, brazier and ghost-fire art and is decoration only. Nothing reads it, so replacing the placeholder with real market art touches no script. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- core/run_state.gd | 7 ++ project.godot | 2 +- scenes/app.gd | 157 ++++++++++++++++++++++++++++++++++ scenes/app.tscn | 12 +++ scenes/ghost_market.gd | 60 +++++++++++++ scenes/ghost_market.tscn | 167 +++++++++++++++++++++++++++++++++++++ scenes/main.gd | 28 ++++++- systems/run_coordinator.gd | 49 ++++++++++- ui/result_screen.gd | 54 ++++++++---- 9 files changed, 512 insertions(+), 24 deletions(-) create mode 100644 scenes/app.gd create mode 100644 scenes/app.tscn create mode 100644 scenes/ghost_market.gd create mode 100644 scenes/ghost_market.tscn diff --git a/core/run_state.gd b/core/run_state.gd index 45ab450..88e7234 100644 --- a/core/run_state.gd +++ b/core/run_state.gd @@ -180,6 +180,13 @@ func set_stamina_recovery(value: float) -> void: _stamina_recovery = maxf(0.0, value) recalculate_derived() +## Flat base attack, used by meta progression at run creation. Offensive, not +## structural, so it does not move integrity — a permanent upgrade must not make +## the body read as more broken or more whole. +func add_flat_attack(value: float) -> void: + _attack = maxf(0.0, _attack + value) + recalculate_derived() + func add_additive(value: float) -> void: _additive_sum += value recalculate_derived() diff --git a/project.godot b/project.godot index 2dc9f50..3b51894 100644 --- a/project.godot +++ b/project.godot @@ -10,7 +10,7 @@ config_version=5 config/name="Nine Nether" config/description="Godot 4 greybox prototype: sacrifice structural survival for offensive power." -run/main_scene="res://scenes/main.tscn" +run/main_scene="res://scenes/app.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") [autoload] diff --git a/scenes/app.gd b/scenes/app.gd new file mode 100644 index 0000000..4bebbb5 --- /dev/null +++ b/scenes/app.gd @@ -0,0 +1,157 @@ +class_name App +extends Node +## Top-level loop: Ghost Market → run → run end → Ghost Market. +## +## The composition root for everything outside a run. It owns the meta profile +## and the save file, decides which scene is on screen, and is the only place +## the two halves of the game meet. `Main` knows nothing about the market; +## `GhostMarket` knows nothing about combat. +## +## Run-end pipeline (docs/GHOST_MARKET_LOOP.md), identical for death and +## victory because both arrive on the same signal: +## +## RunCoordinator.run_finished(result) +## → MetaState.record_run (prices the run, banks the ash, moves counters) +## → MetaSave.save_state +## → result screen dismissed +## → Ghost Market +## +## Duplicate end requests are ignored: `_run_recorded` is raised on the first +## and lowered only when the next run starts. +## +## Exactly one child scene exists at a time. Hiding the idle one is not an +## option: both scenes present their UI on CanvasLayers, and a CanvasLayer does +## not inherit visibility from a parent Node2D, so a "hidden" market would keep +## drawing over the run. + +const MARKET_SCENE := "res://scenes/ghost_market.tscn" +const RUN_SCENE := "res://scenes/main.tscn" +const CONFIG_PATH := "res://data/meta_config.tres" + +@export var market_scene: PackedScene +@export var run_scene: PackedScene +@export var meta_config: MetaConfig + +var _save: MetaSave +var _meta: MetaState +var _market: GhostMarket +var _run: Main +var _last_result: RunResult +var _run_recorded: bool = false + +func _ready() -> void: + if market_scene == null: + market_scene = load(MARKET_SCENE) + if run_scene == null: + run_scene = load(RUN_SCENE) + if meta_config == null: + meta_config = load(CONFIG_PATH) + if _save == null: + _save = MetaSave.new() + _meta = _save.load_state() + open_market() + + +## Redirects the profile to another file. Tests call this before the node +## enters the tree so they never touch the player's real save. +func use_save(save: MetaSave) -> void: + _save = save + +func meta() -> MetaState: + return _meta + +func market() -> GhostMarket: + return _market + +func run() -> Main: + return _run + +func last_result() -> RunResult: + return _last_result + + +# --- navigation ------------------------------------------------------------- + +func open_market() -> void: + _close_run() + if _market == null: + _market = market_scene.instantiate() + _market.start_run_requested.connect(start_run) + _market.buy_tempered_blade_requested.connect(buy_tempered_blade) + _market.reset_save_requested.connect(reset_save) + add_child(_market) + _market.show_profile(_meta, meta_config, _last_result) + + +func start_run() -> void: + if _run != null: + return + _run_recorded = false + _close_market() + _run = run_scene.instantiate() + # Assigned before the scene enters the tree, so the coordinator already has + # the bonus when it builds the run's RunState. autostart is off because the + # market owns the run's lifecycle: Main's standalone restart-in-place would + # otherwise fire alongside the return to the market. + _run.autostart = false + _run.meta_attack_bonus = _meta.attack_bonus(meta_config) + add_child(_run) + _run.coordinator().run_finished.connect(_on_run_finished) + _run.result_screen().dismissed.connect(_on_result_dismissed) + _run.coordinator().start_run(RNGService.run_seed()) + + +# --- market commands -------------------------------------------------------- + +func buy_tempered_blade() -> void: + if not _meta.buy_tempered_blade(meta_config): + return + _save.save_state(_meta) + _refresh_market() + + +func reset_save() -> void: + _meta = _save.reset() + _last_result = null + _refresh_market() + + +func _refresh_market() -> void: + if _market != null: + _market.show_profile(_meta, meta_config, _last_result) + + +# --- run end ---------------------------------------------------------------- + +## Banks the run immediately rather than on dismissal: a player who quits at the +## result screen should still keep what they earned. +func _on_run_finished(result: RunResult) -> void: + if _run_recorded: + return + _run_recorded = true + _meta.record_run(result, meta_config) + _save.save_state(_meta) + _last_result = result + + +func _on_result_dismissed(_result: RunResult) -> void: + open_market() + + +func _close_run() -> void: + if _run == null: + return + # Unpause first: a run abandoned during the sacrifice panel leaves the tree + # paused, and the market would come up frozen. + get_tree().paused = false + remove_child(_run) + _run.queue_free() + _run = null + + +func _close_market() -> void: + if _market == null: + return + remove_child(_market) + _market.queue_free() + _market = null diff --git a/scenes/app.tscn b/scenes/app.tscn new file mode 100644 index 0000000..3c433a7 --- /dev/null +++ b/scenes/app.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=5 format=3 uid="uid://bnnapproot0001"] + +[ext_resource type="Script" path="res://scenes/app.gd" id="1_app"] +[ext_resource type="PackedScene" path="res://scenes/ghost_market.tscn" id="2_market"] +[ext_resource type="PackedScene" path="res://scenes/main.tscn" id="3_run"] +[ext_resource type="Resource" path="res://data/meta_config.tres" id="4_meta_config"] + +[node name="App" type="Node"] +script = ExtResource("1_app") +market_scene = ExtResource("2_market") +run_scene = ExtResource("3_run") +meta_config = ExtResource("4_meta_config") diff --git a/scenes/ghost_market.gd b/scenes/ghost_market.gd new file mode 100644 index 0000000..e79a1b9 --- /dev/null +++ b/scenes/ghost_market.gd @@ -0,0 +1,60 @@ +class_name GhostMarket +extends Node2D +## The hub between runs. Shows what you have, what the last run earned, the one +## upgrade, and the door out. +## +## It reads MetaState and asks — `buy_tempered_blade` and `reset_save` are +## requests to the owner, not writes. Same rule the HUD follows for RunState, +## for the same reason: one place mutates, everything else observes. +## +## The scenery is decoration only. Nothing here reads it, so replacing the +## placeholder brazier with real market art touches this file not at all. + +signal start_run_requested +signal buy_tempered_blade_requested +signal reset_save_requested + +@onready var _soul_ash: Label = $Ui/Root/Panel/SoulAsh +@onready var _profile: Label = $Ui/Root/Panel/Profile +@onready var _last_run: Label = $Ui/Root/Panel/LastRun +@onready var _upgrade: Label = $Ui/Root/Panel/Upgrade +@onready var _buy_button: Button = $Ui/Root/Panel/Buy +@onready var _start_button: Button = $Ui/Root/Panel/Start +@onready var _reset_button: Button = $Ui/Root/Panel/Reset + +var _meta: MetaState +var _config: MetaConfig + +func _ready() -> void: + _buy_button.pressed.connect(func() -> void: buy_tempered_blade_requested.emit()) + _start_button.pressed.connect(func() -> void: start_run_requested.emit()) + _reset_button.pressed.connect(func() -> void: reset_save_requested.emit()) + +## Renders a profile. Called again after every purchase, reset and returning +## run, so there is one refresh path and no incremental UI state to drift. +func show_profile(meta: MetaState, config: MetaConfig, last_run: RunResult) -> void: + _meta = meta + _config = config + _soul_ash.text = "Soul Ash %d" % meta.soul_ash() + _profile.text = "Runs %d Victories %d Deaths %d" % [ + meta.runs(), meta.victories(), meta.deaths() + ] + _last_run.text = ( + "Last run\n %s" % last_run.summary() if last_run != null + else "Last run\n none yet" + ) + _upgrade.text = ( + "Tempered Blade — +%.1f starting attack, permanent\n %s" + % [ + config.tempered_blade_attack_bonus, + "OWNED" if meta.tempered_blade_owned() else "Cost %d Soul Ash" % config.tempered_blade_cost, + ] + ) + _buy_button.disabled = not meta.can_buy_tempered_blade(config) + _buy_button.text = "Owned" if meta.tempered_blade_owned() else "Buy Tempered Blade" + _start_button.grab_focus() + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed(&"confirm"): + start_run_requested.emit() + get_viewport().set_input_as_handled() diff --git a/scenes/ghost_market.tscn b/scenes/ghost_market.tscn new file mode 100644 index 0000000..e7c973a --- /dev/null +++ b/scenes/ghost_market.tscn @@ -0,0 +1,167 @@ +[gd_scene load_steps=8 format=3 uid="uid://bnnghostmarket"] + +[ext_resource type="Script" path="res://scenes/ghost_market.gd" id="1_market"] +[ext_resource type="Texture2D" path="res://assets/background/bg_deep.png" id="2_bg_deep"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_floor.png" id="3_floor"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_stone_brick.png" id="4_brick"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_brazier.png" id="5_brazier"] +[ext_resource type="Texture2D" path="res://assets/effects/effect_ghost_fire.png" id="6_ghost_fire"] +[ext_resource type="Texture2D" path="res://assets/icons/ui/icon_ghostfire.png" id="7_icon"] + +[node name="GhostMarket" type="Node2D"] +script = ExtResource("1_market") + +[node name="Void" type="CanvasLayer" parent="."] +layer = -3 + +[node name="Fill" type="ColorRect" parent="Void"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 1) + +[node name="Backdrop" type="CanvasLayer" parent="."] +layer = -2 + +[node name="Deep" type="Sprite2D" parent="Backdrop"] +texture_repeat = 2 +position = Vector2(0, 124) +centered = false +texture = ExtResource("2_bg_deep") +region_enabled = true +region_rect = Rect2(0, 0, 640, 180) +modulate = Color(0.72, 0.7, 0.8, 1) + +[node name="Scenery" type="CanvasLayer" parent="."] +layer = -1 + +[node name="Wall" type="Sprite2D" parent="Scenery"] +texture_repeat = 2 +position = Vector2(0, 112) +centered = false +texture = ExtResource("4_brick") +region_enabled = true +region_rect = Rect2(0, 0, 640, 192) +modulate = Color(0.55, 0.53, 0.6, 1) + +[node name="Floor" type="Sprite2D" parent="Scenery"] +texture_repeat = 2 +position = Vector2(0, 304) +centered = false +texture = ExtResource("3_floor") +region_enabled = true +region_rect = Rect2(0, 0, 640, 56) + +[node name="BrazierLeft" type="Sprite2D" parent="Scenery"] +position = Vector2(56, 272) +centered = false +texture = ExtResource("5_brazier") + +[node name="FireLeft" type="Sprite2D" parent="Scenery"] +position = Vector2(48, 232) +centered = false +texture = ExtResource("6_ghost_fire") +modulate = Color(1, 1, 1, 0.75) + +[node name="BrazierRight" type="Sprite2D" parent="Scenery"] +position = Vector2(552, 272) +centered = false +texture = ExtResource("5_brazier") + +[node name="FireRight" type="Sprite2D" parent="Scenery"] +position = Vector2(544, 232) +centered = false +texture = ExtResource("6_ghost_fire") +modulate = Color(1, 1, 1, 0.75) + +[node name="Ui" type="CanvasLayer" parent="."] +layer = 2 + +[node name="Root" type="Control" parent="Ui"] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Title" type="Label" parent="Ui/Root"] +offset_left = 120.0 +offset_top = 8.0 +offset_right = 520.0 +offset_bottom = 32.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 16 +text = "鬼市 — GHOST MARKET" +horizontal_alignment = 1 + +[node name="Panel" type="Panel" parent="Ui/Root"] +offset_left = 96.0 +offset_top = 40.0 +offset_right = 544.0 +offset_bottom = 318.0 + +[node name="Icon" type="TextureRect" parent="Ui/Root/Panel"] +offset_left = 10.0 +offset_top = 10.0 +offset_right = 42.0 +offset_bottom = 42.0 +texture = ExtResource("7_icon") + +[node name="SoulAsh" type="Label" parent="Ui/Root/Panel"] +offset_left = 48.0 +offset_top = 12.0 +offset_right = 300.0 +offset_bottom = 34.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 14 +text = "Soul Ash 0" + +[node name="Profile" type="Label" parent="Ui/Root/Panel"] +offset_left = 48.0 +offset_top = 34.0 +offset_right = 438.0 +offset_bottom = 50.0 +theme_override_colors/font_color = Color(0.463, 0.451, 0.416, 1) +theme_override_font_sizes/font_size = 9 +text = "Runs 0 Victories 0 Deaths 0" + +[node name="LastRun" type="Label" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 58.0 +offset_right = 436.0 +offset_bottom = 104.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 +text = "Last run + none yet" + +[node name="Upgrade" type="Label" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 112.0 +offset_right = 436.0 +offset_bottom = 152.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 +text = "Tempered Blade" + +[node name="Buy" type="Button" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 158.0 +offset_right = 200.0 +offset_bottom = 182.0 +theme_override_font_sizes/font_size = 10 +text = "Buy Tempered Blade" + +[node name="Start" type="Button" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 196.0 +offset_right = 436.0 +offset_bottom = 228.0 +theme_override_font_sizes/font_size = 13 +text = "Start Run (Enter)" + +[node name="Reset" type="Button" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 238.0 +offset_right = 140.0 +offset_bottom = 262.0 +theme_override_font_sizes/font_size = 9 +text = "Reset Save" diff --git a/scenes/main.gd b/scenes/main.gd index da51e05..a4f07f6 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -1,10 +1,24 @@ class_name Main extends Node2D -## Composition root. Builds the object graph and starts the run. +## Composition root for one run. Builds the object graph and starts the run. ## ## Wiring lives here rather than inside each node so that every dependency is ## visible in one place, and so no observer can miss the opening `state_ready` ## by binding after the coordinator has already started. +## +## Two ways in, both real: +## * `app.tscn` sets `meta_attack_bonus`, leaves `autostart` on, and listens +## for `run_finished` / `ResultScreen.dismissed` to close the Ghost Market +## loop. +## * Pressing F6 on this scene in the editor plays a run with no meta layer, +## which is how a combat change gets tested without going through the shop. +## Set `autostart = false` to hold the run until the owner calls `start_run`. + +@export var autostart: bool = true + +## Flat starting attack from meta progression. Assign before the scene enters +## the tree; the coordinator reads it when the run is created. +var meta_attack_bonus: float = 0.0 @onready var _coordinator: RunCoordinator = $RunCoordinator @onready var _hud: Hud = $UIRoot/Hud @@ -18,7 +32,17 @@ func _ready() -> void: _sacrifice_panel.bind(_coordinator) _result_screen.bind(_coordinator) _debug_panel.bind(_coordinator, _debug_shapes) - _coordinator.start_run(RNGService.run_seed()) + _coordinator.meta_attack_bonus = meta_attack_bonus + if autostart: + # Standalone: there is no Ghost Market to return to, so dismissing the + # result screen restarts in place. + _result_screen.dismissed.connect( + func(_result: RunResult) -> void: _coordinator.restart_run() + ) + _coordinator.start_run(RNGService.run_seed()) func coordinator() -> RunCoordinator: return _coordinator + +func result_screen() -> ResultScreen: + return _result_screen diff --git a/systems/run_coordinator.gd b/systems/run_coordinator.gd index 1cc9ac1..ea7f4ac 100644 --- a/systems/run_coordinator.gd +++ b/systems/run_coordinator.gd @@ -12,7 +12,10 @@ signal phase_changed(phase: StringName) signal state_ready(state: RunState) signal sacrifice_offered(definition: SacrificeDefinition) signal boss_spawned(boss: BossActor) -signal run_finished(outcome: StringName) +## Carries the finished run's RunResult. Emitted exactly once per run: both +## death and victory arrive here through `_finish_run`, which is guarded by the +## phase machine. See docs/GHOST_MARKET_LOOP.md and ADR-012. +signal run_finished(result: RunResult) const PHASE_BOOT := &"boot" const PHASE_WAVE := &"wave" @@ -32,6 +35,11 @@ const M1_SACRIFICE_ID := &"severed_lifespan" @export var arena_path: NodePath @export var actor_root_path: NodePath +## Flat starting attack granted by meta progression. Set by the caller before +## `start_run`; zero when the run scene is played standalone. This is the only +## channel through which the Ghost Market touches a run's numbers. +var meta_attack_bonus: float = 0.0 + var _arena: Arena var _actor_root: Node2D var _state: RunState @@ -40,6 +48,9 @@ var _boss: BossActor var _phase: StringName = PHASE_BOOT var _live_enemies: Array[EnemyBase] = [] var _run_counter: int = 0 +var _kills: int = 0 +var _run_started_msec: int = 0 +var _result: RunResult ## Resolves scene references only. The run is started by Main once every @@ -69,6 +80,13 @@ func live_enemies() -> Array[EnemyBase]: func offered_sacrifice() -> SacrificeDefinition: return GameData.definition(M1_SACRIFICE_ID) +## The finished run's result, or null while a run is in progress. +func last_result() -> RunResult: + return _result + +func kills() -> int: + return _kills + ## Fresh run. Reusing the scene rather than reloading it keeps the seed under ## our control, which is what makes a reported bug replayable. @@ -79,7 +97,14 @@ func start_run(run_seed: int, restarted: bool = false) -> void: EventBus.bind_run(_run_counter) _clear_actors() + _kills = 0 + _result = null + _run_started_msec = Time.get_ticks_msec() _state = RunState.create(GameData.balance, run_seed) + # Meta progression is applied once, at creation, so the rest of the run sees + # it as an ordinary starting stat. Nothing downstream special-cases it. + if not is_zero_approx(meta_attack_bonus): + _state.add_flat_attack(meta_attack_bonus) _state.set_run_status(RunState.STATUS_ACTIVE) _spawn_player() state_ready.emit(_state) @@ -154,13 +179,29 @@ func begin_boss() -> void: boss_spawned.emit(_boss) +## The single run-end path. Death and victory both arrive here, and the phase +## guard makes a second request a no-op — the run cannot end twice. func _finish_run(outcome: StringName) -> void: if _phase == PHASE_RESULT: return _set_phase(PHASE_RESULT) _state.set_run_status(outcome) - EventBus.run_completed.emit(EventBus.context({"outcome": outcome})) - run_finished.emit(outcome) + _result = _build_result(outcome) + EventBus.run_completed.emit(EventBus.context(_result.to_dictionary())) + run_finished.emit(_result) + + +func _build_result(outcome: StringName) -> RunResult: + var result := RunResult.new() + result.outcome = outcome + result.duration_seconds = float(Time.get_ticks_msec() - _run_started_msec) / 1000.0 + result.kills = _kills + result.sacrifices = _state.sacrifice_history().size() + result.integrity = _state.integrity() + result.imbalance = _state.imbalance() + # soul_ash_earned stays 0 here: pricing a run is a meta concern, applied by + # MetaState.record_run. + return result # --- spawning --------------------------------------------------------------- @@ -204,11 +245,13 @@ func _clear_actors() -> void: func _on_enemy_died(enemy: EnemyBase) -> void: _live_enemies.erase(enemy) + _kills += 1 if _phase == PHASE_WAVE and _live_enemies.is_empty(): _begin_sacrifice() func _on_boss_defeated(_boss_actor: BossActor) -> void: + _kills += 1 _finish_run(RunState.STATUS_VICTORY) diff --git a/ui/result_screen.gd b/ui/result_screen.gd index 3d459a9..42cc81d 100644 --- a/ui/result_screen.gd +++ b/ui/result_screen.gd @@ -1,13 +1,20 @@ class_name ResultScreen extends CanvasLayer -## End-of-run screen for both outcomes. Restart is requested from the -## coordinator; the screen does not reset anything itself. +## End-of-run screen for both outcomes. +## +## It reports and then steps aside: `dismissed` is the only thing it does. Who +## listens decides what happens next — the Ghost Market loop returns to the +## market, and `main.tscn` played standalone restarts in place. The screen has +## no opinion, which is what lets both work without a mode flag. + +signal dismissed(result: RunResult) @onready var _title: Label = $Root/Title @onready var _summary: Label = $Root/Summary @onready var _restart_button: Button = $Root/Restart var _coordinator: RunCoordinator +var _result: RunResult func bind(coordinator: RunCoordinator) -> void: _coordinator = coordinator @@ -17,12 +24,15 @@ func bind(coordinator: RunCoordinator) -> void: func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS visible = false - _restart_button.pressed.connect(_on_restart) + _restart_button.pressed.connect(dismiss) + +func result() -> RunResult: + return _result -func show_outcome(outcome: StringName) -> void: +func show_outcome(result: RunResult) -> void: + _result = result var state := _coordinator.state() - var victory := outcome == RunState.STATUS_VICTORY - if not victory: + if not result.is_victory(): var player := _coordinator.player() if ( player != null @@ -30,33 +40,41 @@ func show_outcome(outcome: StringName) -> void: and player.sprite.is_playing() ): await player.sprite.animation_finished - # Not 同归: the same-death mechanic is not implemented in M1, so a win here - # is an ordinary victory and the screen must not claim otherwise. - _title.text = "VICTORY" if victory else "DEATH" - _title.modulate = Color(0.706, 0.392, 0.118) if victory else Color(0.831, 0.353, 0.294) + # Not 同归: the same-death mechanic is not implemented yet, so a win here is + # an ordinary victory and the screen must not claim otherwise. + _title.text = "VICTORY" if result.is_victory() else "DEATH" + _title.modulate = ( + Color(0.706, 0.392, 0.118) if result.is_victory() else Color(0.831, 0.353, 0.294) + ) _summary.text = ( - "Seed %d\nSacrifices %d\nAttack %.1f Estimated DPS %.1f\n" - + "Max lifespan %.0f Effective HP %.0f\nIntegrity %.3f Imbalance %.0f" + "Seed %d\nTime %s Kills %d Sacrifices %d\n" + + "Attack %.1f Estimated DPS %.1f\n" + + "Max lifespan %.0f Effective HP %.0f\n" + + "Integrity %.3f Imbalance %.0f\nSoul Ash earned %d" ) % [ state.run_seed(), - state.sacrifice_history().size(), + RunResult.format_duration(result.duration_seconds), + result.kills, + result.sacrifices, state.attack(), state.dps_estimate(), state.max_hp(), state.effective_hp(), - state.integrity(), - state.imbalance(), + result.integrity, + result.imbalance, + result.soul_ash_earned, ] visible = true _restart_button.grab_focus() func _unhandled_input(event: InputEvent) -> void: if visible and (event.is_action_pressed(&"confirm") or event.is_action_pressed(&"restart_run")): - _on_restart() + dismiss() get_viewport().set_input_as_handled() -func _on_restart() -> void: +## Closes the screen and hands control back. Safe to call when already hidden. +func dismiss() -> void: if not visible: return visible = false - _coordinator.restart_run() + dismissed.emit(_result) From 3204e07e382541e589b145f732d7c777b2ee5576 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 10:28:04 +0000 Subject: [PATCH 5/6] [M2] Tests Twelve cases covering exactly the M2 brief, driving the real app.tscn against a scratch save file. Market loads first, start run works, death returns to the market, victory returns to the market, a run is rewarded exactly once, soul ash persists to disk and reloads, the upgrade persists and reaches the next run's starting attack, and a second run resets HP, stamina, integrity, imbalance and sacrifice history while keeping soul ash and the upgrade. Plus three the brief did not ask for but the code needed: the reward rule priced straight from config, a malformed save yielding a fresh profile instead of a crash, and restart clearing leftover actors -- the market path rebuilds the whole run scene, so _clear_actors is only really exercised by the in-scene restart. Two harness additions: TestCase.wait_for for bounded polling, since the result screen is gated on a death animation, and an awaited frame in after_each so a torn-down app is really gone before the next case boots one. Whole suite: 78 tests, 484 assertions, 0 failures on Godot 4.3 headless. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- tests/cases/test_meta_loop.gd | 305 ++++++++++++++++++++++++++++++++++ tests/framework/test_case.gd | 9 + 2 files changed, 314 insertions(+) create mode 100644 tests/cases/test_meta_loop.gd diff --git a/tests/cases/test_meta_loop.gd b/tests/cases/test_meta_loop.gd new file mode 100644 index 0000000..ba0554a --- /dev/null +++ b/tests/cases/test_meta_loop.gd @@ -0,0 +1,305 @@ +extends TestCase +## The Ghost Market loop: market → run → run end → market, and what survives. +## +## Scope is deliberately the M2 brief and nothing else. Combat, sacrifices and +## enemy behaviour have their own cases and are not re-tested here. + +const APP_SCENE := "res://scenes/app.tscn" +const SAVE_PATH := "user://test_meta_save.json" + +var save: MetaSave +var config: MetaConfig +var app: App + +func before_each() -> void: + save = MetaSave.new(SAVE_PATH) + save.reset() + config = (load(App.CONFIG_PATH) as MetaConfig).duplicate(true) + +func after_each() -> void: + tree.paused = false + if app != null and is_instance_valid(app): + app.queue_free() + # One frame so the scene is really gone before the next case boots one. + await tree.process_frame + app = null + save.reset() + +## Boots the app against the scratch save file. +func _launch() -> App: + app = (load(APP_SCENE) as PackedScene).instantiate() + app.use_save(save) + app.meta_config = config + tree.root.add_child(app) + await step_physics(2) + return app + +## Keeps hitting the player until the run actually ends. A single blow is not +## enough: the player is briefly invulnerable after any hit, so a test that has +## already damaged them would otherwise have its killing blow ignored. +func _end_run_by_death() -> void: + var coordinator: RunCoordinator = app.run().coordinator() + await wait_for(func() -> bool: + if coordinator.phase() == RunCoordinator.PHASE_RESULT: + return true + coordinator.debug_damage(100000.0) + return false + ) + +func _dismiss_result() -> bool: + var screen: ResultScreen = app.run().result_screen() + var shown: bool = await wait_for(func() -> bool: return screen.visible) + if shown: + screen.dismiss() + await step_physics(2) + return shown + + +# --- the loop --------------------------------------------------------------- + +func test_ghost_market_loads_first() -> void: + await _launch() + assert_not_null(app.market(), "the market exists") + assert_not_null(app.market(), "the game opens in the Ghost Market") + assert_null(app.run(), "no run is in progress") + assert_null(app.last_result(), "there is no previous run to report") + + +func test_start_run_launches_a_playable_run() -> void: + await _launch() + app.start_run() + await step_physics(2) + var coordinator: RunCoordinator = app.run().coordinator() + assert_null(app.market(), "the market is torn down, not merely hidden") + assert_equal(coordinator.phase(), RunCoordinator.PHASE_WAVE, "the run boots into a wave") + assert_not_null(coordinator.player(), "a player exists") + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "the wave spawned" + ) + + +func test_death_returns_to_the_ghost_market() -> void: + await _launch() + app.start_run() + await step_physics(2) + await _end_run_by_death() + + var result := app.last_result() + assert_not_null(result, "the run produced a result") + assert_equal(result.outcome, RunState.STATUS_DEFEAT, "recorded as a defeat") + + assert_true(await _dismiss_result(), "the result screen appeared") + assert_null(app.run(), "the run scene was torn down") + assert_not_null(app.market(), "the market is back") + assert_equal(app.meta().deaths(), 1, "the death was counted") + assert_equal(app.meta().runs(), 1, "the run was counted") + + +func test_victory_returns_to_the_ghost_market() -> void: + await _launch() + app.start_run() + await step_physics(2) + var coordinator: RunCoordinator = app.run().coordinator() + coordinator.begin_boss() + await step_physics(2) + coordinator.boss().die(&"test") + # The boss holds victory until its collapse animation finishes. + assert_true( + await wait_for(func() -> bool: return app.last_result() != null), + "the victory was reported" + ) + + assert_equal(app.last_result().outcome, RunState.STATUS_VICTORY, "recorded as a victory") + assert_true(await _dismiss_result(), "the result screen appeared") + assert_not_null(app.market(), "the market is back") + assert_equal(app.meta().victories(), 1, "the victory was counted") + assert_equal(app.meta().deaths(), 0, "no death was counted") + + +# --- reward ------------------------------------------------------------------ + +func test_a_run_is_rewarded_exactly_once() -> void: + await _launch() + app.start_run() + await step_physics(2) + await _end_run_by_death() + + var banked := app.meta().soul_ash() + var result := app.last_result() + assert_greater(float(banked), 0.0, "the run paid something") + assert_equal(banked, result.soul_ash_earned, "what was banked is what the result reports") + + # Re-emitting the real signal is the route a duplicate would arrive by. + var coordinator: RunCoordinator = app.run().coordinator() + coordinator.run_finished.emit(result) + coordinator.run_finished.emit(result) + assert_equal(app.meta().soul_ash(), banked, "soul ash was not paid twice") + assert_equal(app.meta().runs(), 1, "the run was not counted twice") + + +func test_reward_prices_kills_and_victory_from_config() -> void: + var defeat := RunResult.new() + defeat.outcome = RunState.STATUS_DEFEAT + defeat.kills = 3 + assert_equal( + config.soul_ash_for(defeat), + config.soul_ash_base + 3 * config.soul_ash_per_kill, + "a defeat pays base plus kills" + ) + + var victory := RunResult.new() + victory.outcome = RunState.STATUS_VICTORY + victory.kills = 3 + assert_equal( + config.soul_ash_for(victory), + config.soul_ash_base + 3 * config.soul_ash_per_kill + config.soul_ash_victory_bonus, + "a victory adds the bonus" + ) + + +# --- persistence ------------------------------------------------------------- + +func test_soul_ash_persists_across_a_relaunch() -> void: + await _launch() + app.start_run() + await step_physics(2) + await _end_run_by_death() + var banked := app.meta().soul_ash() + assert_greater(float(banked), 0.0, "the run paid something") + + # A second App reading the same file is the honest test of persistence. + var reloaded := MetaSave.new(SAVE_PATH).load_state() + assert_equal(reloaded.soul_ash(), banked, "soul ash survived the write") + assert_equal(reloaded.runs(), 1, "so did the run counter") + + +func test_upgrade_persists_and_reaches_the_next_run() -> void: + await _launch() + # Fund the purchase through the normal path rather than by poking a field. + var funded := RunResult.new() + funded.outcome = RunState.STATUS_VICTORY + funded.kills = 40 + app.meta().record_run(funded, config) + assert_true(app.meta().can_buy_tempered_blade(config), "the purchase is affordable") + + var before := app.meta().soul_ash() + app.buy_tempered_blade() + assert_true(app.meta().tempered_blade_owned(), "the blade is owned") + assert_equal( + app.meta().soul_ash(), before - config.tempered_blade_cost, "the cost was paid" + ) + assert_false(app.meta().can_buy_tempered_blade(config), "it cannot be bought twice") + + assert_true( + MetaSave.new(SAVE_PATH).load_state().tempered_blade_owned(), + "the purchase was written to disk" + ) + + app.start_run() + await step_physics(2) + assert_almost( + app.run().coordinator().state().attack(), + GameData.balance.base_attack + config.tempered_blade_attack_bonus, + "the upgrade is applied to the new run's starting attack", + 1e-6 + ) + + +func test_reset_save_clears_progress_and_the_file() -> void: + await _launch() + var funded := RunResult.new() + funded.kills = 10 + app.meta().record_run(funded, config) + save.save_state(app.meta()) + assert_true(save.exists(), "there is a save to clear") + + app.reset_save() + assert_equal(app.meta().soul_ash(), 0, "soul ash is cleared") + assert_equal(app.meta().runs(), 0, "counters are cleared") + assert_false(app.meta().tempered_blade_owned(), "upgrades are cleared") + assert_false(save.exists(), "the file is gone") + + +func test_a_malformed_save_yields_a_fresh_profile_instead_of_failing() -> void: + var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + file.store_string("{ not json") + file.close() + var meta := MetaSave.new(SAVE_PATH).load_state() + assert_equal(meta.soul_ash(), 0, "a corrupt save starts fresh rather than crashing") + + +# --- second run -------------------------------------------------------------- + +func test_a_second_run_starts_from_a_clean_state() -> void: + await _launch() + app.start_run() + await step_physics(2) + + # Dirty the first run thoroughly: damage, a sacrifice, and a live arrow. + var first: RunCoordinator = app.run().coordinator() + first.debug_damage(20.0) + first.debug_apply_sacrifice() + await step_physics(2) + assert_less(first.state().current_hp(), first.state().max_hp(), "the player is hurt") + assert_false(first.state().sacrifice_history().is_empty(), "a sacrifice was taken") + assert_greater(first.state().imbalance(), 0.0, "imbalance rose") + + await _end_run_by_death() + assert_true(await _dismiss_result(), "the result screen appeared") + + app.start_run() + await step_physics(2) + var second: RunCoordinator = app.run().coordinator() + var state := second.state() + assert_almost(state.current_hp(), state.max_hp(), "HP is full again") + assert_almost(state.current_stamina(), state.max_stamina(), "stamina is full again") + assert_almost(state.max_hp(), GameData.balance.base_max_hp, "max lifespan is restored") + assert_almost(state.integrity(), 1.0, "integrity is whole again") + assert_almost(state.imbalance(), 0.0, "imbalance is cleared") + assert_true(state.sacrifice_history().is_empty(), "sacrifice history is cleared") + assert_equal( + second.live_enemies().size(), GameData.balance.wave_enemy_count, + "a fresh wave spawned" + ) + assert_equal(second.kills(), 0, "the kill count restarted") + + # The market path rebuilds the whole run scene, so this mostly guards against + # something being parented outside it. The in-scene restart path is checked + # by test_restart_clears_leftover_actors below. + for node in app.run().get_node("Actors").get_children(): + assert_true( + node is Player or node is EnemyBase, + "unexpected leftover actor %s" % node.name + ) + + # And the meta side kept what it should. + assert_equal(app.meta().runs(), 1, "the finished run is still counted") + assert_greater(float(app.meta().soul_ash()), 0.0, "soul ash carried over") + + +## Debug restart reuses the run scene instead of rebuilding it, so this is the +## path where _clear_actors has to do the work — corpses mid-death animation and +## projectiles still in flight included. +func test_restart_clears_leftover_actors() -> void: + await _launch() + app.start_run() + await step_physics(2) + var coordinator: RunCoordinator = app.run().coordinator() + var actors: Node2D = app.run().get_node("Actors") + + var debris := Node2D.new() + debris.name = "InFlightProjectile" + actors.add_child(debris) + assert_not_null(actors.get_node_or_null("InFlightProjectile"), "the debris is there") + + coordinator.restart_run() + await step_physics(2) + assert_null( + actors.get_node_or_null("InFlightProjectile"), + "restart removed everything under Actors" + ) + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "and respawned a clean wave" + ) diff --git a/tests/framework/test_case.gd b/tests/framework/test_case.gd index c770906..914cd2d 100644 --- a/tests/framework/test_case.gd +++ b/tests/framework/test_case.gd @@ -37,6 +37,15 @@ func step_physics(count: int) -> void: for _i in range(count): await tree.physics_frame +## Steps physics until `condition` returns true, up to `max_frames`. Returns +## whether it became true, so a caller can assert rather than hang. +func wait_for(condition: Callable, max_frames: int = 240) -> bool: + for _i in range(max_frames): + if bool(condition.call()): + return true + await tree.physics_frame + return bool(condition.call()) + ## A BalanceConfig detached from the shipped resource, so a test can retune a ## value without leaking the change into the next test. func make_balance() -> BalanceConfig: From c4185164b754aab15bede2a529fef7a398fbcc31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 10:30:26 +0000 Subject: [PATCH 6/6] [M2] Docs - docs/GHOST_MARKET_LOOP.md (new): the loop, the run-end pipeline, the data shapes, the save format, how the one upgrade reaches a run, what a new run resets, and what M2 deliberately does not do. - ARCHITECTURE.md: app.tscn as the composition root outside a run, the new state-ownership rows, and the meta gap in the known-gaps list. - AI_HANDOFF.md: branch, M2 summary, updated test figures, and two new limitations -- meta is one upgrade, and the engine version has drifted (project.godot and CI say 4.3, the committed assets_v2 .import files carry Godot 4.4+ keys, and the M1.5 handoff reports local work on 4.7.1). - DECISIONS.md: ADR-012 (run_finished carries a RunResult) and ADR-013 (one live child scene rather than hiding the idle one). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- docs/AI_HANDOFF.md | 44 ++++++++++++++-- docs/ARCHITECTURE.md | 24 +++++++-- docs/DECISIONS.md | 41 +++++++++++++++ docs/GHOST_MARKET_LOOP.md | 106 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 docs/GHOST_MARKET_LOOP.md diff --git a/docs/AI_HANDOFF.md b/docs/AI_HANDOFF.md index d647705..e5f626e 100644 --- a/docs/AI_HANDOFF.md +++ b/docs/AI_HANDOFF.md @@ -1,17 +1,43 @@ # AI Handoff -- **Current branch:** `codex/m15-art-v2-integration` -- **Current phase:** M1.5 Art V2 integrated; draft PR #6 open for review -- **Next owner:** Claude and Game Director for visual/gameplay review +- **Current branch:** `claude/m2-minimal-loop` (draft PR into `develop`, unmerged) +- **Current phase:** M2 Ghost Market loop delivered on top of M1.5 Art V2 +- **Next owner:** Game Director for review - **Last updated:** 2026-08-02 (Australia/Melbourne) ## Playable status +The game now opens in the **Ghost Market**: start a run, fight, die or win, read +the run result, return to the market with soul ash banked, buy the one upgrade, +run again. See `docs/GHOST_MARKET_LOOP.md`. + The complete M1 loop remains playable: arena wave → sacrifice → Gate Warden → victory/defeat → restart. Normal gameplay now renders the production V2 player, melee ghost, Ghost Archer, Ghost Arrow, Gate Warden, combat effects, and matching HUD icons. Debug collision shapes remain off by default and available on F8. +## M2 Ghost Market loop + +| Piece | Where | +| --- | --- | +| Main scene | `scenes/app.tscn` — owns `MetaState`, swaps market and run | +| Hub | `scenes/ghost_market.tscn` — soul ash, latest run, one upgrade, start, reset | +| Persisted state | `core/meta_state.gd` — soul ash, runs, deaths, victories, Tempered Blade | +| Run summary | `systems/run_result.gd` — outcome, duration, kills, sacrifices, integrity, imbalance, soul ash | +| Save | `core/meta_save.gd` — `user://meta_save.json`, versioned, path injectable | +| Meta tunables | `data/meta_config.tres` — reward rates and upgrade cost, apart from `BalanceConfig` | + +Death and victory share one run-end pipeline; duplicate end requests are ignored +at both the coordinator and the app layer. Combat, enemy AI, the sacrifice +system, balance values, art and animations were not modified. The two changes to +existing gameplay code are recorded as ADR-012 (`run_finished` now carries a +`RunResult`; `RunState.add_flat_attack` added) and ADR-013 (one live child scene +rather than hiding the idle one — a hidden `Node2D` does not hide its +`CanvasLayer` children, and the market was drawing over the run). + +Market visuals reuse existing floor, brick, brazier and ghost-fire art and are +decoration only: no script reads them, so real market art changes no code. + ## M1.5 integration | Area | Integrated mapping | @@ -65,8 +91,8 @@ actor/state scripts and scenes. ## Tests -- **Local:** 66 tests, 421 assertions, 0 failures -- **Engine:** Godot 4.7.1 stable headless locally; CI remains pinned to 4.3 +- **Local:** 78 tests, 484 assertions, 0 failures (12 new in `tests/cases/test_meta_loop.gd`) +- **Engine:** Godot 4.3 stable headless for the M2 run; CI is pinned to 4.3 - **Commands:** - `godot --headless --import` - `godot --headless --path . res://tests/test_runner.tscn` @@ -90,6 +116,14 @@ added. 4. Combat has no audio; audio integration remains outside M1.5 scope. 5. X04 now has a second supplied attack strip, but still depends on the Boss `PhaseController` / `AttackScheduler` foundation and an approved move design. +6. Meta progression is one currency and one upgrade. No upgrade tree, no meta + unlocks touching the sacrifice pool or enemy roster, no multiple profiles. +7. **Engine version drift.** `project.godot` declares `4.3`, `godot-tests` pins + `4.3-stable`, but the committed `assets_v2/**.import` files carry Godot 4.4+ + keys (`compress/uastc_level`, `process/channel_remap/*`) and the M1.5 handoff + reports local work on 4.7.1. Opening the project on 4.3 rewrites those files. + They were reverted rather than committed on this branch. The team should pick + one engine version and align `project.godot`, CI and local tooling. ## Frozen interfaces diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d44a0f..e115ebf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,7 +48,16 @@ Follows Prototype Development Pack A3, plus `scenes/` (see ADR-009). ## 3. Scene tree -`scenes/main.tscn` matches Prototype Development Pack A4: +`scenes/app.tscn` is the main scene and the composition root outside a run. It +owns the meta profile and keeps exactly one child scene in the tree — the market +or a run, never both. See `docs/GHOST_MARKET_LOOP.md`. + +``` +App (Node, scenes/app.gd) ← owns MetaState + MetaSave +└── GhostMarket (scenes/ghost_market.tscn) ← or Main, never both +``` + +`scenes/main.tscn` is one run, and matches Prototype Development Pack A4: ``` Main (Node2D, scenes/main.gd) ← composition root @@ -64,10 +73,11 @@ Main (Node2D, scenes/main.gd) ← composition root └── DebugShapes (DebugShapeOverlay) ``` -`Main._ready()` binds every observer and only then calls -`RunCoordinator.start_run()`. Starting the run from the coordinator's own -`_ready` would let observers bind after the opening `state_ready` had already -fired. +`Main._ready()` binds every observer before the run starts. Starting the run +from the coordinator's own `_ready` would let observers bind after the opening +`state_ready` had already fired. `App` sets `autostart = false` and calls +`start_run` itself once it has bound too; with `autostart` left on, `main.tscn` +plays standalone in the editor with no meta layer. Actor scenes: @@ -90,6 +100,8 @@ No core calculation lives in `Main`, in `Arena` or in any UI node. | Sacrifice library | `GameData` (autoload) | Process | | RNG streams | `RNGService` (autoload) | Re-seeded per run | | Active `RunState` | `RunCoordinator` | One run | +| `MetaState` (soul ash, counters, upgrade) | `App` | Process, persisted to `user://meta_save.json` | +| Latest `RunResult` | `App` | Until the next run ends | | Enemy and Boss HP | The actor instance | One actor | | Everything the UI shows | Nobody — it is read each frame | — | @@ -265,3 +277,5 @@ Listed so nobody mistakes absence for oversight. Each is scoped to a later task. - No three-slot sacrifice generator: M1 offers one card at a fixed point. - No Boss phase controller or attack scheduler. - No telemetry persistence: events are emitted but nothing writes them to disk. +- Meta progression is one upgrade and one currency (M2). No upgrade tree, no + meta unlocks touching the sacrifice pool or the enemy roster. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5e01fa9..4302b12 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -235,3 +235,44 @@ Append decisions in this format; do not rewrite accepted history. must use the existing configured timer as a documented fallback. Only actions already supported by gameplay are mapped. - **Interfaces affected:** none. No frozen or protected framework file changed. + +## ADR-012: `run_finished` carries a `RunResult` instead of a bare outcome + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** M2 needs duration, kills, sacrifices, integrity and imbalance at + the moment a run ends, and needs death and victory to travel one path. The + frozen signature was `run_finished(outcome: StringName)`, which forced every + listener to reach back into `RunCoordinator` and reassemble the figures — a + second place where "what the run amounted to" would be defined. +- **Decision:** `RunCoordinator._finish_run` builds a `RunResult` and emits it. + `RunCoordinator` gains a kill count and a start timestamp; `RunState` gains + `add_flat_attack` for meta progression. Nothing else in the run changes: + `CombatResolver`, enemy AI, the sacrifice system and `BalanceConfig` are + untouched. +- **Consequences:** One listener needed updating (`ResultScreen`). Any future + telemetry sink gets the whole picture from the payload. `RunResult` is a plain + value, so it outlives the run scene and a test can build one by hand. +- **Interfaces affected:** `RunCoordinator.run_finished`, + `ResultScreen.show_outcome`, `RunState.add_flat_attack` (additive). + +## ADR-013: One live child scene, rather than hiding the idle one + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** The first Ghost Market implementation kept both scenes in the + tree and toggled `visible` on the idle one. It drew the market's title, panel + and braziers straight over the running game. `GhostMarket` and `Main` are + `Node2D`s whose UI lives on `CanvasLayer` children, and a `CanvasLayer` does + not inherit visibility from a parent `Node2D`. +- **Decision:** `App` instantiates and frees. Exactly one of the market and the + run exists at any moment. +- **Consequences:** No visibility subtleties and no stale nodes between runs; a + second run rebuilds the run scene from scratch, which is a stronger reset than + `_clear_actors`. The in-scene restart path (debug `R`) still relies on + `_clear_actors` and has its own test. Rebuilding a scene per transition is + negligible at this size. +- **Interfaces affected:** `App.market()` / `App.run()` return null when the + other is active. diff --git a/docs/GHOST_MARKET_LOOP.md b/docs/GHOST_MARKET_LOOP.md new file mode 100644 index 0000000..3e19bc4 --- /dev/null +++ b/docs/GHOST_MARKET_LOOP.md @@ -0,0 +1,106 @@ +# Ghost Market Loop + +> **STATUS: ACTIVE — M2** · Owner: Claude + +The loop between runs. Minimal on purpose: a hub, one upgrade, one save file. + +``` +Ghost Market → Start Run → Combat → Death / Victory → Run Result → Ghost Market +``` + +## Scenes + +| Scene | Role | +| --- | --- | +| `scenes/app.tscn` | Main scene. Owns `MetaState` and `MetaSave`; swaps between the two below. | +| `scenes/ghost_market.tscn` | Hub UI: soul ash, latest run, one upgrade, start, reset. | +| `scenes/main.tscn` | One run. Unchanged in behaviour; it does not know the market exists. | + +Exactly one of the last two is in the tree at a time. Hiding the idle one is not +an option: both present their UI on `CanvasLayer`s, and a `CanvasLayer` does not +inherit visibility from a parent `Node2D`, so a "hidden" market keeps drawing. + +`main.tscn` still runs standalone (F6 in the editor): `autostart` defaults true +and dismissing the result screen restarts in place. `App` sets `autostart` false +and drives the run itself. + +## Run end pipeline + +Death and victory already converge on `RunCoordinator._finish_run`, which is +guarded by the phase machine. Everything after that is one path: + +``` +RunCoordinator._finish_run(outcome) + → builds RunResult (outcome, duration, kills, sacrifices, integrity, imbalance) + → run_finished(result) +App._on_run_finished(result) + → MetaState.record_run (prices the run, banks the ash, moves the counters) + → MetaSave.save_state +ResultScreen.dismissed + → App.open_market +``` + +Banking happens on run end, not on dismissal: a player who quits at the result +screen keeps what they earned. + +**Duplicate end requests are ignored** at two layers, each for its own reason — +`RunCoordinator` will not leave `PHASE_RESULT` twice, and `App._run_recorded` +guarantees one reward per run even if the signal is re-emitted. + +## Data + +`MetaState` (`core/meta_state.gd`) persists five values and nothing else: +soul ash, runs, deaths, victories, Tempered Blade owned. Private fields, reads +through getters, writes through named commands — the same rule `RunState` +follows, so the market observes and asks rather than assigning. + +`RunResult` (`systems/run_result.gd`) is a plain value: outcome, duration, kills, +sacrifices, integrity, imbalance, soul ash earned. The run fills all but the +last; pricing is a meta concern. + +`MetaConfig` (`data/meta_config.tres`) holds the meta tunables, kept apart from +`BalanceConfig` so tuning the shop cannot disturb combat balance: + +| Field | Default | +| --- | --- | +| `soul_ash_base` | 5 | +| `soul_ash_per_kill` | 2 | +| `soul_ash_victory_bonus` | 25 | +| `tempered_blade_cost` | 40 | +| `tempered_blade_attack_bonus` | 2.0 | + +## Save + +`user://meta_save.json`, shape `{ "version": 1, "meta": { … } }`. +`MetaSave` takes its path as a constructor argument so tests write to a scratch +file. A missing, unreadable, malformed or newer-versioned save yields a fresh +profile with a warning — refusing to launch would be worse than losing a save. +Bump `MetaSave.VERSION` when the stored shape changes in a way +`MetaState.from_dictionary` cannot absorb, and add the migration there. + +## The one upgrade + +**Tempered Blade** — costs soul ash once, grants flat starting attack to every +later run, permanently. It reaches a run through exactly one channel: +`App` sets `Main.meta_attack_bonus`, `RunCoordinator` applies it via +`RunState.add_flat_attack` when the run's state is created. Offensive, not +structural, so a permanent upgrade cannot make the body read as more or less +whole and the integrity formula is unaffected. + +## What a new run resets + +Reset by creating a fresh `RunState` and clearing the actor root: current and +max HP, stamina, integrity, imbalance, sacrifice history, enemies, corpses and +projectiles. Kept: soul ash and the upgrade, which live in `MetaState`. + +## Visuals + +The market reuses existing floor, brick, brazier and ghost-fire art. It is +decoration: no script reads it, so replacing it with real market art changes no +code. There is no merchant, no NPC and no walkable hub — one panel. + +## Not in M2 + +No second upgrade, no upgrade tree, no currency sinks beyond the one purchase, +no run modifiers chosen in the hub, no meta unlocks affecting the sacrifice pool +or enemy roster, no cloud save, no multiple profiles.