# The man at the front desk, end to end: that he is stood at his post in his
# uniform with his cap on, that talking to him works and his answers come out
# with their numbers filled in, that the questions worth asking are the ones on
# offer, and that he comes looking for the player once the calls start going
# wrong. Plus the thing the visitors needed him to prove: that the furniture is
# something they walk round rather than through.
#
# `godot --headless res://tests/guide_test.tscn`
extends Node

var _fails: Array[String] = []

func _ready() -> void:
	process_mode = Node.PROCESS_MODE_ALWAYS
	var tree := get_tree()
	tree.root.add_child.call_deferred(load("res://scenes/main.tscn").instantiate())

	var main: Node = null
	for i in 900:
		await tree.process_frame
		main = tree.root.get_node_or_null("Main")
		if main == null:
			continue
		var screens: Node = main.get_node_or_null("%ScreenHost")
		if screens != null and screens.get_child_count() >= 2 \
				and screens.get_child(screens.get_child_count() - 1).name == "Menu":
			break
	if main == null:
		_fails.append("the game never booted")
		_report()
		return

	var host: Node = main.get_node("%ScreenHost")
	host.get_child(host.get_child_count() - 1).start_pressed.emit()
	await tree.process_frame
	var run: Node3D = host.get_child(0)
	for i in 20:
		await tree.process_frame

	_check_where_the_words_come_from()
	_check_he_is_a_different_man_each_run()
	await _check_the_museum_opens(run, tree)
	_check_he_is_at_his_post(run)
	_check_the_face_in_the_box_is_his(run)
	_check_the_furniture_is_in_the_way(run)
	_check_the_piece_outranks_the_guide(run)
	await _check_talking(run, tree)
	await _check_first_meeting(run, tree)
	_check_he_walks_round_things(run)
	_check_he_stops_short_of_a_bench(run)
	await _check_he_comes_over(run, tree)
	await _check_he_answers_the_phone(run, tree)
	await _check_he_is_asked_about_the_piece(run, tree)
	await _check_he_gives_in_and_shows_you(run, tree)
	await _check_he_keeps_to_the_rooms(run, tree)
	await _check_clicking_off_the_box_ends_it(run, tree)
	await _check_the_museum_stays_up_a_moment(run, tree)
	# Last, because it names every fake in the gallery and there is no going back
	# from that.
	_check_the_last_fake_is_cheered(run)

	tree.paused = false
	_report()

# --- Getting out of things ---------------------------------------------------

# Naming the last fake in the gallery is said out loud and played. Once: this
# hangs off the same refresh that runs on every mark and every doorway, so
# without a latch a man walking to the way out is congratulated at every step.
func _check_the_last_fake_is_cheered(run: Node3D) -> void:
	# The earlier checks left a wrong call behind on purpose, to make him come over
	# about it. Cleared first, or the rope never lifts and there is nothing to
	# cheer.
	for index in run.museum.rooms.size():
		var room: Dictionary = run.museum.rooms[index]
		if room["piece"].is_empty() or not room["authentic"]:
			continue
		room["mark"] = ""
		if run._rooms.has(index) and run._rooms[index].artwork != null:
			run._rooms[index].artwork.set_mark("")
	run._cheered = false
	run._call_every_fake()
	_check(run._cheered, "every fake named and nothing was said about it")
	_check(run._fanfare != null and run._fanfare.stream != null,
		"there was no flourish to play")
	if run._fanfare == null or run._fanfare.stream == null:
		return
	var wav: AudioStreamWAV = run._fanfare.stream
	var seconds := float(wav.data.size() / 2) / float(Fanfare.RATE)
	var wanted := 0.0
	for note: Dictionary in Fanfare.NOTES:
		wanted += float(note["seconds"])
	_check(absf(seconds - wanted) < 0.02,
		"the flourish is %.2f seconds long and should be %.2f" % [seconds, wanted])
	# Loud enough to hear and not so loud it clips: a normalisation gone wrong
	# ships either silence or a fuzz, and both play perfectly happily.
	var peak := 0
	for i in range(0, wav.data.size(), 2):
		var sample: int = wav.data[i] | (wav.data[i + 1] << 8)
		if sample >= 32768:
			sample -= 65536
		peak = maxi(peak, absi(sample))
	_check(peak > 9000 and peak < 32767,
		"the flourish peaks at %d of 32767" % peak)

	# And it does not play again every time the rope is looked at.
	run._fanfare.queue_free()
	run._fanfare = null
	run._refresh_exit()
	_check(run._fanfare == null, "the flourish plays again on every refresh")

# A click anywhere off the box leaves the conversation. On the box it reads on, so
# a man clicking his way through a long answer never loses it by accident.
func _check_clicking_off_the_box_ends_it(run: Node3D, tree: SceneTree) -> void:
	var dialogue: Control = run.get_node("%Dialogue")
	run._talk()
	await tree.process_frame
	_check(dialogue.visible, "the conversation would not open")
	var box: Control = dialogue.get_node("%Box")
	# On the box: still there.
	dialogue._input(_click_at(box.get_global_rect().get_center()))
	await tree.process_frame
	_check(dialogue.visible, "a click on the box itself closed the conversation")
	# Off it: gone.
	var above := Vector2(box.get_global_rect().get_center().x, box.get_global_rect().position.y - 40.0)
	dialogue._input(_click_at(above))
	await tree.process_frame
	_check(not dialogue.visible, "a click off the box did not end the conversation")

# Pulled back, a click leaves the museum up for a moment. The second click of a
# double-click has to have somewhere to land: dropped on the first click, the
# second one lands in the close view where the same pixel is a different piece of
# floor, and the man walks somewhere nobody pointed at.
func _check_the_museum_stays_up_a_moment(run: Node3D, tree: SceneTree) -> void:
	tree.paused = false
	run._camera.overview(run._museum_world_aabb())
	await tree.process_frame
	_check(run._camera.overviewing, "the camera would not pull back")
	run._unhandled_input(_click_at(Vector2(640, 360)))
	await tree.process_frame
	_check(run._camera.overviewing,
		"one click dropped the frame at once, leaving no room for a double-click")
	await tree.create_timer(float(run.OVERVIEW_LINGER) + 0.25).timeout
	_check(not run._camera.overviewing,
		"the frame never came back after the click")

func _click_at(where: Vector2) -> InputEventMouseButton:
	var press := InputEventMouseButton.new()
	press.button_index = MOUSE_BUTTON_LEFT
	press.pressed = true
	press.position = where
	press.global_position = where
	return press

# --- A different man each run -----------------------------------------------

# The guide is drawn from the run seed: body, complexion, head of hair. This
# reads the atlas he ends up wearing rather than the numbers he was given, which
# is the whole point of it: for a long time the numbers were varied and the man
# was not. His head was being repainted, correctly, and then a slice of it was
# re-UV'd onto a patch painted black, because the height test that was looking
# for his feet read `y` in a mesh space where `y` is not up.
#
# So: dress several guides, and compare the palettes they end up wearing. The
# palette rather than a named texel, because which texel is the hair is exactly
# the thing this is not allowed to assume. Eight men, at least four different
# palettes, and every one of them in the blue.
#
# The atlas read here is the one repaint() kept on the material, not one asked
# back from the texture: that is a round trip out to the rendering server, and it
# answers with whatever it had a moment ago — which is how a first version of this
# test failed, twice differently, on men who were in the render plainly in
# uniform.
func _check_he_is_a_different_man_each_run() -> void:
	var guides: Array[Guide] = []
	for seed_value in [1, 2, 3, 4, 5, 7, 11, 13]:
		GameState.rng.seed = seed_value
		var guide: Guide = (load("res://scenes/game/guide.tscn") as PackedScene).instantiate()
		add_child(guide)
		guide.wear_uniform()
		guides.append(guide)

	var palettes := {}
	var uniformed := 0
	for guide: Guide in guides:
		var atlas := _atlas_of(guide)
		if atlas == null:
			_fails.append("a guide came out with no atlas at all")
			continue
		var present := {}
		for y in atlas.get_height():
			for x in atlas.get_width():
				present[atlas.get_pixel(x, y).to_html(false)] = true
		if present.has(Guide.UNIFORM.to_html(false)):
			uniformed += 1

		var worn: Array = present.keys()
		worn.sort()
		palettes["|".join(worn)] = true
	for guide: Guide in guides:
		guide.queue_free()
	_check(uniformed == guides.size(), "the guide is not in uniform: %d of %d"
		% [uniformed, guides.size()])
	_check(palettes.size() >= 4, "the guide is always the same man: %d looks in %d runs"
		% [palettes.size(), guides.size()])

func _atlas_of(guide: Guide) -> Image:
	var model: Node3D = guide.get_node("%Visual").get_node_or_null("Model")
	if model == null:
		return null
	var mesh := Npc.find_mesh(model)
	if mesh == null or mesh.material_override == null:
		return null
	var material := mesh.material_override as StandardMaterial3D
	if material == null or not material.has_meta("atlas"):
		return null
	return material.get_meta("atlas") as Image

# The portrait in the conversation box is the guide, not a second man dressed off
# the same seed a moment later. It used to be the second man, and he came out
# ginger while the guide across the room was blond.
func _check_the_face_in_the_box_is_his(run: Node3D) -> void:
	var box: Node3D = run.get_node("%Dialogue")._portrait_man
	if box == null:
		_fails.append("the conversation box has nobody in it")
		return
	_check(box.model_path == run._guide.model_path,
		"the box shows a %s and the museum has a %s"
			% [box.model_path.get_file(), run._guide.model_path.get_file()])
	var his := Npc.find_mesh(run._guide.get_node("%Visual").get_node("Model"))
	var shown := Npc.find_mesh(box.get_node("%Visual").get_node("Model"))
	_check(his != null and shown != null and his.material_override == shown.material_override,
		"the face in the box is painted from a sheet of its own")
	var caps := 0
	for node in box.get_node("%Visual").find_children("*", "", true, false):
		if str(node.name).begins_with("Cap"):
			caps += 1
	_check(caps == 1, "the man in the box is wearing %d caps" % caps)

# --- Where the words come from ---------------------------------------------

# The art server owns the script now, and the copy in the build is what he says
# when the server has nothing. Headless has no network, so this test is the
# fallback: every line and every topic has to be there anyway. And the served
# shape — fixed lines under "lines" — has to flatten to the shape the panel
# reads, or a museum with a working server is a museum of silent men.
const SPOKEN_LINES := ["intro", "skip", "opening", "opening_again", "opening_called",
	"called_about", "hand_yes", "hand_no", "hand_first", "hand_hint", "hand_none",
	"exit_open", "exit_thanks", "concerned", "concerned_yes", "concerned_no",
	"yes", "no", "bye", "farewell"]

func _check_where_the_words_come_from() -> void:
	_check(not Convos.served("guide"), "headless reached a server it has no business reaching")
	var script_data := Convos.script_for("guide")
	_check(not script_data.is_empty(), "the guide has no script at all, served or shipped")
	for key: String in SPOKEN_LINES:
		_check(not str(script_data.get(key, "")).strip_edges().is_empty(),
			"the script has nothing under %s" % key)
	var topics: Array = script_data.get("topics", [])
	_check(topics.size() > 5, "the script offers %d questions" % topics.size())
	for topic: Variant in topics:
		var entry: Dictionary = topic
		_check(not str(entry.get("id", "")).is_empty(), "a topic with no id")
		_check(not str(entry.get("q", "")).is_empty(), "topic %s asks nothing" % entry.get("id", "?"))
		_check(not str(entry.get("a", "")).is_empty(), "topic %s answers nothing" % entry.get("id", "?"))
		if entry.has("a_if_zero"):
			_check(not str(entry.get("zero", "")).is_empty(),
				"topic %s has a nought answer and nothing to count" % entry.get("id", "?"))

	# The server's shape, flattened: lines at the top, topics beside them.
	var served := Convos._flatten({
		"lines": {"opening": "Bonjour.", "farewell": "Bonne visite."},
		"topics": [{"id": "what", "q": "Et alors ?", "a": "Voilà."}],
	})
	_check(str(served.get("opening", "")) == "Bonjour.",
		"a served line did not land where the panel looks for it")
	_check((served.get("topics", []) as Array).size() == 1, "the served topics were lost on the way in")
	_check(not served.has("lines"), "the served shape was carried through unflattened")

# --- The opening -----------------------------------------------------------

# The first gallery of a game opens on the guide telling the player what he is
# here for: nothing of his own to do until it is over, and one press — or the
# skip — to be through it.
func _check_the_museum_opens(run: Node3D, tree: SceneTree) -> void:
	var dialogue: Control = run.get_node("%Dialogue")
	_check(dialogue.visible, "the first gallery began without a word from anyone")
	_check(dialogue._speaking, "the opening is not a spoken line")
	_check(run._player.frozen, "the player can walk off during the opening")
	_check(dialogue.get_node("%SkipButton").visible, "there is no way past the opening")
	_check(_buttons(dialogue.get_node("%Choices")).is_empty(),
		"the opening asks the player something")
	var said: String = (dialogue.get_node("%Says") as Label).text
	_check(said.to_lower().contains("test"), "the opening does not say what he is here for: %s" % said)
	# A press, and the game is his.
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame
	_check(not dialogue.visible, "the opening would not close")
	_check(not run._player.frozen, "the player is still frozen once the opening is over")
	_check(not run._intro_pending, "the opening would play again")

# --- At the desk -----------------------------------------------------------

func _check_he_is_at_his_post(run: Node3D) -> void:
	var guide := _guide(run)
	if guide == null:
		_fails.append("there is no guide in the museum")
		return
	_check(guide.room_index == run.museum.start_index, "the guide is not in the entrance hall")
	_check(guide.at_desk, "the guide left his post before the player did")

	var hall: Room = run._rooms[run.museum.start_index]
	_check(hall.desk_side() in Room.FAR_SIDES,
		"the desk stands against a near wall, where the camera cannot see over it")
	_check(guide.global_position.distance_to(hall.desk_post()) < 0.1,
		"the guide is not stood behind his desk")
	# Behind the counter, not in front of it: the desk is between him and the room.
	var to_wall: Vector3 = Room.normal_of(hall.desk_side())
	_check(to_wall.dot(guide.global_position - hall.global_position) > 0.0,
		"the guide is stood on the visitors' side of his own desk")

	var cap := _named(guide, "Cap")
	_check(cap != null, "the guide is not wearing his cap")
	if cap != null:
		var lifted: float = (cap as Node3D).global_position.y - guide.global_position.y
		_check(lifted > 1.3 and lifted < 2.0,
			"the cap is at %.2f above his feet — that is not a head" % lifted)

func _check_the_furniture_is_in_the_way(run: Node3D) -> void:
	# Every gallery is furnished, and every visitor knows what is in his room.
	var furnished := 0
	for index: int in run._rooms:
		if not (run._rooms[index] as Room).obstacles.is_empty():
			furnished += 1
	_check(furnished > 0, "not one room reported anything to walk round")
	for npc: Npc in run._npcs:
		_check(not npc.obstacles.is_empty(),
			"a visitor was sent into a furnished room with nothing to avoid")
		break
	# The reach is a real one: a bench you start turning round when you are on top
	# of it is a bench you walk through.
	for index: int in run._rooms:
		for thing: Dictionary in (run._rooms[index] as Room).obstacles:
			_check(float(thing["radius"]) > 0.1,
				"an obstacle with no size at all: %s" % str(thing))

# The guide does his rounds by walking up to the pieces, so he is often standing
# at the one the player is standing at. There is one prompt and one key: the
# piece has them. Otherwise a man loitering at a canvas makes the canvas
# unusable, which is the opposite of a guide.
func _check_the_piece_outranks_the_guide(run: Node3D) -> void:
	var prompt: Label = run.get_node("%Hud").get_node("%Prompt")
	run._at_guide = true
	run._at_artwork = false
	run._refresh_prompt()
	_check(prompt.visible and prompt.text.to_lower().contains("guide"),
		"standing at the guide alone, the prompt says: %s" % prompt.text)
	# And now at a piece as well, with the guide still in reach. In a gallery: the
	# entrance hall has no piece, so there would be nothing to outrank him with.
	var was: int = run.current
	for index in run.museum.rooms.size():
		if not run.museum.rooms[index]["vestibule"] and not run.museum.rooms[index]["piece"].is_empty():
			run.current = index
			break
	run._at_artwork = true
	run._refresh_prompt()
	_check(prompt.visible and prompt.text.to_lower().contains("closer look"),
		"the guide took the piece's own prompt: %s" % prompt.text)
	run._at_artwork = false
	run._at_guide = false
	run._refresh_prompt()
	_check(not prompt.visible, "the prompt stayed up with nothing in front of him")
	run.current = was

# --- Talking ---------------------------------------------------------------

func _check_talking(run: Node3D, tree: SceneTree) -> void:
	var dialogue: Control = run.get_node("%Dialogue")
	run._talk()
	await tree.process_frame
	await tree.process_frame
	_check(dialogue.visible, "talking to the guide opened nothing")
	_check(run._player.frozen, "the player walks off in the middle of the conversation")

	# He speaks first, and the box says there is more to come. Only then the list.
	_check(dialogue._speaking, "the conversation opened on a menu rather than on a word")
	_check((dialogue.get_node("%Says") as Label).visible, "his line is not on the box")
	_check(dialogue.get_node("%More").visible, "nothing says that a press reads on")
	_check(_buttons(dialogue.get_node("%Choices")).is_empty(),
		"the questions are up while he is still talking")
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame
	_check(not dialogue.get_node("%Says").visible, "his line is still up over the questions")

	var choices: Array = _buttons(dialogue.get_node("%Choices"))
	_check(choices.size() > 2, "the guide offers %d things to ask" % choices.size())
	_check(not (dialogue.get_node("%Says") as Label).text.is_empty(), "the guide says nothing at all")
	var focused := get_viewport().gui_get_focus_owner()
	_check(focused != null and dialogue.is_ancestor_of(focused),
		"the conversation came up without putting the keyboard in it")

	# Every answer comes out with its numbers in it, and nothing on offer is a
	# question he cannot answer.
	for button: Button in choices:
		_check(not button.text.contains("{"), "an unfilled token on a button: %s" % button.text)
	var offered_first := choices.size()
	for i in choices.size() - 1:
		var asking: String = (choices[0] as Button).text
		(choices[0] as Button).pressed.emit()
		await tree.process_frame
		await tree.process_frame
		var said: String = (dialogue.get_node("%Says") as Label).text
		_check(dialogue._speaking, "an answer that was not spoken")
		_check(not said.is_empty(), "the guide had no answer to a question he offered")
		_check(not said.contains("{"), "an unfilled token in an answer: %s" % said)
		dialogue._advance()
		await tree.process_frame
		await tree.process_frame
		choices = _buttons(dialogue.get_node("%Choices"))
		# Asked and answered: it comes off the list for the rest of this
		# conversation.
		for button: Button in choices:
			_check(button.text != asking, "a question he has just answered is still on offer")
	_check(choices.size() < offered_first, "the list never got shorter")

	# A question about mistakes he has not made is not worth asking.
	_check(not _asks_about(choices, "wrong"),
		"the guide offers to talk about mistakes before any have been made")

	dialogue._walk_away()
	await tree.process_frame
	await tree.process_frame
	_check(not dialogue.visible, "the conversation would not close")
	_check(not run._player.frozen, "the player is still frozen after the conversation")

func _check_first_meeting(run: Node3D, tree: SceneTree) -> void:
	var dialogue: Control = run.get_node("%Dialogue")
	# A fresh conversation, nothing asked yet: only the general questions.
	dialogue._asked.clear()
	dialogue._everything_unlocked = false
	run._talk()
	await tree.process_frame
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame
	var offered := _buttons(dialogue.get_node("%Choices"))
	_check(not _asks_about(offered, "how many fakes"),
		"the first conversation opens with questions about this particular game")
	_check(not _asks_about(offered, "how am i doing"),
		"the first conversation opens with questions about this particular game")
	_check(_asks_about(offered, "supposed to do"),
		"the first conversation does not offer the one question a new player has")
	# One of them asked, and it is gone: not for this conversation, for the gallery.
	# Walk away, come back, and it is still gone. A question is worth asking once.
	var asked_one := ""
	for button: Button in offered:
		if button.text.to_lower().contains("supposed to do"):
			asked_one = button.text
			button.pressed.emit()
			break
	await tree.process_frame
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame
	offered = _buttons(dialogue.get_node("%Choices"))
	_check(not _asks_about(offered, "supposed to do"),
		"a question he has just answered is still on the list")
	dialogue._walk_away()
	await tree.process_frame
	run._talk()
	await tree.process_frame
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame
	offered = _buttons(dialogue.get_node("%Choices"))
	_check(not asked_one.is_empty(), "the one question a new player has was never on the list")
	_check(not _asks_about(offered, "supposed to do"),
		"a question answered in an earlier conversation came back in this one")

	# Go through the rest of the general ones; the questions about the game itself
	# follow, and they are what is left on the list.
	for topic: Variant in dialogue._script_data.get("topics", []):
		if str((topic as Dictionary).get("scope", "")) != "run":
			dialogue._asked[str((topic as Dictionary).get("id", ""))] = true
	dialogue._offer_topics()
	await tree.process_frame
	offered = _buttons(dialogue.get_node("%Choices"))
	_check(_asks_about(offered, "how many fakes"),
		"the questions about this game never open up")
	_check(not _asks_about(offered, "how do i judge"),
		"a general question that was asked is still being offered")

	dialogue._walk_away()
	await tree.process_frame

# Sent for, he opens on the piece hanging in the room the player is standing in,
# by name, and offers two things to say back. A hand, and he says where to look on
# any piece; a hand a second time, and he gives up the museum's note on this one,
# which is the only way to that note now the phone has stopped handing it out.
func _check_he_is_asked_about_the_piece(run: Node3D, tree: SceneTree) -> void:
	var dialogue: Control = run.get_node("%Dialogue")
	var here: Dictionary = run.museum.rooms[run.current].get("piece", {})
	var title := str(here.get("title_native", ""))
	if title.is_empty():
		_fails.append("the room the test is standing in has nothing hanging in it")
		return
	run._talk(false, true)
	await tree.process_frame
	_check(dialogue.get_node("%Says").text.contains(title),
		"called over, he does not say which piece he is being asked about: %s"
			% dialogue.get_node("%Says").text)
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame

	var offered := _buttons(dialogue.get_node("%Choices"))
	_check(offered.size() == 2, "the piece he was called about offers %d ways to answer"
		% offered.size())
	_check(_asks_about(offered, "hand"), "there is no way to ask him for a hand")

	# Asked once: the advice he gives about anything. The note stays his.
	var note := str(here.get("hint", ""))
	_hand(offered)
	await tree.process_frame
	_check(not dialogue.get_node("%Says").text.contains(note) or note.is_empty(),
		"he gave up the note the first time he was asked")
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame

	# Asked twice: the note.
	offered = _buttons(dialogue.get_node("%Choices"))
	_check(_asks_about(offered, "hand"), "he cannot be asked a second time")
	_hand(offered)
	await tree.process_frame
	if not note.is_empty():
		_check(dialogue.get_node("%Says").text.contains(note),
			"asked twice, he still did not give the note: %s"
				% dialogue.get_node("%Says").text)
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame

	# And nothing to do but thank him. The questions he is otherwise asked have no
	# business here: he was sent for about one piece.
	offered = _buttons(dialogue.get_node("%Choices"))
	_check(offered.size() == 1, "the piece he was called about ends in %d choices"
		% offered.size())
	_check(not _asks_about(offered, "supposed to do") and not _asks_about(offered, "how am i doing"),
		"being called over opens the whole list of questions")
	if offered.size() == 1:
		(offered[0] as Button).pressed.emit()
		await tree.process_frame
		_check(not dialogue.visible, "thanking him left the conversation open")
	dialogue._walk_away()
	await tree.process_frame

func _hand(offered: Array) -> void:
	for button: Button in offered:
		if button.text.to_lower().contains("hand"):
			button.pressed.emit()
			return
	_fails.append("nothing on offer asked him for a hand")

# The thing that pushing could never do: a bench in the way is walked round.
func _check_he_walks_round_things(run: Node3D) -> void:
	var walker: Npc = run._npcs[0] if not run._npcs.is_empty() else _guide(run)
	if walker == null:
		return
	var from: Vector3 = walker.global_position
	var to := from + Vector3(6.0, 0.0, 0.0)
	var bench := {"at": from + Vector3(3.0, 0.0, 0.0), "radius": 0.8}
	var was: Array = walker.obstacles.duplicate()
	walker.obstacles = [bench] as Array[Dictionary]
	walker.go([to] as Array[Vector3], walker.room_index)
	var route: Array = walker._path
	_check(route.size() > 1, "a bench dead ahead and he walks straight at it")
	# No leg of the route passes through the thing.
	var at := from
	var clear: float = float(bench["radius"]) + Npc.BODY_RADIUS
	for point: Vector3 in route:
		_check(_clearance(at, point, bench["at"]) >= clear - 0.01,
			"a leg of the route passes through the bench (%.2f of %.2f)"
				% [_clearance(at, point, bench["at"]), clear])
		at = point
	_check(route[route.size() - 1].distance_to(to) < 0.01,
		"the detour lost the place he was going")
	walker.obstacles.assign(was)

# Sent to a place he cannot stand in, which is what the middle of a room is when
# there is a bench across it. He should go as near it as the floor allows, on the
# side he is coming from, and stop there. He used to be sent into the bench, walk
# round the side of it, set off into it again, and lean on it until the stuck
# timer moved him on: a man arguing with the furniture.
func _check_he_stops_short_of_a_bench(run: Node3D) -> void:
	var walker: Npc = run._npcs[0] if not run._npcs.is_empty() else _guide(run)
	if walker == null:
		return
	var from: Vector3 = walker.global_position
	var middle := from + Vector3(4.0, 0.0, 0.0)
	var bench := {"at": middle, "radius": 0.9}
	var was: Array = walker.obstacles.duplicate()
	var was_floor: Array = walker.floors.duplicate()
	walker.obstacles = [bench] as Array[Dictionary]
	# No opinion about the floor, so the only thing deciding is the bench.
	walker.floors = [] as Array[Dictionary]
	walker.go([middle] as Array[Vector3], walker.room_index)

	var route: Array = walker._path
	_check(not route.is_empty(), "he was given nowhere to go")
	if not route.is_empty():
		var last: Vector3 = route[route.size() - 1]
		var clear: float = float(bench["radius"]) + Npc.BODY_RADIUS
		_check(last.distance_to(middle) >= clear - 0.01,
			"he is still being sent into the bench (%.2f of %.2f)"
				% [last.distance_to(middle), clear])
		_check(last.distance_to(from) < middle.distance_to(from) + 0.01,
			"he walks round to the far side of the bench for no reason")
	walker.obstacles.assign(was)
	walker.floors.assign(was_floor)

# How near a segment comes to a point, on the floor plane.
func _clearance(from: Vector3, to: Vector3, point: Vector3) -> float:
	var a := from * Vector3(1, 0, 1)
	var b := to * Vector3(1, 0, 1)
	var p := point * Vector3(1, 0, 1)
	var along := b - a
	var length := along.length()
	if length < 0.001:
		return a.distance_to(p)
	var t := clampf((p - a).dot(along / length), 0.0, length)
	return (a + along / length * t).distance_to(p)

# --- Coming over ------------------------------------------------------------

func _check_he_comes_over(run: Node3D, tree: SceneTree) -> void:
	var guide := _guide(run)
	if guide == null:
		return
	# Two wrong calls: enough for him to come and offer a hand.
	var wrongly := 0
	for index in run.museum.rooms.size():
		var room: Dictionary = run.museum.rooms[index]
		if room["vestibule"] or room["piece"].is_empty():
			continue
		room["mark"] = "fake" if room["authentic"] else "original"
		wrongly += 1
		if wrongly == 2:
			break
	_check(run.museum.calls_wrong() == 2, "the museum counts %d wrong calls, not 2" % run.museum.calls_wrong())
	_check(run._guide_should_offer_help(), "two calls wrong and the guide sees no reason to help")
	run._send_guide_to_player()
	await tree.process_frame
	_check(guide.seeking, "the guide was sent for the player and did not set off")
	_check(not guide.at_desk, "the guide is looking for the player from behind his desk")
	_check(run._guide_has_offered, "the guide would offer again on the next wrong call")
	# And once is once.
	_check(not run._guide_should_offer_help(), "the guide would come over a second time")

	# The answers now know about the mistakes, and the question about them is on
	# offer at last.
	var facts: Dictionary = run._guide_facts()
	_check(int(facts["wrong"]) == 2, "the guide would say %s calls are wrong" % str(facts["wrong"]))
	_check(int(facts["judged"]) == 2, "the guide has lost count of what has been called")
	_check(int(facts["total"]) == run.museum.pieces_total(), "the guide miscounts the exhibition")

# Ask him twice which one you got wrong and he gives in: he says so, the
# conversation ends, and he sets off for the nearest piece you called wrong to
# stand at it. (_check_he_comes_over has already left two calls wrong.)
func _check_he_gives_in_and_shows_you(run: Node3D, tree: SceneTree) -> void:
	var dialogue: Control = run.get_node("%Dialogue")
	var guide := _guide(run)
	if guide == null:
		return
	guide.stop_seeking()
	run._talk()
	await tree.process_frame
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame

	var asked_once := false
	for round_number in 2:
		var which: Button = null
		for button: Button in _buttons(dialogue.get_node("%Choices")):
			if button.text.to_lower().contains("which one i got wrong"):
				which = button
		if which == null:
			_check(asked_once, "he never offers to say which piece was called wrong")
			# The question is repeatable, so it has to still be there the second time.
			_check(false, "the question about which piece went missing after one ask")
			return
		which.pressed.emit()
		await tree.process_frame
		await tree.process_frame
		asked_once = true
		if round_number == 0:
			dialogue._advance()
			await tree.process_frame
			await tree.process_frame

	var said: String = (dialogue.get_node("%Says") as Label).text
	_check(said.to_lower().contains("follow me"), "asked twice, he still will not show: %s" % said)
	dialogue._advance()
	await tree.process_frame
	await tree.process_frame
	_check(not dialogue.visible, "he agreed to show the way and stayed talking")
	_check(run._guide_leading_to != -1, "he agreed to show the way and went nowhere")
	var wrong_room: int = run._guide_leading_to
	var room: Dictionary = run.museum.rooms[wrong_room]
	_check((room["mark"] == "fake") != (not room["authentic"]),
		"he is walking to a piece that was called right")

	# And he gets there.
	# Across the museum at walking pace, with a look round the furniture on the way.
	for i in 2400:
		await tree.process_frame
		if run._guide_leading_to == -1:
			break
	_check(run._guide_leading_to == -1, "he never arrived at the piece he promised to show")
	_check(guide.room_index == wrong_room, "he arrived somewhere else entirely")

# Sent for from the phone: the look ends, the phone goes down with it, and he
# comes. Wherever he was, he turns out to have been just outside the door, so
# this is a walk of seconds and not of galleries.
func _check_he_answers_the_phone(run: Node3D, tree: SceneTree) -> void:
	var dialogue: Control = run.get_node("%Dialogue")
	var guide := _guide(run)
	if guide == null:
		return
	# Standing at a piece, looking at it, with the phone up.
	var gallery := -1
	for index in run.museum.rooms.size():
		if not run.museum.rooms[index]["vestibule"] and not run.museum.rooms[index]["piece"].is_empty():
			gallery = index
			break
	run.current = gallery
	run._room = run._rooms[gallery]
	run._player.global_position = (run._rooms[gallery] as Node3D).global_position + Vector3(2, 0, 1)
	await tree.process_frame
	run._at_artwork = true
	run._start_observing()
	await tree.process_frame
	run._observation.show_phone()
	await tree.process_frame
	_check(run.observing and run._observation._phone.raised, "the phone would not come up")

	run._observation._phone._call_the_guide()
	await tree.process_frame
	await tree.process_frame
	_check(not run.observing, "calling the guide left the player at the canvas")
	_check(not run._observation._phone.raised, "calling the guide left the phone up")
	_check(run._guide_summoned, "the call did not reach the guide")

	var arrived := false
	for i in 900:
		await tree.process_frame
		if dialogue.visible:
			arrived = true
			break
	_check(arrived, "he was called and never came")
	if arrived:
		_check((dialogue.get_node("%Says") as Label).text.to_lower().contains("question"),
			"he arrived and opened with: %s" % (dialogue.get_node("%Says") as Label).text)
		dialogue._advance()
		await tree.process_frame
		dialogue._walk_away()
		await tree.process_frame

# --- Inside the building ---------------------------------------------------

# He walks the museum on world points rather than on a navigation mesh, so
# nothing but the routing keeps him out of the walls. Sent right across the
# building, he should never once be outside a room: a straight line from here to
# a room two doors away goes through everything in between, which is what he was
# doing when he was told to walk at the player from wherever he stood.
func _check_he_keeps_to_the_rooms(run: Node3D, tree: SceneTree) -> void:
	var guide := _guide(run)
	if guide == null:
		return
	guide.stop_seeking()
	guide.at_desk = false
	# The far end of the museum, so he has to cross it.
	var distance: Dictionary = run.museum.distances_from(run.museum.start_index)
	var far: int = run.museum.start_index
	for index: int in distance:
		if int(distance[index]) > int(distance.get(far, 0)):
			far = index
	run.current = far

	var worst := 0.0
	var strayed := 0
	var in_the_furniture := 0
	var walkers: Array[Node3D] = [guide]
	for npc: Npc in run._npcs:
		walkers.append(npc)
	for i in 1500:
		await tree.process_frame
		for walker: Node3D in walkers:
			var out := _outside_by(run, walker.global_position)
			if out > 0.0:
				strayed += 1
				worst = maxf(worst, out)
			if _standing_in_something(run, walker):
				in_the_furniture += 1
	_check(worst < 0.75, "someone walked %.2f outside the rooms, on %d frames" % [worst, strayed])
	_check(in_the_furniture == 0, "someone stood inside a bench on %d frames" % in_the_furniture)

# Is he inside a piece of furniture — not merely near it, but through its skin?
func _standing_in_something(run: Node3D, walker: Node3D) -> bool:
	for index: int in run._rooms:
		for thing: Dictionary in (run._rooms[index] as Room).obstacles:
			var flat: Vector3 = (walker.global_position - (thing["at"] as Vector3)) * Vector3(1, 0, 1)
			if flat.length() < float(thing["radius"]) * 0.8:
				return true
	return false

# How far outside the nearest room a point is, on the floor plane: nought if it
# is inside one. A doorway is inside both rooms it joins, so crossing one counts
# as being in the building.
func _outside_by(run: Node3D, point: Vector3) -> float:
	var best := INF
	for room: Dictionary in run.museum.rooms:
		var centre: Vector3 = Vector3(room["grid"].x, 0, room["grid"].y) * Room.span()
		var dx: float = absf(point.x - centre.x) - Room.HALF
		var dz: float = absf(point.z - centre.z) - Room.HALF
		best = minf(best, maxf(0.0, maxf(dx, dz)))
	return best

# --- Plumbing --------------------------------------------------------------

func _guide(run: Node3D) -> Guide:
	for child in run.get_children():
		if child is Guide:
			return child
	return null

func _named(node: Node, name: String) -> Node:
	if node.name == name:
		return node
	for child in node.get_children():
		var found := _named(child, name)
		if found != null:
			return found
	return null

func _buttons(box: Node) -> Array:
	var out: Array = []
	for child in box.get_children():
		if child is Button:
			out.append(child)
	return out

func _asks_about(choices: Array, word: String) -> bool:
	for button: Button in choices:
		if button.text.to_lower().contains(word):
			return true
	return false

func _check(ok: bool, message: String) -> void:
	if not ok:
		_fails.append(message)

func _report() -> void:
	if _fails.is_empty():
		print("GUIDE OK")
	else:
		for f in _fails:
			print("GUIDE FAIL: ", f)
	get_tree().quit(0 if _fails.is_empty() else 1)
