From f9b1b22a51257126193535c7dc22dfabd47d9dbc Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Thu, 13 Aug 2026 15:11:59 +0800 Subject: [PATCH] fix(runtime): make session revisions atomic --- runtime/domain/p9_1/session_service.gd | 2 +- runtime/domain/p9_1/session_state.gd | 1 - .../tests/p9_1/foundation_atomicity_test.gd | 170 ++++++++++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 runtime/tests/p9_1/foundation_atomicity_test.gd diff --git a/runtime/domain/p9_1/session_service.gd b/runtime/domain/p9_1/session_service.gd index 6ee87023..aabd9259 100644 --- a/runtime/domain/p9_1/session_service.gd +++ b/runtime/domain/p9_1/session_service.gd @@ -13,7 +13,7 @@ func _init(initial_state: FirstSessionState = null) -> void: func reset_transient(reason: StringName = &"reset") -> Dictionary: state.reset_transient() - return _success([{ "kind": Contracts.EVENT_RUN_CLEARED, "reason": reason }], false) + return _success([{ "kind": Contracts.EVENT_RUN_CLEARED, "reason": reason }]) func open_new_session() -> Dictionary: state.reset_transient() diff --git a/runtime/domain/p9_1/session_state.gd b/runtime/domain/p9_1/session_state.gd index c07b3fcc..eab845d3 100644 --- a/runtime/domain/p9_1/session_state.gd +++ b/runtime/domain/p9_1/session_state.gd @@ -79,7 +79,6 @@ func set_screen(value: StringName) -> void: legacy_page = mapped_page func reset_transient() -> void: - revision += 1 front_choice = "" rear_choice = "" selected_recruits.clear() diff --git a/runtime/tests/p9_1/foundation_atomicity_test.gd b/runtime/tests/p9_1/foundation_atomicity_test.gd new file mode 100644 index 00000000..ec2d8cda --- /dev/null +++ b/runtime/tests/p9_1/foundation_atomicity_test.gd @@ -0,0 +1,170 @@ +extends SceneTree + +const Contracts = preload("res://domain/p9_1/contracts.gd") +const FirstSessionServiceScript = preload("res://domain/p9_1/session_service.gd") +const FirstSessionStateScript = preload("res://domain/p9_1/session_state.gd") + +var failures: Array[String] = [] +var successful_commands := 0 +var rejected_commands := 0 + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + _test_success_matrix() + _test_rejection_matrix() + _test_exact_guild_ids() + + if failures.is_empty(): + print("ABG_P9_1_FOUNDATION_ATOMICITY_OK success=%d rejected=%d exact_guild_ids=2 legacy_leaks=0" % [successful_commands, rejected_commands]) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func _test_success_matrix() -> void: + _success(&"reset_transient") + _success(&"open_new_session") + _success(&"toggle_recruit", ["Hearthguard"], {"screen": Contracts.SCREEN_RECRUIT_SELECTION}) + _success(&"review_formation", [], _formation_fields(Contracts.SCREEN_RECRUIT_SELECTION)) + _success(&"revise_formation", [], {"screen": Contracts.SCREEN_FORMATION_REVIEW}) + _success(&"commit_formation", [], _formation_fields(Contracts.SCREEN_FORMATION_REVIEW)) + _success(&"begin_compat_first_battle", [], {"front_choice": "Hearthguard", "rear_choice": "Farstring"}) + _success(&"open_adaptation", [], {"screen": Contracts.SCREEN_RESULT_1, "first_reward_committed": true}) + _success(&"open_guild_response", [], {"screen": Contracts.SCREEN_RESULT_2, "adaptation_committed": true}) + _success(&"open_route_board", [], {"screen": Contracts.SCREEN_RESULT_3, "guild_committed": true}) + _success(&"open_growth", [], {"screen": Contracts.SCREEN_RESULT_4, "route_committed": true, "route_reward_committed": true}) + _success(&"commit_reward", [&"battle_1", 20, Contracts.SCREEN_RESULT_1], {"screen": Contracts.SCREEN_RESULT_1}) + _success(&"choose_adaptation", ["EQ-002"], {"screen": Contracts.SCREEN_ADAPTATION}) + _success(&"commit_adaptation", [], {"screen": Contracts.SCREEN_ADAPTATION, "adaptation_choice": "EQ-002"}) + _success(&"choose_guild_response", ["retain_loan"], {"screen": Contracts.SCREEN_GUILD_RESPONSE, "coin": 20}) + _success(&"commit_guild_response", [], _guild_fields("retain_loan")) + _success(&"choose_route", ["R6-01"], {"screen": Contracts.SCREEN_ROUTE_BOARD}) + _success(&"review_route", [], {"screen": Contracts.SCREEN_ROUTE_BOARD, "route_choice": "R6-01"}) + _success(&"cancel_route_review", [], {"screen": Contracts.SCREEN_ROUTE_REVIEW}) + _success(&"commit_route", [], {"screen": Contracts.SCREEN_ROUTE_REVIEW, "route_choice": "R6-01"}) + _success(&"choose_growth", ["Hearthguard"], _growth_fields(Contracts.SCREEN_GROWTH)) + _success(&"review_growth", [], _growth_fields(Contracts.SCREEN_GROWTH, "Hearthguard")) + _success(&"cancel_growth_review", [], _growth_fields(Contracts.SCREEN_GROWTH_REVIEW)) + _success(&"commit_growth", [], _growth_fields(Contracts.SCREEN_GROWTH_REVIEW, "Hearthguard")) + _success(&"commit_impact", [Contracts.ENCOUNTER_1, 0, &"atomicity", {"delta": 0}], _battle_fields(Contracts.SCREEN_BATTLE_1, 0, true)) + _success(&"resolve_impact", [Contracts.ENCOUNTER_1, 2], _battle_fields(Contracts.SCREEN_BATTLE_1, 2, true)) + _success(&"resolve_compat_impact", [Contracts.ENCOUNTER_1, 2], _battle_fields(Contracts.SCREEN_BATTLE_1, 2, false)) + _success(&"complete_phase", [Contracts.ENCOUNTER_1, 0], _battle_fields(Contracts.SCREEN_BATTLE_1, 0, true)) + + +func _test_rejection_matrix() -> void: + _reject(&"toggle_recruit", ["Hearthguard"]) + _reject(&"review_formation") + _reject(&"revise_formation") + _reject(&"commit_formation") + _reject(&"begin_compat_first_battle") + _reject(&"open_adaptation") + _reject(&"open_guild_response") + _reject(&"open_route_board") + _reject(&"open_growth") + _reject(&"commit_reward", [&"battle_1", 20, Contracts.SCREEN_RESULT_1]) + _reject(&"choose_adaptation", ["EQ-002"]) + _reject(&"commit_adaptation") + _reject(&"choose_guild_response", ["retain_loan"]) + _reject(&"commit_guild_response") + _reject(&"choose_route", ["R6-01"]) + _reject(&"review_route") + _reject(&"cancel_route_review") + _reject(&"commit_route") + _reject(&"choose_growth", ["Hearthguard"]) + _reject(&"review_growth") + _reject(&"cancel_growth_review") + _reject(&"commit_growth") + _reject(&"commit_impact", [Contracts.ENCOUNTER_1, 0, &"atomicity", {}]) + _reject(&"resolve_impact", [Contracts.ENCOUNTER_1, 2]) + _reject(&"resolve_compat_impact", [Contracts.ENCOUNTER_1, 2]) + _reject(&"complete_phase", [Contracts.ENCOUNTER_1, 0]) + + +func _test_exact_guild_ids() -> void: + for guild_id in Contracts.GUILD_RESPONSE_IDS: + var service = _service({"screen": Contracts.SCREEN_GUILD_RESPONSE, "coin": 20}) + var before: Dictionary = service.snapshot() + _assert_success("choose_guild_response:%s" % guild_id, service, service.callv(&"choose_guild_response", [guild_id]), before) + before = service.snapshot() + _assert_success("commit_guild_response:%s" % guild_id, service, service.callv(&"commit_guild_response", []), before) + expect(service.state.guild_response == guild_id, "%s remains the committed exact Guild ID" % guild_id) + if guild_id == "retain_loan": + expect(service.state.added_rear.is_empty(), "retain_loan does not add a Recruit") + else: + expect(not service.state.added_rear.is_empty(), "add_recruit adds the mapped Recruit") + for legacy_id in ["retain_item", "recruit_rear"]: + var service = _service({"screen": Contracts.SCREEN_GUILD_RESPONSE, "coin": 20}) + var before: Dictionary = service.snapshot() + var result: Dictionary = service.choose_guild_response(legacy_id) + _assert_rejected("legacy_guild_id:%s" % legacy_id, service, before, result) + expect(service.state.guild_response.is_empty(), "%s does not leak into state" % legacy_id) + + +func _success(method: StringName, args: Array = [], fields: Dictionary = {}) -> void: + var service = _service(fields) + var before: Dictionary = service.snapshot() + var result: Dictionary = service.callv(method, args) + _assert_success(String(method), service, result, before) + + +func _reject(method: StringName, args: Array = [], fields: Dictionary = {}) -> void: + var service = _service(fields) + var before: Dictionary = service.snapshot() + var result: Dictionary = service.callv(method, args) + _assert_rejected(String(method), service, before, result) + + +func _assert_success(label: String, service, result: Dictionary, before: Dictionary) -> void: + var after: Dictionary = service.snapshot() + expect(result.ok, "%s succeeds" % label) + expect(after.revision == before.revision + 1, "%s revision delta is exactly 1, got %d" % [label, after.revision - before.revision]) + expect(result.snapshot == after, "%s returns the committed snapshot" % label) + if result.ok: + successful_commands += 1 + + +func _assert_rejected(label: String, service, before: Dictionary, result: Dictionary) -> void: + var after: Dictionary = service.snapshot() + expect(not result.ok, "%s rejects" % label) + expect(after.revision == before.revision, "%s preserves revision" % label) + expect(after == before, "%s preserves the complete snapshot" % label) + expect(result.snapshot == before, "%s returns the unchanged snapshot" % label) + expect(result.events.is_empty(), "%s emits no event" % label) + if not result.ok: + rejected_commands += 1 + + +func _service(fields: Dictionary = {}): + var service = FirstSessionServiceScript.new(FirstSessionStateScript.new()) + for field in fields: + service.state.set(field, fields[field]) + return service + + +func _formation_fields(screen: StringName) -> Dictionary: + var selected: Array[String] = ["Hearthguard", "Farstring"] + return {"screen": screen, "selected_recruits": selected, "front_choice": "Hearthguard", "rear_choice": "Farstring"} + + +func _guild_fields(guild_id: String) -> Dictionary: + return {"screen": Contracts.SCREEN_GUILD_RESPONSE, "coin": 20, "guild_response": guild_id, "adaptation_choice": "EQ-002", "front_choice": "Hearthguard", "rear_choice": "Farstring"} + + +func _growth_fields(screen: StringName, member_id := "") -> Dictionary: + return {"screen": screen, "front_choice": "Hearthguard", "rear_choice": "Farstring", "growth_member_id": member_id} + + +func _battle_fields(screen: StringName, phase: int, playing: bool) -> Dictionary: + return {"screen": screen, "front_choice": "Hearthguard", "rear_choice": "Farstring", "battle_phase": phase, "battle_playing": playing} + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.1 foundation atomicity: %s" % message)