#166-#173: plainer words, a point to shoot at, and a catalog that says when it is broken
Eight tickets. The last one is the interesting one. #166 - PLAINER DIALOG. Every spoken line rewritten: the nine prologue lines and all twenty quest brief/done pairs. Mostly splitting em-dash subclauses into sentences and dropping the literary register ("hulls" -> "ships", "That is our hands back" -> "That frees us for other work"). Same meaning, same voice. #167 - NOTHING ANNOUNCED TWICE. Quests carry an `important` flag; seven of the twenty have it, and they are the search for the World Ship. A quest handed out with its goal ALREADY met is now credited in silence unless it is one of those - no brief, no answer, both signals still fired and the index still moved, so it appears ticked in the objectives panel. The chain used to stop the game to set a task the player had already done, then stop it again to congratulate them on it. #168 - THE KEYBOARD CAN ANSWER A DIALOG. The rule the ticket asks for (still arriving, show it all; already whole, move on) was already the rule for a click since #149, but a key press reached the banner and did nothing - and this game is played with WASD and a mouse. Touch, click and key now go through one door. Modifiers alone and auto-repeat echoes are not presses. #169 - A LOADING SCREEN. A press raises a cover, and the load happens after it has been on screen for a frame, so the wait is something the player is watching. Deliberately still a blocking load: load_threaded_request() comes back FAILED for these scenes, reporting text-parse errors that load() does not produce, because the global class names and preloads they reach each other through are not resolvable off the loading thread. A progress bar is not worth a menu that sometimes refuses to start the game, and the web build ships with thread support off anyway. Further presses while loading are ignored - on the web a button fires on PRESS, so an impatient double tap costs nothing to make and used to build the world twice. #170 - THE PICTURE BEFORE THE CAPTION. The prologue's first line was up before the scene had finished arriving, so the game opened STOPPED (#128) over a convoy nobody had seen yet. It flies for three seconds first. #171 - THE CONTROLS FOLLOW WHAT IS BEING HELD. is_touchscreen_available() asks whether a touch COULD arrive, and in a browser that is yes on any laptop whose panel reports touch support - so the web build put thumb sticks over a screen being played with a mouse, and switched mouse aiming off on the machine it exists for. The reading is now the last input actually used, it lives in GameState so the sticks and the guns cannot disagree, and it is two-way: nothing can strand a tablet, because the gesture that needs the controls is the gesture that brings them back. A touch suppresses mouse evidence for a second, so the click Godot manufactures out of a tap is not mistaken for a mouse. #172 - AIM AT A PLACE. Every gun was handed the same direction, so a fleet firing at something threw a sheet of parallel bolts past either side of it and only the middle hull could hit - worse the wider the formation. Guns now take a POINT and each hull works out its own heading to it, so the fleet's fire converges. The mouse already had a place and was flattening it into a bearing; a pushed stick names the point at the guns' reach along it, so touch loses nothing. The assist (#89) still bends a shot onto an enemy, now per hull and around that hull's own heading. The aim guide stops at the point and marks it. #173 - AND THE ONE THAT EXPLAINS ITSELF. "In the web export version the fleet can not store any resources" is not a web bug. It is the third time the Godot editor's .tres writer has quietly dropped a property it thought was default-valued: InterceptorVesselDefinition losing `capabilities` leaves cargo_capacity at 20 and takes away the "cargo" claim that fleet_cargo_capacity() actually gates on, so the hold is zero and nothing can be mined or carried. That is the symptom, word for word. test_definitions_intact caught it in the suite. What it could not do is catch it in a BUILD - an export is taken from the working tree at whatever moment the editor last touched it, which is exactly when the damage is done and exactly where nobody is running tests. The checks move to core/CatalogCheck.gd and now run at startup in every build as well as in the test, from the same function. On the web push_error lands in the on-page log web_shell.html mirrors, which is the only error a phone can read. The tree is intact today, so a fresh export should restore the hold. The point of the change is that the next time it is not, the build says so. Full suite green at 154/154. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139MmxRAJSUe1j77kftAWv3
This commit is contained in:
parent
3fcc26c8bc
commit
0fad1dd155
24 changed files with 1372 additions and 286 deletions
216
tests/test_aim_converges.gd
Normal file
216
tests/test_aim_converges.gd
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_aim_converges.gd
|
||||
# Forgejo #172: "when aiming, whether with mouse or touch controls, we should
|
||||
# aim for a given point on the space (still using mouse or the joystick). As
|
||||
# it's now a point and not a direction, it lets the player try to get the
|
||||
# fleet's shot converge and not go in parallel. The auto aim when trying to
|
||||
# aim for an enemy vessel should still work."
|
||||
#
|
||||
# The failure this replaces is worth stating, because it is invisible in a
|
||||
# fleet of one and gets worse with every hull added: every gun was handed the
|
||||
# SAME direction, so a fleet firing at something threw a sheet of parallel
|
||||
# bolts and only the ship that happened to be lined up could hit. The wider
|
||||
# the formation, the further the fire missed by.
|
||||
#
|
||||
# Covers:
|
||||
# A. two hulls either side of a target aim at IT, not along one shared
|
||||
# heading - and each one's line actually arrives at the point
|
||||
# B. the mouse hands over a place: the cursor's world position, distance
|
||||
# included, rather than a bearing from the fleet's centre
|
||||
# C. a pushed stick still works, resolving to the point at the guns' reach
|
||||
# along it - the touch player loses nothing
|
||||
# D. the assist (#89) still bends a shot onto an enemy, now per hull and
|
||||
# around each hull's own heading to the point
|
||||
# E. releasing stops every gun in the fleet
|
||||
# F. range belongs to the gun, not to the aim: a point past reach is fired
|
||||
# toward rather than refused or clamped into a different heading
|
||||
|
||||
const WEAPON = preload("res://vessels/VesselWeaponComponent.gd")
|
||||
|
||||
var game_state: Node
|
||||
var main: Node
|
||||
var fleet: Node
|
||||
var controller: Node
|
||||
var mouse: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var left_ship: Node2D
|
||||
var right_ship: Node2D
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.reset_progression()
|
||||
game_state.intro_active = false
|
||||
game_state.reset_quests()
|
||||
main = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(main)
|
||||
current_scene = main
|
||||
fleet = main.get_node("Fleet")
|
||||
controller = main.get_node("FireStickController")
|
||||
mouse = main.get_node("MouseCombatController")
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_AIM_CONVERGES: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
|
||||
func _clear() -> void:
|
||||
for node in get_nodes_in_group("enemies") + get_nodes_in_group("asteroids") \
|
||||
+ get_nodes_in_group("player_projectiles"):
|
||||
if is_instance_valid(node):
|
||||
node.free()
|
||||
|
||||
|
||||
func _weapon(ship: Node2D) -> Node:
|
||||
return ship.get_component("weapon")
|
||||
|
||||
|
||||
# Where a shot from this hull would arrive if it flew far enough: the hull,
|
||||
# plus its own heading, times the distance to the aimed point. The whole of
|
||||
# "converging" is that this comes out the same for every hull.
|
||||
func _lands_at(ship: Node2D, point: Vector2) -> Vector2:
|
||||
var weapon: Node = _weapon(ship)
|
||||
return ship.global_position \
|
||||
+ weapon.firing_heading() * ship.global_position.distance_to(point)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
main.get_node("EnemySpawner").set_process(false)
|
||||
main.get_node("AsteroidField").set_process(false)
|
||||
_clear()
|
||||
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray(
|
||||
[GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR]))
|
||||
if fleet.ships.size() != 2:
|
||||
return _fail("wanted two hulls, got %d" % fleet.ships.size())
|
||||
# Placed by hand, wide apart and level, so the geometry the test
|
||||
# talks about is the geometry it set up rather than whatever the
|
||||
# current default formation happens to be.
|
||||
left_ship = fleet.ships[0]
|
||||
right_ship = fleet.ships[1]
|
||||
left_ship.global_position = Vector2(-400.0, 0.0)
|
||||
right_ship.global_position = Vector2(400.0, 0.0)
|
||||
if _weapon(left_ship) == null or _weapon(right_ship) == null:
|
||||
return _fail("an interceptor has no weapon component")
|
||||
stage = 1
|
||||
elapsed = 0.0
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
|
||||
# --- A. two hulls, one point --------------------------------
|
||||
var point := Vector2(0.0, -900.0)
|
||||
controller.aim_at(point)
|
||||
var left: Vector2 = _weapon(left_ship).firing_heading()
|
||||
var right: Vector2 = _weapon(right_ship).firing_heading()
|
||||
if left == Vector2.ZERO or right == Vector2.ZERO:
|
||||
return _fail("aiming at a point left a gun with no heading")
|
||||
if left.distance_to(right) < 0.1:
|
||||
return _fail("both hulls fire along %s - that is a direction, not a point" % left)
|
||||
for ship in [left_ship, right_ship]:
|
||||
var lands: Vector2 = _lands_at(ship, point)
|
||||
if lands.distance_to(point) > 1.0:
|
||||
return _fail("a hull at %s fires at %s, %.0f from the point it was given" \
|
||||
% [ship.global_position, lands, lands.distance_to(point)])
|
||||
print("hulls %.0f apart fire on headings %.0f degrees apart, and both arrive at the point" \
|
||||
% [left_ship.global_position.distance_to(right_ship.global_position),
|
||||
rad_to_deg(left.angle_to(right))])
|
||||
|
||||
# --- B. the mouse hands over a place ------------------------
|
||||
mouse.pointer_mode = mouse.PointerMode.ON
|
||||
mouse.select_weapon(0)
|
||||
# Deliberately NOT on the line from the fleet's centre through
|
||||
# anything: a bearing-from-the-centre implementation would still
|
||||
# pass a point straight ahead.
|
||||
var cursor := Vector2(650.0, -300.0)
|
||||
mouse.aim_world = cursor
|
||||
mouse.firing = true
|
||||
mouse._process(0.0)
|
||||
controller._process(0.0)
|
||||
for ship in [left_ship, right_ship]:
|
||||
if _weapon(ship).fire_target.distance_to(cursor) > 0.5:
|
||||
return _fail("the mouse aimed a hull at %s, the cursor was at %s" \
|
||||
% [_weapon(ship).fire_target, cursor])
|
||||
if _lands_at(left_ship, cursor).distance_to(_lands_at(right_ship, cursor)) > 1.0:
|
||||
return _fail("the two hulls' fire does not meet at the cursor")
|
||||
print("holding the button aims every hull at the cursor's own position")
|
||||
|
||||
# --- E. and releasing stops all of them ---------------------
|
||||
mouse.firing = false
|
||||
mouse._process(0.0)
|
||||
controller._process(0.0)
|
||||
for ship in [left_ship, right_ship]:
|
||||
if _weapon(ship).is_firing():
|
||||
return _fail("releasing the button left a gun firing")
|
||||
print("releasing stops every gun")
|
||||
|
||||
# --- C. a stick still resolves to a point -------------------
|
||||
var reach: float = controller.guide_range()
|
||||
if reach <= 0.0:
|
||||
return _fail("the fleet reports no gun range")
|
||||
var pushed := Vector2(0.0, -1.0)
|
||||
var resolved: Vector2 = controller.stick_point(pushed)
|
||||
var wanted: Vector2 = fleet.get_centroid() + pushed * reach
|
||||
if resolved.distance_to(wanted) > 1.0:
|
||||
return _fail("a stick pushed up resolved to %s, expected %s" % [resolved, wanted])
|
||||
controller.aim(pushed)
|
||||
for ship in [left_ship, right_ship]:
|
||||
if not _weapon(ship).is_firing():
|
||||
return _fail("a pushed stick did not fire a gun")
|
||||
if _weapon(ship).fire_target.distance_to(wanted) > 1.0:
|
||||
return _fail("a pushed stick aimed a hull somewhere other than the point it names")
|
||||
print("a pushed stick names the point at the guns' reach (%.0f) along it" % reach)
|
||||
|
||||
# --- F. reach is the gun's, not the aim's -------------------
|
||||
var far_point: Vector2 = fleet.get_centroid() + Vector2(0.0, -reach * 3.0)
|
||||
controller.aim_at(far_point)
|
||||
var to_far: Vector2 = (far_point - left_ship.global_position).normalized()
|
||||
if _weapon(left_ship).firing_heading().distance_to(to_far) > 0.01:
|
||||
return _fail("a point past the guns' reach bent the heading to %s, expected %s" \
|
||||
% [_weapon(left_ship).firing_heading(), to_far])
|
||||
print("a point beyond reach is still fired toward - the bolt falls short, the aim does not bend")
|
||||
|
||||
# --- D. the assist still works, per hull --------------------
|
||||
# Each hull asks its own question now - "is anything within MY
|
||||
# reach, near MY heading to the point" - so the case worth
|
||||
# checking is one where the two hulls answer differently. The
|
||||
# right hull is moved out past its own gun range of the enemy, so
|
||||
# it cannot be assisted onto it however the angles fall.
|
||||
_clear()
|
||||
right_ship.global_position = Vector2(reach * 1.5, 0.0)
|
||||
var target_point := Vector2(0.0, -300.0)
|
||||
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
|
||||
enemy.global_position = left_ship.global_position \
|
||||
+ (target_point - left_ship.global_position).normalized().rotated(
|
||||
deg_to_rad(15.0)) * 150.0
|
||||
main.add_child(enemy)
|
||||
enemy.speed = 0.0
|
||||
if right_ship.global_position.distance_to(enemy.global_position) <= reach:
|
||||
return _fail("the test failed to put the far hull out of range of the enemy")
|
||||
controller.aim_at(target_point)
|
||||
var to_enemy: Vector2 = (enemy.global_position - left_ship.global_position).normalized()
|
||||
if _weapon(left_ship).firing_heading().distance_to(to_enemy) > 0.02:
|
||||
return _fail("the assist no longer bends a shot onto an enemy near the aim (heading %s, enemy %s)" \
|
||||
% [_weapon(left_ship).firing_heading(), to_enemy])
|
||||
var right_wanted: Vector2 = (target_point - right_ship.global_position).normalized()
|
||||
if _weapon(right_ship).firing_heading().distance_to(right_wanted) > 0.02:
|
||||
return _fail("a hull out of range of the enemy was still bent onto it - the assist is not per hull")
|
||||
print("the assist still snaps onto an enemy near the aim, and only for the hull that can reach it")
|
||||
enemy.free()
|
||||
|
||||
print("TEST_AIM_CONVERGES: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_aim_converges.gd.uid
Normal file
1
tests/test_aim_converges.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://crknehe6qim5k
|
||||
|
|
@ -3,128 +3,79 @@ extends SceneTree
|
|||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_definitions_intact.gd
|
||||
#
|
||||
# A guard against a failure mode that has now cost this project twice, and
|
||||
# both times silently.
|
||||
# A guard against a failure mode that has now cost this project three times,
|
||||
# and every time silently.
|
||||
#
|
||||
# Opening the project in the Godot editor re-saves .tres files, and its
|
||||
# writer DROPS properties it considers default-valued. An empty-looking
|
||||
# Opening the project in the Godot editor re-saves .tres files, and its writer
|
||||
# DROPS properties it considers default-valued. An empty-looking
|
||||
# PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost
|
||||
# `capabilities` and `collected_outputs` once (which took the gas burner's
|
||||
# whole conversion with it), and InterceptorVesselDefinition later lost
|
||||
# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` -
|
||||
# which cost the starting vessel its cargo hold, so nothing could be mined.
|
||||
# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` - which
|
||||
# cost the starting vessel its cargo hold, so nothing could be mined.
|
||||
#
|
||||
# Neither showed up as an error. The resource still loaded; it was just
|
||||
# quietly less than it had been, and the failure surfaced somewhere else
|
||||
# entirely - a full suite reporting ten unrelated tests broken.
|
||||
#
|
||||
# So this asserts the SHAPE of the catalog rather than any one field: every
|
||||
# definition must carry the things that make it what it is, and a capability
|
||||
# a definition claims must have a component behind it. Anything the editor
|
||||
# eats fails here first, next to the thing that was eaten.
|
||||
# The checks themselves have moved to core/CatalogCheck.gd, because #173 was
|
||||
# the same damage a THIRD time and reached a shipped web build: a fleet that
|
||||
# could not store any resources. A test only guards the tree at the moment
|
||||
# somebody runs it, and an export is taken from the tree at whatever moment
|
||||
# the editor last touched it. The same function now runs at startup in every
|
||||
# build (GameState._ready) and here, so the two can never drift apart and a
|
||||
# damaged build says so on its own.
|
||||
#
|
||||
# Covers:
|
||||
# A. every vessel is whole - name, cost, icon, hull, and stats
|
||||
# B. a vessel with cargo declares it and has a capacity to go with it
|
||||
# C. every building is whole, and its menu wiring is complete
|
||||
# D. a converter building keeps the fields its conversion needs
|
||||
# E. no definition has lost its component list
|
||||
# A. the catalogue is whole - every vessel and building carries the things
|
||||
# that make it what it is, and the paired facts agree
|
||||
# B. the shipped build asks the same question, from the same code, so this
|
||||
# test is not a promise the game does not keep
|
||||
|
||||
var failures: Array = []
|
||||
|
||||
|
||||
func _fail(message: String) -> String:
|
||||
failures.append(message)
|
||||
return message
|
||||
|
||||
|
||||
# get() answers null for a property a definition's script never declared,
|
||||
# and null has no is_empty(). These keep a missing field from looking like
|
||||
# an empty one - and from taking the rest of the sweep down with it.
|
||||
func _text(definition: Resource, field: String) -> String:
|
||||
var value: Variant = definition.get(field)
|
||||
return "" if value == null else str(value)
|
||||
|
||||
|
||||
func _list(definition: Resource, field: String) -> Array:
|
||||
var value: Variant = definition.get(field)
|
||||
if value == null:
|
||||
return []
|
||||
return Array(value.keys() if value is Dictionary else value)
|
||||
|
||||
|
||||
func _check_vessels() -> void:
|
||||
for definition in VesselDefinitions.all():
|
||||
var id: String = String(definition.definition_id)
|
||||
# --- A. whole ---------------------------------------------------
|
||||
if String(definition.display_name).strip_edges() == "":
|
||||
_fail("vessel %s has no display name" % id)
|
||||
if definition.base_stats.is_empty():
|
||||
_fail("vessel %s has no stats at all" % id)
|
||||
# Only the hulls that actually join the fleet: a repair drone is a
|
||||
# logistics unit driven by GameState, with nothing bolted to it.
|
||||
if definition.fleet_eligible and definition.component_scenes.is_empty():
|
||||
_fail("vessel %s flies in the fleet with no components - an empty hull" % id)
|
||||
if definition.hull_points.is_empty():
|
||||
_fail("vessel %s has no hull points - it would draw as the fallback triangle" % id)
|
||||
# --- B. cargo --------------------------------------------------
|
||||
var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0
|
||||
var claims: bool = "cargo" in definition.capabilities
|
||||
if carries != claims:
|
||||
_fail("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \
|
||||
% [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities])
|
||||
if "weapon" in definition.capabilities \
|
||||
and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0:
|
||||
_fail("vessel %s claims a weapon and has no range" % id)
|
||||
|
||||
|
||||
func _check_buildings() -> void:
|
||||
for definition in BuildingDefinitions.all():
|
||||
var id: String = String(definition.definition_id)
|
||||
# --- C. whole --------------------------------------------------
|
||||
if String(definition.display_name).strip_edges() == "":
|
||||
_fail("building %s has no display name" % id)
|
||||
if String(definition.description).strip_edges() == "":
|
||||
_fail("building %s has no description - its info popup would be blank" % id)
|
||||
if int(definition.cost) <= 0:
|
||||
_fail("building %s is free" % id)
|
||||
if int(definition.max_hp) <= 0:
|
||||
_fail("building %s has no hit points" % id)
|
||||
# Space-placed structures only: an extractor is a marker on a planet
|
||||
# rather than a thing standing in the world, and has no scene.
|
||||
if definition.placement_mode == "space" and definition.visual_scene == null:
|
||||
_fail("building %s stands in space with nothing to draw" % id)
|
||||
for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]:
|
||||
if str(definition.get(field)).strip_edges() == "":
|
||||
_fail("building %s has no %s - the build menu could not place it" % [id, field])
|
||||
if definition.capabilities.is_empty():
|
||||
_fail("building %s declares no capabilities - it would do nothing at all" % id)
|
||||
# --- D. converters ---------------------------------------------
|
||||
# These live on the LOGISTICS profile, a sub-resource of the
|
||||
# definition, not on the definition itself. Asking the definition
|
||||
# for them answers null and the whole check quietly never fires -
|
||||
# which is how the first draft of this test passed while the gas
|
||||
# burner sat there with its collected_outputs missing.
|
||||
var logistics: Resource = definition.logistics
|
||||
if logistics != null and _text(logistics, "conversion_input") != "":
|
||||
if _text(logistics, "conversion_output") == "":
|
||||
_fail("building %s converts something into nothing" % id)
|
||||
if _list(logistics, "collected_outputs").is_empty():
|
||||
_fail("building %s converts but lists no collected outputs - what it makes would never be fetched" % id)
|
||||
if _list(logistics, "input_buffers").is_empty():
|
||||
_fail("building %s converts with no input buffer" % id)
|
||||
var stage: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_check_vessels()
|
||||
_check_buildings()
|
||||
if not failures.is_empty():
|
||||
for message in failures:
|
||||
# --- A. nothing has been eaten ---------------------------------------
|
||||
var faults: PackedStringArray = CatalogCheck.faults()
|
||||
if not faults.is_empty():
|
||||
for message in faults:
|
||||
print(" - %s" % message)
|
||||
print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" % failures.size())
|
||||
print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" \
|
||||
% faults.size())
|
||||
quit(1)
|
||||
return
|
||||
print("%d vessels and %d buildings, all intact" \
|
||||
% [VesselDefinitions.all().size(), BuildingDefinitions.all().size()])
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
if stage != 0:
|
||||
return false
|
||||
stage = 1
|
||||
|
||||
# --- B. and the build itself checked, from the same code -------------
|
||||
# The whole point of #173 is that the suite is not where this damage gets
|
||||
# noticed. GameState runs the check on startup and keeps what it found, so
|
||||
# a build with an eaten .tres reports it instead of quietly failing to
|
||||
# mine - on the web, into the on-page error log.
|
||||
var game_state: Node = root.get_node("GameState")
|
||||
if not (game_state.catalog_faults is PackedStringArray):
|
||||
return _fail("GameState does not record what the startup check found")
|
||||
if not game_state.catalog_faults.is_empty():
|
||||
return _fail("the running build reports %d catalog fault(s) this test did not" \
|
||||
% game_state.catalog_faults.size())
|
||||
var source: String = FileAccess.get_file_as_string("res://core/GameState.gd")
|
||||
if "CATALOG_CHECK.faults()" not in source or "push_error" not in source:
|
||||
return _fail("GameState no longer runs the catalog check at startup, so a damaged build would ship silently again")
|
||||
print("the running build ran the same check on startup and found nothing")
|
||||
|
||||
print("TEST_DEFINITIONS_INTACT: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_DEFINITIONS_INTACT: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -83,16 +83,23 @@ func _process(delta: float) -> bool:
|
|||
print("weapon 1 is the fleet's guns, weapon 2 is the gas bomb, %d in total" \
|
||||
% mouse.weapon_count())
|
||||
|
||||
# --- F. inert where a touchscreen exists -----------------------
|
||||
# Checked against the real capability rather than forced, so this
|
||||
# reports what will actually happen on the machine it runs on.
|
||||
print("touchscreen present: %s -> mouse combat enabled: %s" \
|
||||
% [DisplayServer.is_touchscreen_available(), mouse.is_enabled()])
|
||||
# --- F. inert while the player is using touch ------------------
|
||||
# #171: the gate used to be "does this machine have a
|
||||
# touchscreen", which is a capability rather than a fact and is
|
||||
# answered wrongly by every browser on a touch-capable laptop. It
|
||||
# is now the last input actually used - which, unlike the display
|
||||
# query, can be driven here, so both branches are exercised
|
||||
# instead of whichever one this machine happens to report.
|
||||
if mouse.pointer_mode != mouse.PointerMode.AUTO:
|
||||
return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested")
|
||||
if mouse.is_enabled() == DisplayServer.is_touchscreen_available():
|
||||
return _fail("mouse combat is not gated on the absence of a touchscreen")
|
||||
print("gated correctly on the display's own answer")
|
||||
game_state.note_touch_input()
|
||||
if mouse.is_enabled():
|
||||
return _fail("mouse combat is armed while the player is using touch - a tap would fire the guns")
|
||||
game_state.last_touch_msec = -1
|
||||
game_state.note_pointer_input()
|
||||
if not mouse.is_enabled():
|
||||
return _fail("mouse combat is off for a player using a mouse")
|
||||
print("gated on the last input used: off under touch, on under a mouse")
|
||||
|
||||
# Everything below is about the AIMING, not the guard. Headless
|
||||
# reports a touchscreen, so the mode is forced on here - otherwise
|
||||
|
|
|
|||
|
|
@ -4,35 +4,51 @@ extends SceneTree
|
|||
# godot --headless -s res://tests/test_desktop_hides_touch_controls.gd
|
||||
# Forgejo #85: on a computer the on-screen sticks and the pivot pair are dead
|
||||
# weight - WASD steers, the mouse aims, Q/E pivot.
|
||||
# Forgejo #171: "the web export version should auto detect keyboard mouse or
|
||||
# screen and remove the touch screen controls if not needed."
|
||||
#
|
||||
# #85 asked DisplayServer.is_touchscreen_available() and, if it said yes, kept
|
||||
# the controls for ever. In a browser that query says yes on any machine whose
|
||||
# panel or pointer reports touch support, so the web build put thumb sticks
|
||||
# over a screen being played with a mouse - and the decision could not be
|
||||
# revisited, because it was made once.
|
||||
#
|
||||
# The rule is now the last input the player ACTUALLY used, which is a fact
|
||||
# rather than a capability, and it is two-way. That also removes the reason
|
||||
# the old rule had to be so cautious: nothing can strand a tablet with no
|
||||
# controls, because the gesture that needs them is the gesture that brings
|
||||
# them back.
|
||||
#
|
||||
# Covers:
|
||||
# A. they all start visible, so this is testing a change and not a default
|
||||
# B. using a keyboard hides all four at once on a machine with no
|
||||
# touchscreen
|
||||
# C. a touch device keeps every one of them, even with a keyboard attached -
|
||||
# a finger is still a way to play it
|
||||
#
|
||||
# The touchscreen answer is read from DisplayServer, and headless does not
|
||||
# answer the same way everywhere: Godot on Android says yes, Godot on Windows
|
||||
# says no. So B is exercised by driving the handler directly under a stubbed
|
||||
# answer, and C is skipped - not failed - wherever there is no touchscreen to
|
||||
# exercise it with. That is the whole behaviour: "no touchscreen + keyboard
|
||||
# used".
|
||||
# B. a keyboard takes all four away, together
|
||||
# C. a touch brings all four back, together - on any device, whatever the
|
||||
# display claims
|
||||
# D. a mouse takes them away again, so the detection is not one-shot
|
||||
# E. the manufactured event is not believed: Godot makes a mouse click out
|
||||
# of every touch, and one arriving straight after a touch must not read
|
||||
# as "this player has a mouse"
|
||||
# F. the two consumers agree - the same reading gates mouse aiming, so the
|
||||
# sticks and the guns can never disagree about what is being held
|
||||
|
||||
const CONTROLS: Array = ["UI/Joystick", "UI/FireStick", "UI/PivotCWButton", "UI/PivotCCWButton"]
|
||||
|
||||
var game_state: Node
|
||||
var main: Node
|
||||
var controller: Node
|
||||
var mouse: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
root.get_node("GameState").reset_progression()
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.reset_progression()
|
||||
main = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(main)
|
||||
current_scene = main
|
||||
controller = main.get_node("GameController") if main.has_node("GameController") else main
|
||||
mouse = main.get_node_or_null("MouseCombatController")
|
||||
|
||||
|
||||
func _all_visible() -> bool:
|
||||
|
|
@ -49,6 +65,13 @@ func _any_visible() -> bool:
|
|||
return false
|
||||
|
||||
|
||||
# A touch far enough in the past that the grace window has expired. Ageing the
|
||||
# stamp rather than sleeping for a second: the window is what is under test,
|
||||
# not the wall clock.
|
||||
func _forget_touch() -> void:
|
||||
game_state.last_touch_msec = -1
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
if stage != 0:
|
||||
|
|
@ -62,43 +85,54 @@ func _process(delta: float) -> bool:
|
|||
return _fail("a touch control was already hidden before any input")
|
||||
print("all four touch controls start visible")
|
||||
|
||||
# --- C. a touchscreen keeps them ----------------------------------
|
||||
# Only where the machine actually reports one: there the real code path
|
||||
# is the touch-device one and can be driven as-is. Where it reports none
|
||||
# there is nothing to drive, and the static check at the end of B still
|
||||
# guards the touchscreen condition itself, so skip rather than fail.
|
||||
if DisplayServer.is_touchscreen_available():
|
||||
controller.keyboard_seen = false
|
||||
controller._note_keyboard()
|
||||
if not _all_visible():
|
||||
return _fail("a touch device hid its controls when a keyboard was used")
|
||||
print("touch device keeps all four even after keyboard input")
|
||||
else:
|
||||
print("SKIP case C: this environment reports no touchscreen")
|
||||
|
||||
# --- B. no touchscreen: they all go -------------------------------
|
||||
# The guard is the FIRST thing _note_keyboard checks, so the branch is
|
||||
# reproduced here rather than faked: hide exactly what the function
|
||||
# hides, then assert the function agrees.
|
||||
for path in CONTROLS:
|
||||
main.get_node(path).visible = false
|
||||
# --- B. a keyboard takes them away --------------------------------
|
||||
_forget_touch()
|
||||
controller._note_keyboard()
|
||||
if _any_visible():
|
||||
return _fail("could not stage the hidden state")
|
||||
print("with no touchscreen all four are hidden together")
|
||||
return _fail("using a keyboard left a touch control on screen")
|
||||
if game_state.pointer_is_touch:
|
||||
return _fail("a keyboard did not change the reading to \"not touch\"")
|
||||
print("using a keyboard hides all four together")
|
||||
|
||||
# The four are hidden as one list, so a control added to that list later
|
||||
# is covered without editing this test. Guard the list itself instead.
|
||||
var source: FileAccess = FileAccess.open("res://controllers/GameController.gd", FileAccess.READ)
|
||||
if source == null:
|
||||
return _fail("could not read GameController.gd")
|
||||
var text: String = source.get_as_text()
|
||||
source.close()
|
||||
for control_name in ["joystick", "fire_stick", "pivot_cw_button", "pivot_ccw_button"]:
|
||||
if control_name not in text.split("func _note_keyboard()")[1].split("func ")[0]:
|
||||
return _fail("_note_keyboard no longer hides %s" % control_name)
|
||||
if "is_touchscreen_available" not in text.split("func _note_keyboard()")[1].split("func ")[0]:
|
||||
return _fail("_note_keyboard no longer checks for a touchscreen before hiding anything")
|
||||
print("_note_keyboard hides all four, and only where there is no touchscreen")
|
||||
# --- C. and a touch brings them back ------------------------------
|
||||
# The case the old one-shot rule could not do at all, and the reason the
|
||||
# new rule is safe to apply on every platform rather than only where the
|
||||
# display says there is no touchscreen.
|
||||
controller._note_touch()
|
||||
if not _all_visible():
|
||||
return _fail("touching the screen did not bring the touch controls back")
|
||||
if not game_state.pointer_is_touch:
|
||||
return _fail("a touch did not change the reading to \"touch\"")
|
||||
print("a touch brings all four back")
|
||||
|
||||
# --- E. the manufactured click is not believed --------------------
|
||||
# Godot turns every touch into a click (emulate_mouse_from_touch), so the
|
||||
# click below is what a phone produces one frame after the touch above.
|
||||
# Believing it would take the controls away from a player holding the
|
||||
# device in their hands.
|
||||
controller._note_keyboard()
|
||||
if not _all_visible():
|
||||
return _fail("a click arriving right after a touch took the controls away - that click is the touch")
|
||||
print("a click in the shadow of a touch is ignored")
|
||||
|
||||
# --- D. a real mouse, later, does take them away ------------------
|
||||
_forget_touch()
|
||||
controller._note_keyboard()
|
||||
if _any_visible():
|
||||
return _fail("a mouse used well after the last touch did not hide the controls")
|
||||
print("a mouse used later hides them again - the detection is not one-shot")
|
||||
|
||||
# --- F. and the guns read the same answer -------------------------
|
||||
if mouse == null:
|
||||
return _fail("no MouseCombatController in the scene")
|
||||
if mouse.pointer_mode != mouse.PointerMode.AUTO:
|
||||
return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested")
|
||||
if not mouse.is_enabled():
|
||||
return _fail("the controls are hidden for a mouse but mouse aiming is off")
|
||||
controller._note_touch()
|
||||
if mouse.is_enabled():
|
||||
return _fail("a touch put the thumb controls back but left mouse firing armed - a tap would fire the guns")
|
||||
print("mouse aiming follows the same reading the controls do")
|
||||
|
||||
print("TEST_DESKTOP_HIDES_TOUCH_CONTROLS: PASS")
|
||||
quit(0)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ extends SceneTree
|
|||
# E. it types while the game is stopped, being the one node still running
|
||||
# F. the sci-fi part: the panel is drawn differently while decoding, and
|
||||
# settles when the line is complete
|
||||
# G. #168 - a KEY does exactly what a click does, both halves of it, since
|
||||
# the game is played on a desktop with WASD and a mouse and pressing
|
||||
# space at a dialog reached nothing at all. Modifiers alone and held-key
|
||||
# echoes are not presses, or a line would be run out by a held key.
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
|
|
@ -53,6 +57,14 @@ func _click() -> void:
|
|||
narration._unhandled_input(press)
|
||||
|
||||
|
||||
func _key(keycode: int, echo: bool = false) -> void:
|
||||
var press: InputEventKey = InputEventKey.new()
|
||||
press.keycode = keycode
|
||||
press.pressed = true
|
||||
press.echo = echo
|
||||
narration._unhandled_input(press)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
|
|
@ -139,6 +151,38 @@ func _process(delta: float) -> bool:
|
|||
return _fail("dismissing landed on \"%s\"" % narration.current)
|
||||
print("the press after that moves on to \"%s\"" % narration.current)
|
||||
|
||||
# --- G. #168: a key is a press too -----------------------
|
||||
narration.clear()
|
||||
narration.say(first_line, "TEST")
|
||||
narration.say("Second.", "TEST")
|
||||
if not narration.is_typing():
|
||||
return _fail("the fresh line was not arriving")
|
||||
# Not everything that reaches the banner is a press.
|
||||
_key(KEY_SHIFT)
|
||||
_key(KEY_SPACE, true)
|
||||
if not narration.is_typing():
|
||||
return _fail("a modifier or a held-key echo finished the line")
|
||||
# Still arriving: the key shows the whole of it, and no more.
|
||||
_key(KEY_SPACE)
|
||||
if narration.is_typing():
|
||||
return _fail("a key did not finish a line that was still arriving")
|
||||
if narration.current != first_line:
|
||||
return _fail("a key on an arriving line dismissed it - the next line would be skipped unread")
|
||||
# Whole: the key moves on, the same as a click.
|
||||
_key(KEY_SPACE)
|
||||
if narration.current != "Second.":
|
||||
return _fail("a key on a finished line landed on \"%s\"" % narration.current)
|
||||
# Any key, not one chosen key - the player has no reason to guess.
|
||||
_key(KEY_W)
|
||||
if narration.is_typing():
|
||||
return _fail("only some keys finish a line")
|
||||
_key(KEY_ENTER)
|
||||
if narration.is_speaking():
|
||||
return _fail("a key on the last line did not close the dialog")
|
||||
if paused:
|
||||
return _fail("keying through every line left the game stopped")
|
||||
print("space, W and enter all finish then advance a line; shift and echoes do not")
|
||||
|
||||
print("TEST_DIALOG_TYPING: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ extends SceneTree
|
|||
# acknowledging it is what starts the world
|
||||
# Q. #133 - the handover runs straight into the first quest
|
||||
# R. #139 - no belt rock is laid down inside the visible screen
|
||||
# S. #170 - the prologue opens on the PICTURE: the convoy is flying and the
|
||||
# game is running for a few seconds before the guide says anything
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
|
|
@ -97,10 +99,43 @@ func _process(delta: float) -> bool:
|
|||
if not director.running:
|
||||
return _fail("the director did not start on a new game")
|
||||
|
||||
# --- S. #170: it opens on the picture, not the caption ---
|
||||
# The scene is a third of a second old here, well inside the
|
||||
# silence, so the convoy should be flying and nothing should be
|
||||
# on the banner.
|
||||
if narration.is_speaking():
|
||||
return _fail("the guide was already talking %.2fs in - #170 asks for a few seconds first" \
|
||||
% director.beat_time)
|
||||
if paused:
|
||||
return _fail("the prologue opened stopped, with nothing on the banner to stop it")
|
||||
# Hand-ticked from here so the silence is MEASURED rather than
|
||||
# waited out, and so the tree's own clock cannot race it.
|
||||
director.set_process(false)
|
||||
var ticks: int = 0
|
||||
while not narration.is_speaking() and ticks < 1200:
|
||||
director._process(1.0 / 60.0)
|
||||
ticks += 1
|
||||
if narration.is_speaking():
|
||||
break
|
||||
if director.beat_time > director.OPENING_SILENCE + 0.05:
|
||||
return _fail("the clock passed the %.1fs silence at %.2fs and the guide still had not spoken" \
|
||||
% [director.OPENING_SILENCE, director.beat_time])
|
||||
if not narration.is_speaking():
|
||||
return _fail("the guide never spoke at all")
|
||||
if director.beat_time < director.OPENING_SILENCE:
|
||||
return _fail("the guide spoke at %.2fs, inside the %.1fs silence" \
|
||||
% [director.beat_time, director.OPENING_SILENCE])
|
||||
print("the prologue flies for %.1fs before the guide calls in" % director.beat_time)
|
||||
director.set_process(true)
|
||||
|
||||
# --- N. a dialog waits for the reader --------------------
|
||||
var first_line: String = narration.label.text
|
||||
if first_line.strip_edges() == "":
|
||||
return _fail("the prologue opened with an empty banner")
|
||||
return _fail("the guide spoke with an empty banner")
|
||||
# The beat clock as the line went up. Nothing may move it on while
|
||||
# the line is unread (#128) - the tree is stopped, and this test
|
||||
# stops hand-ticking the director for exactly that reason.
|
||||
var clock_at_line: float = director.beat_time
|
||||
# Ten seconds of clock, with nothing pressed. A timer-driven
|
||||
# banner would have moved on several times over.
|
||||
for i in range(600):
|
||||
|
|
@ -112,10 +147,10 @@ func _process(delta: float) -> bool:
|
|||
# --- P. and the game is STOPPED behind it (#128) ---------
|
||||
if not paused:
|
||||
return _fail("the prologue opened on a line with the game still running")
|
||||
if director.beat_time > 0.01:
|
||||
if absf(director.beat_time - clock_at_line) > 0.01:
|
||||
return _fail("the director's clock ran on %.2fs behind an unread line" \
|
||||
% director.beat_time)
|
||||
print("the prologue opens stopped: beat clock at %.2fs" % director.beat_time)
|
||||
% absf(director.beat_time - clock_at_line))
|
||||
print("the line stopped the game: beat clock held at %.2fs" % director.beat_time)
|
||||
|
||||
var click: InputEventMouseButton = InputEventMouseButton.new()
|
||||
click.button_index = MOUSE_BUTTON_LEFT
|
||||
|
|
|
|||
|
|
@ -75,15 +75,21 @@ func _process(delta: float) -> bool:
|
|||
_key(k, false)
|
||||
print("all %d key combinations produced the right heading" % cases.size())
|
||||
|
||||
# The stick must survive on a touch device.
|
||||
var stick_kept: bool = joystick.visible
|
||||
print("stick still visible after keyboard use: %s (touchscreen=%s)" % [
|
||||
stick_kept, DisplayServer.is_touchscreen_available()
|
||||
])
|
||||
if DisplayServer.is_touchscreen_available() and not stick_kept:
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (took the stick away on a touch device)")
|
||||
# #171: the stick follows the last input actually used, rather
|
||||
# than the display's claim about what could arrive. Using the
|
||||
# keyboard takes it away - on any device - and a touch brings it
|
||||
# straight back, which is what keeps that safe.
|
||||
if joystick.visible:
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (the stick survived keyboard use)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._note_touch()
|
||||
if not joystick.visible:
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (a touch did not bring the stick back)")
|
||||
quit(1)
|
||||
return true
|
||||
print("keyboard use hides the stick; a touch brings it back")
|
||||
root.get_node("GameState").last_touch_msec = -1
|
||||
|
||||
# --- holding a key flies the fleet ----------------------------
|
||||
start_centroid = fleet.get_centroid()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ extends SceneTree
|
|||
# 2. there is no Quit entry in any build - a web build has nothing to quit
|
||||
# to, and Android's back gesture already does it
|
||||
# 3. Play resets GameState to a fresh game AND reaches Main.tscn
|
||||
# 4. #169 - a press puts a loading screen up FIRST and swaps the scene
|
||||
# afterwards. A button that does nothing for several seconds reads as a
|
||||
# button that was not pressed, which is how the web build reads; the
|
||||
# order is the whole fix, so it is the thing asserted.
|
||||
# 5. #169 - a second press while a load is running is ignored, or an
|
||||
# impatient double tap builds the world twice (on the web a button fires
|
||||
# on PRESS, so that tap costs nothing to make)
|
||||
|
||||
var menu: Node
|
||||
var game_state: Node
|
||||
|
|
@ -46,17 +53,37 @@ func _process(delta: float) -> bool:
|
|||
return _fail("the menu still carries a Quit entry")
|
||||
|
||||
_press(menu.editor_button)
|
||||
|
||||
# --- 4. the cover goes up before anything blocks --------------
|
||||
if menu.loading_path != "res://surface/PlanetSetupEditor.tscn":
|
||||
return _fail("the press did not start a load (loading_path %s)" % menu.loading_path)
|
||||
if menu.loading_screen == null or not menu.loading_screen.is_inside_tree():
|
||||
return _fail("the press started a load with no loading screen up")
|
||||
if current_scene != menu:
|
||||
return _fail("the scene swapped inside the press, so nothing was ever shown")
|
||||
print("pressing an entry raises the loading screen and leaves the menu up")
|
||||
|
||||
# --- 5. and a second press changes nothing --------------------
|
||||
var screen: Node = menu.loading_screen
|
||||
_press(menu.editor_button)
|
||||
_press(menu.play_button)
|
||||
if menu.loading_screen != screen \
|
||||
or menu.loading_path != "res://surface/PlanetSetupEditor.tscn":
|
||||
return _fail("a second press while loading started another scene")
|
||||
print("further presses while a load is running are ignored")
|
||||
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# _start awaits a frame so its status text paints before the
|
||||
# blocking load, so the change takes a couple of frames.
|
||||
if elapsed < 1.0:
|
||||
return false
|
||||
# Waited on rather than timed: the load is spread over frames now,
|
||||
# and how many it takes is a property of the machine.
|
||||
var on_editor: bool = current_scene != null and current_scene.name == "PlanetSetupEditor"
|
||||
if not on_editor:
|
||||
return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn")
|
||||
if elapsed < 30.0:
|
||||
return false
|
||||
return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn in 30s")
|
||||
print("the load finished and the scene swapped")
|
||||
|
||||
# Return to the menu the same way the player would, then test Play.
|
||||
menu = load("res://ui/MainMenu.tscn").instantiate()
|
||||
|
|
@ -80,9 +107,9 @@ func _process(delta: float) -> bool:
|
|||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 1.0:
|
||||
return false
|
||||
var on_main: bool = current_scene != null and current_scene.name == "Main"
|
||||
if not on_main and elapsed < 30.0:
|
||||
return false
|
||||
# A normal game now starts with NOTHING banked - the opening
|
||||
# mining run is the tutorial, and 500 free ore skipped it.
|
||||
var reset_ok: bool = game_state.rocks == 0 and game_state.buildings.is_empty() \
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ extends SceneTree
|
|||
# is announced down there, not on the way back up, and the objective is
|
||||
# reachable from a button there as well
|
||||
# K. landing is recorded, both the planet and the kind of world it was
|
||||
# L. #167 - a TEACHING quest handed out with its goal already met is
|
||||
# credited without a word, while a STORY quest in the same position is
|
||||
# still read out
|
||||
|
||||
const QUESTS = preload("res://core/Quests.gd")
|
||||
|
||||
|
|
@ -316,6 +319,61 @@ func _catalogue_cases() -> bool:
|
|||
return _fail("there is still an active quest after the chain ended")
|
||||
game_state._advance_quests()
|
||||
print("the chain walks to the end and stops there")
|
||||
|
||||
# --- L. #167: nothing is announced twice about a job already done ----
|
||||
# The chain is handed out in a fixed order, so by the time some of its
|
||||
# teaching quests come up the player has long since done the thing -
|
||||
# banked the ore, put the extractor down. Announcing one of those stops
|
||||
# the game to set a task, then stops it again to congratulate them on a
|
||||
# task they never did.
|
||||
#
|
||||
# The story quests are the exception, and the flag has to actually
|
||||
# separate the two: a chain where everything is important skips nothing,
|
||||
# and one where nothing is skips the plot.
|
||||
var important: int = 0
|
||||
for i in range(QUESTS.count()):
|
||||
if QUESTS.is_important(QUESTS.at(i)):
|
||||
important += 1
|
||||
if important == 0:
|
||||
return _fail("no quest carries the story, so every already-met quest would be silent")
|
||||
if important == QUESTS.count():
|
||||
return _fail("every quest is important, so nothing is ever skipped")
|
||||
for story in ["mothership", "planetfall"]:
|
||||
if not QUESTS.is_important(QUESTS.by_id(story)):
|
||||
return _fail("the %s quest is not marked important - #167 names it as story" % story)
|
||||
print("%d of %d quests carry the story, the rest are teaching" \
|
||||
% [important, QUESTS.count()])
|
||||
|
||||
game_state.reset_new_game()
|
||||
game_state.intro_active = false
|
||||
# Enough ore that the first two quests are both already satisfied the
|
||||
# moment they are handed out. One is story, one is teaching.
|
||||
game_state.bank_inventory.deposit(GameState.RESOURCE_ORE, 500)
|
||||
if not QUESTS.is_important(QUESTS.at(0)) or QUESTS.is_important(QUESTS.at(1)):
|
||||
return _fail("this case needs a story quest followed by a teaching one, and the chain opens %s then %s" \
|
||||
% [QUESTS.at(0).id, QUESTS.at(1).id])
|
||||
game_state._advance_quests()
|
||||
if game_state.quest_index != 0 or game_state.quest_messages.is_empty():
|
||||
return _fail("a story quest whose goal was already met was skipped - the plot went with it")
|
||||
game_state.take_quest_messages()
|
||||
game_state._advance_quests()
|
||||
if game_state.quest_index != 1:
|
||||
return _fail("the story quest did not close on the frame after it was announced")
|
||||
game_state.take_quest_messages()
|
||||
|
||||
var skipped: String = String(QUESTS.at(1).id)
|
||||
game_state._advance_quests()
|
||||
if game_state.quest_index != 2:
|
||||
return _fail("the already-met %s quest was announced instead of being banked" % skipped)
|
||||
if not game_state.quest_messages.is_empty():
|
||||
return _fail("the skipped %s quest still said \"%s\"" \
|
||||
% [skipped, game_state.quest_messages[0].line])
|
||||
# ...and it was CREDITED, not dropped. quest_index is what the objectives
|
||||
# panel counts its DONE list off, so the player still sees it ticked.
|
||||
if String(game_state.quest_at(game_state.quest_index - 1).id) != skipped:
|
||||
return _fail("the skipped quest is not the last one in the done list")
|
||||
print("\"%s\" was already done when it was handed out: credited, and not a word said" \
|
||||
% QUESTS.by_id(skipped).title)
|
||||
return false
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue