extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_research_double_tap.gd # Forgejo #83: the research tree's separate buy button is gone. Tapping a node # selects it; tapping the SAME node again buys it. # # Covers: # A. the buy button really is gone from the scene, not merely bypassed - a # leftover would still be tappable and would still be the thing a player # reaches for # B. the first tap selects and does NOT buy, so browsing the tree is free # C. a second tap on the same node buys it # D. moving to a DIFFERENT node resets that - two taps on two nodes must # never add up to a purchase # E. tapping an unaffordable node twice buys nothing and says why # F. one physical tap counts ONCE, even though the device delivers it twice # - as a touch and as the mouse button emulated from it. It did not, and # that is #162: the duplicate found the node already selected, took # itself for the confirming tap, and bought the research on first touch var game_state: Node var main: Node var tree_view: Node var controller: Node var stage: int = 0 var elapsed: float = 0.0 const NODE_ID := "shipyards" func _initialize() -> void: game_state = root.get_node("GameState") game_state.buildings.clear() game_state.extractors.clear() game_state.reset_progression() main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main controller = main.get_node("BuildController") tree_view = main.get_node("UI/SkillMenu/Tree") func _node_position(id: String) -> Vector2: return tree_view.global_position + tree_view.node_screen_pos(game_state.skill_node(id)) # ONE physical tap, delivered the way a real device delivers it: as a touch # AND as the mouse button Godot emulates from it. Both settings are on # (emulate_mouse_from_touch and emulate_touch_from_mouse), and the menu's # dispatcher routes both kinds to the tree - so a handler that is not # idempotent per gesture sees every tap twice. It did, and that made the FIRST # tap buy (#162). Going through both paths here is the whole point; tapping # only one of them would pass while the game stayed broken. func _tap(id: String) -> void: var at: Vector2 = _node_position(id) var press := InputEventScreenTouch.new() press.index = 1 press.pressed = true press.position = at tree_view.handle_touch(press) var emulated := InputEventMouseButton.new() emulated.button_index = MOUSE_BUTTON_LEFT emulated.pressed = true emulated.position = at emulated.global_position = at tree_view.handle_mouse_button(emulated) var release := InputEventScreenTouch.new() release.index = 1 release.pressed = false release.position = at tree_view.handle_touch(release) var emulated_release := InputEventMouseButton.new() emulated_release.button_index = MOUSE_BUTTON_LEFT emulated_release.pressed = false emulated_release.position = at emulated_release.global_position = at tree_view.handle_mouse_button(emulated_release) func _process(delta: float) -> bool: elapsed += delta if stage != 0: return false if elapsed < 0.3: return false stage = 1 # --- A. the button is gone --------------------------------------------- if main.get_node_or_null("UI/SkillMenu/UnlockButton") != null: return _fail("the research panel still has an UnlockButton node") print("the separate buy button is gone from the panel") main.get_node("UI/SkillMenu").visible = true tree_view.visible = true tree_view.reset_scroll() game_state.unlocked.clear() game_state.research_points = 99 game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0) var cost: int = int(game_state.skill_node(NODE_ID).cost) var before: int = game_state.available_ore() # --- B. the first tap only selects ------------------------------------- _tap(NODE_ID) if tree_view.selected_id != NODE_ID: return _fail("the first tap did not select the node") if game_state.is_unlocked(NODE_ID): return _fail("the first tap bought the research - browsing the tree must be free") if game_state.available_ore() != before: return _fail("the first tap spent %d" % (before - game_state.available_ore())) print("first tap selects and spends nothing") # --- C. the second tap buys -------------------------------------------- _tap(NODE_ID) if not game_state.is_unlocked(NODE_ID): return _fail("a second tap on the same node did not buy it") if game_state.available_ore() != before - cost: return _fail("bought for %d, expected %d" % [before - game_state.available_ore(), cost]) print("second tap on the same node buys it (%d ore)" % cost) # --- D. two taps on two nodes are not a purchase ----------------------- var first: String = "" var second: String = "" for node in GameState.SKILL_TREE: var id: String = String(node.id) if game_state.is_unlocked(id) or not game_state.prerequisite_met(id): continue if first == "": first = id elif second == "": second = id break if second == "": return _fail("could not find two unlocked-but-available nodes for the cross-node case") var ore_before: int = game_state.available_ore() _tap(first) _tap(second) if game_state.is_unlocked(first) or game_state.is_unlocked(second): return _fail("tapping two different nodes bought one of them") if game_state.available_ore() != ore_before: return _fail("tapping two different nodes spent %d" % (ore_before - game_state.available_ore())) print("one tap each on two nodes buys neither") # --- E. unaffordable stays unaffordable -------------------------------- game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0) _tap(second) _tap(second) if game_state.is_unlocked(second): return _fail("a node was bought with no ore at all") controller._refresh_skill_menu(second) # #134: the refusal is shown on the price rather than written out in a # sentence below it - what you hold over what it costs, in red. if controller.skill_menu.cost.is_affordable(): return _fail("a node the player cannot pay for reads as affordable") if not controller.skill_menu.cost.is_short(GameState.RESOURCE_ORE): return _fail("the ore the player does not have is not marked short") print("unaffordable node refused, and its price says so: %d/%d ore" \ % [controller.skill_menu.cost.held(GameState.RESOURCE_ORE), controller.skill_menu.cost.priced(GameState.RESOURCE_ORE)]) print("TEST_RESEARCH_DOUBLE_TAP: PASS") quit(0) return true func _fail(message: String) -> bool: print("TEST_RESEARCH_DOUBLE_TAP: FAIL (%s)" % message) quit(1) return true