diff --git a/godot-jev/README.md b/godot-jev/README.md index 171ebe4..f5ca733 100644 --- a/godot-jev/README.md +++ b/godot-jev/README.md @@ -51,6 +51,13 @@ in that room at that moment. ## The screen +The game opens on a start menu over the crypt stairs: **Enter the crypt**, **Help** and **Quit**, +with a line saying whether Jev is listening (and which model) or offline for want of +`TYPESAFE_API_KEY`. Help is a scrolling page on what the experiment is about: the problem, how the +Choice over possible moves works, the act and clarify thresholds (read from the parser, not +written into the text), the hypothesis, and how to read the screen below. Back or Esc returns to +the menu. + | Area | What it shows | | --- | --- | | Room art (top left) | A 160×90 pixel scene for the current room, scaled 4×. It fades between rooms, drops to near-black in a dark room without light, and switches to a room's variant art once the variant's flag is set (the hall after the skeleton falls). The room's name sits on a plaque in the corner. | @@ -118,11 +125,14 @@ don't need separate art; the scene dims the room's image at runtime. | `demo/world.json` | The authored dungeon: 6 rooms, 5 items, 3 interactions | | `demo/world.gd` | Rules: possible actions, room variants, and authored responses only | | `demo/parser.gd` | Player text to one possible action, gated by confidence | +| `demo/start_menu.gd` | The start menu (the main scene) and its help page | +| `demo/help.gd` | BBCode for the help page, and the menu's Jev status line | | `demo/adventure.gd` | The playable scene: art, status panel, transcript and input | +| `demo/style.gd` | Palette, fonts, framed panels and theme shared by the menu and the game | | `demo/transcript.gd` | BBCode for the transcript; escapes all authored and typed text | | `demo/art/`, `demo/fonts/` | Room images and the two pixel fonts | | `tools/paint_rooms.gd` | Regenerates the placeholder room art | -| `tests/` | A headless runner and 29 tests; a script error inside a test counts as a failure | +| `tests/` | A headless runner and 34 tests; a script error inside a test counts as a failure | Using the add-on in any game: @@ -157,7 +167,7 @@ the game stays fully authored, and it's fast enough to feel like a parser, not a ```bash export TYPESAFE_API_KEY=sk-... # read by the Jev autoload; local prototyping only -godot --path . # play +godot --path . # play (opens the start menu) godot --headless --path . --import godot --headless --path . --script res://tests/run_tests.gd ``` diff --git a/godot-jev/demo/adventure.gd b/godot-jev/demo/adventure.gd index fd1e4c4..5201cb3 100644 --- a/godot-jev/demo/adventure.gd +++ b/godot-jev/demo/adventure.gd @@ -6,8 +6,8 @@ extends Control const World := preload("res://demo/world.gd") const Parser := preload("res://demo/parser.gd") const Transcript := preload("res://demo/transcript.gd") -const BODY_FONT := preload("res://demo/fonts/VT323-Regular.ttf") -const TITLE_FONT := preload("res://demo/fonts/PixelifySans.ttf") +const Style := preload("res://demo/style.gd") +const TITLE_FONT := Style.TITLE_FONT const NOT_UNDERSTOOD := [ "You're not sure how to do that here.", @@ -15,13 +15,11 @@ const NOT_UNDERSTOOD := [ "You hesitate. That doesn't seem possible right now.", ] const REVEAL_CHARS_PER_S := 160.0 -const BG := Color("0b0910") -const PANEL := Color("16121c") -const EDGE := Color("7a6038") -const EDGE_DARK := Color("2a2130") -const INK := Color("e8dcc0") -const DIM := Color("8d82a3") -const GOLD := Color("f0b85a") +const PANEL := Style.PANEL +const EDGE := Style.EDGE +const EDGE_DARK := Style.EDGE_DARK +const DIM := Style.DIM +const GOLD := Style.GOLD const DARKNESS := Color(0.05, 0.045, 0.07) var world: Object @@ -50,10 +48,7 @@ var details_text: RichTextLabel func _ready() -> void: - for font: FontFile in [BODY_FONT, TITLE_FONT]: - font.antialiasing = TextServer.FONT_ANTIALIASING_NONE - font.hinting = TextServer.HINTING_NONE - font.subpixel_positioning = TextServer.SUBPIXEL_POSITIONING_DISABLED + Style.crisp_fonts() world = World.from_file("res://demo/world.json") parser = Parser.new(Jev) _build_ui() @@ -248,9 +243,9 @@ func _finish_reveal() -> void: # --- layout ------------------------------------------------------------------------- func _build_ui() -> void: - theme = _theme() + theme = Style.theme() var bg := ColorRect.new() - bg.color = BG + bg.color = Style.BG bg.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(bg) @@ -265,11 +260,11 @@ func _build_ui() -> void: var left := VBoxContainer.new() left.add_theme_constant_override("separation", 14) row.add_child(left) - left.add_child(_framed(_art_view())) - var hud := _framed(_hud()) + left.add_child(Style.framed(_art_view())) + var hud := Style.framed(_hud()) hud.size_flags_vertical = Control.SIZE_EXPAND_FILL left.add_child(hud) - var text_box := _framed(_text_panel()) + var text_box := Style.framed(_text_panel()) text_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(text_box) add_child(_details_popup()) @@ -278,7 +273,7 @@ func _build_ui() -> void: func _details_popup() -> PopupPanel: details = PopupPanel.new() details.theme = theme - details.add_theme_stylebox_override("panel", _box(PANEL, GOLD, 2, Vector4.ONE * 14)) + details.add_theme_stylebox_override("panel", Style.box(PANEL, GOLD, 2, Vector4.ONE * 14)) details.popup_hide.connect(func(): input.grab_focus()) details_text = RichTextLabel.new() details_text.bbcode_enabled = true @@ -298,7 +293,7 @@ func _art_view() -> Control: art.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST holder.add_child(art) var tag := PanelContainer.new() - tag.add_theme_stylebox_override("panel", _box(Color(PANEL, 0.85), EDGE, 2, Vector4(10, 2, 10, 4))) + tag.add_theme_stylebox_override("panel", Style.box(Color(PANEL, 0.85), EDGE, 2, Vector4(10, 2, 10, 4))) tag.position = Vector2(12, 12) plaque = Label.new() plaque.add_theme_font_override("font", TITLE_FONT) @@ -405,54 +400,6 @@ func _caption(text: String) -> Label: return label -## A double border: a dark outer line around a brass inner line. -func _framed(content: Control) -> PanelContainer: - var outer := PanelContainer.new() - outer.add_theme_stylebox_override("panel", _box(EDGE_DARK, BG, 2, Vector4.ONE * 2)) - var inner := PanelContainer.new() - inner.add_theme_stylebox_override("panel", _box(PANEL, EDGE, 2, Vector4.ONE * 12)) - inner.size_flags_vertical = Control.SIZE_EXPAND_FILL - outer.add_child(inner) - inner.add_child(content) - return outer - - -func _box(fill: Color, border: Color, width: int, padding: Vector4) -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = fill - style.border_color = border - style.set_border_width_all(width) - style.content_margin_left = padding.x - style.content_margin_top = padding.y - style.content_margin_right = padding.z - style.content_margin_bottom = padding.w - return style - - -func _theme() -> Theme: - var t := Theme.new() - t.default_font = BODY_FONT - t.default_font_size = 22 - t.set_color("font_color", "Label", INK) - t.set_color("default_color", "RichTextLabel", INK) - t.set_color("selection_color", "RichTextLabel", Color(EDGE, 0.5)) - t.set_color("font_color", "LineEdit", INK) - t.set_color("font_placeholder_color", "LineEdit", Color(DIM, 0.7)) - t.set_color("caret_color", "LineEdit", GOLD) - t.set_constant("caret_width", "LineEdit", 2) - t.set_font_size("normal_font_size", "RichTextLabel", 22) - t.set_font("normal_font", "RichTextLabel", BODY_FONT) - t.set_constant("line_separation", "RichTextLabel", 2) - for state in ["normal", "focus", "read_only"]: - t.set_stylebox(state, "LineEdit", StyleBoxEmpty.new()) - var scroll := _box(EDGE_DARK, EDGE_DARK, 0, Vector4.ZERO) - t.set_stylebox("scroll", "VScrollBar", _box(Color(0, 0, 0, 0), EDGE_DARK, 0, Vector4.ONE * 2)) - t.set_stylebox("grabber", "VScrollBar", scroll) - t.set_stylebox("grabber_highlight", "VScrollBar", _box(EDGE, EDGE, 0, Vector4.ZERO)) - t.set_stylebox("grabber_pressed", "VScrollBar", _box(EDGE, EDGE, 0, Vector4.ZERO)) - return t - - ## The last answer's confidence, with ticks at the clarify and act thresholds. class ConfidenceMeter: extends Control diff --git a/godot-jev/demo/help.gd b/godot-jev/demo/help.gd new file mode 100644 index 0000000..2ccb07b --- /dev/null +++ b/godot-jev/demo/help.gd @@ -0,0 +1,53 @@ +extends RefCounted +## BBCode for the start menu's help page: what this experiment is trying to +## show and how to read the screen while playing. The thresholds are passed +## in from the parser, so the page can't drift from what the game does. + +const Transcript := preload("res://demo/transcript.gd") +const HttpDecider := preload("res://addons/jev/http_decider.gd") + + +static func page(clarify_at: float, act_at: float) -> String: + var k: Dictionary = Transcript.KIND + return "\n".join([ + Transcript.heading("What is this?"), + "The Sunken Crypt is an experiment: can a game let you type anything, in your own words, while every room, item and line is still written by its author?", + "", + "Classic parser games failed on vocabulary: [i]\"I don't know the word 'grab'.\"[/i] Games run by a language model fail the other way, inventing doors and treasure nobody wrote.", + "", + Transcript.heading("How it works"), + "Each turn, the game lists the moves possible right now (take the torch, go north, attack the skeleton...) plus [i]none of these[/i]. Jev, a small decision model, picks the one that matches what you typed. It can't choose a door that isn't there, because that door is never one of the options.", + "", + "How sure Jev is decides what happens:", + " [color=%s]act[/color] confidence ≥ %.2f: the move happens" % [k.act, act_at], + " [color=%s]clarify[/color] confidence ≥ %.2f: \"Did you mean...\" with two choices" % [k.clarify, clarify_at], + " [color=%s]unknown[/color] anything lower, or none of these: an authored \"that doesn't seem possible\"" % k.unknown, + "", + Transcript.heading("Hypothesis"), + "Choosing among only the moves that are possible lets players type naturally with few dead ends, keeps the game fully authored, and is fast enough to feel like a parser rather than a chat. The experiment checks that:", + " · paraphrases, typos and indirect requests land on the move you meant", + " · impossible or off-topic requests come back as unknown", + " · a turn answers in well under a second", + " · the options Jev sees are exactly the possible moves, never more", + "", + Transcript.heading("Reading the screen"), + "[color=%s]Jev heard[/color] (bottom left) shows the last outcome, the move it chose, a confidence meter with ticks at %.2f and %.2f, and how long the turn took." % [Transcript.TITLE, clarify_at, act_at], + "Every command you type ends with a score such as [color=%s]act 0.94[/color]. Click it to see what Jev weighed: the top candidates, the options offered, and the latency." % k.act, + "", + Transcript.heading("Playing"), + "Find your way from the collapsed stair to the vault. Say it however you like: \"set the rag on fire\", \"stab the bones\", \"use the little key on the door\".", + "Enter submits, ↑ and ↓ recall earlier commands. When Jev asks which move you meant, type 1 or 2, or click one.", + "", + "[color=%s]Jev needs TYPESAFE_API_KEY in the environment. Without it the game still runs, but every turn reports that the parser is offline.[/color]" % Transcript.QUIET, + ]) + + +## One line for the menu: can Jev answer, and with which model? +static func status_line(decider: Object) -> String: + if decider == null: + return "Jev is offline: no decider configured" + if decider.get_script() == HttpDecider: + if decider.api_key.is_empty(): + return "Jev is offline: set TYPESAFE_API_KEY to play" + return "Jev is listening · model %s" % decider.model + return "Jev is scripted: answers are canned, not live" diff --git a/godot-jev/demo/help.gd.uid b/godot-jev/demo/help.gd.uid new file mode 100644 index 0000000..07dd1b4 --- /dev/null +++ b/godot-jev/demo/help.gd.uid @@ -0,0 +1 @@ +uid://c8aalc6t3lkaq diff --git a/godot-jev/demo/start_menu.gd b/godot-jev/demo/start_menu.gd new file mode 100644 index 0000000..2195e8d --- /dev/null +++ b/godot-jev/demo/start_menu.gd @@ -0,0 +1,147 @@ +extends Control +## The start menu: enter the crypt, read what the experiment is about, or quit. +## Help swaps the title for a scrolling page; Back or Esc swaps it back. + +const Style := preload("res://demo/style.gd") +const Help := preload("res://demo/help.gd") +const Parser := preload("res://demo/parser.gd") +const World := preload("res://demo/world.gd") +const GAME := "res://demo/adventure.tscn" +const BACKDROP := "stairs" +const TAGLINE := "Type anything. Only what the author wrote can happen." + +var title_view: Control +var help_view: Control +var help_text: RichTextLabel +var play_button: Button +var help_button: Button +var quit_button: Button +var back_button: Button + + +func _ready() -> void: + Style.crisp_fonts() + theme = Style.theme() + var bg := ColorRect.new() + bg.color = Style.BG + bg.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(bg) + var art := TextureRect.new() + art.set_anchors_preset(Control.PRESET_FULL_RECT) + art.texture = load(World.art_path(BACKDROP)) + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + art.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + art.modulate = Color(0.35, 0.32, 0.4) + add_child(art) + title_view = _title_view() + add_child(title_view) + help_view = _help_view() + add_child(help_view) + _show_help(false) + play_button.grab_focus() + + +func _unhandled_input(event: InputEvent) -> void: + if help_view.visible and event.is_action_pressed("ui_cancel"): + _show_help(false) + accept_event() + + +func _show_help(on: bool) -> void: + title_view.visible = not on + help_view.visible = on + if on: + help_text.scroll_to_line(0) + back_button.grab_focus() + elif is_inside_tree(): + help_button.grab_focus() + + +func _play() -> void: + get_tree().change_scene_to_file(GAME) + + +func _title_view() -> Control: + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 12) + center.add_child(column) + var title := Label.new() + title.text = ProjectSettings.get_setting("application/config/name") + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_override("font", Style.TITLE_FONT) + title.add_theme_font_size_override("font_size", 72) + title.add_theme_color_override("font_color", Style.GOLD) + title.add_theme_color_override("font_shadow_color", Style.BG) + title.add_theme_constant_override("shadow_offset_x", 4) + title.add_theme_constant_override("shadow_offset_y", 4) + column.add_child(title) + var tagline := Label.new() + tagline.text = TAGLINE + tagline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + tagline.add_theme_font_size_override("font_size", 26) + column.add_child(tagline) + var gap := Control.new() + gap.custom_minimum_size.y = 28 + column.add_child(gap) + play_button = _button(column, "Enter the crypt", _play) + help_button = _button(column, "Help", _show_help.bind(true)) + quit_button = _button(column, "Quit", func(): get_tree().quit()) + quit_button.visible = OS.get_name() != "Web" + var status := Label.new() + var jev := get_node_or_null("/root/Jev") + status.text = Help.status_line(jev.decider if jev else null) + status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + status.add_theme_color_override("font_color", Style.DIM) + var status_gap := Control.new() + status_gap.custom_minimum_size.y = 20 + column.add_child(status_gap) + column.add_child(status) + return center + + +func _button(parent: Control, label: String, action: Callable) -> Button: + var holder := CenterContainer.new() + parent.add_child(holder) + var button := Button.new() + button.text = label + button.custom_minimum_size.x = 300 + button.pressed.connect(action) + holder.add_child(button) + return button + + +func _help_view() -> Control: + var margin := MarginContainer.new() + margin.set_anchors_preset(Control.PRESET_FULL_RECT) + for side in ["left", "right"]: + margin.add_theme_constant_override("margin_" + side, 160) + for side in ["top", "bottom"]: + margin.add_theme_constant_override("margin_" + side, 40) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 12) + help_text = RichTextLabel.new() + help_text.bbcode_enabled = true + help_text.selection_enabled = true + help_text.size_flags_vertical = Control.SIZE_EXPAND_FILL + var thresholds := Parser.new(null) + help_text.text = Help.page(thresholds.clarify_confidence, thresholds.act_confidence) + column.add_child(help_text) + var row := HBoxContainer.new() + row.alignment = BoxContainer.ALIGNMENT_END + column.add_child(row) + var hint := Label.new() + hint.text = "Esc to go back" + hint.add_theme_color_override("font_color", Style.DIM) + hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(hint) + back_button = Button.new() + back_button.text = "Back" + back_button.custom_minimum_size.x = 160 + back_button.pressed.connect(_show_help.bind(false)) + row.add_child(back_button) + var frame := Style.framed(column) + margin.add_child(frame) + return margin diff --git a/godot-jev/demo/start_menu.gd.uid b/godot-jev/demo/start_menu.gd.uid new file mode 100644 index 0000000..ed58619 --- /dev/null +++ b/godot-jev/demo/start_menu.gd.uid @@ -0,0 +1 @@ +uid://4id3d27qcswh diff --git a/godot-jev/demo/start_menu.tscn b/godot-jev/demo/start_menu.tscn new file mode 100644 index 0000000..e486c8f --- /dev/null +++ b/godot-jev/demo/start_menu.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://demo/start_menu.gd" id="1_menu"] + +[node name="StartMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_menu") diff --git a/godot-jev/demo/style.gd b/godot-jev/demo/style.gd new file mode 100644 index 0000000..2ec61b0 --- /dev/null +++ b/godot-jev/demo/style.gd @@ -0,0 +1,81 @@ +extends RefCounted +## The look shared by the start menu and the game: palette, pixel fonts, +## brass-framed panels and the theme. + +const BODY_FONT := preload("res://demo/fonts/VT323-Regular.ttf") +const TITLE_FONT := preload("res://demo/fonts/PixelifySans.ttf") + +const BG := Color("0b0910") +const PANEL := Color("16121c") +const EDGE := Color("7a6038") +const EDGE_DARK := Color("2a2130") +const INK := Color("e8dcc0") +const DIM := Color("8d82a3") +const GOLD := Color("f0b85a") + + +## Pixel fonts stay sharp only without antialiasing, hinting or subpixel offsets. +static func crisp_fonts() -> void: + for font: FontFile in [BODY_FONT, TITLE_FONT]: + font.antialiasing = TextServer.FONT_ANTIALIASING_NONE + font.hinting = TextServer.HINTING_NONE + font.subpixel_positioning = TextServer.SUBPIXEL_POSITIONING_DISABLED + + +static func box(fill: Color, border: Color, width: int, padding: Vector4) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = fill + style.border_color = border + style.set_border_width_all(width) + style.content_margin_left = padding.x + style.content_margin_top = padding.y + style.content_margin_right = padding.z + style.content_margin_bottom = padding.w + return style + + +## A double border: a dark outer line around a brass inner line. +static func framed(content: Control) -> PanelContainer: + var outer := PanelContainer.new() + outer.add_theme_stylebox_override("panel", box(EDGE_DARK, BG, 2, Vector4.ONE * 2)) + var inner := PanelContainer.new() + inner.add_theme_stylebox_override("panel", box(PANEL, EDGE, 2, Vector4.ONE * 12)) + inner.size_flags_vertical = Control.SIZE_EXPAND_FILL + outer.add_child(inner) + inner.add_child(content) + return outer + + +static func theme() -> Theme: + var t := Theme.new() + t.default_font = BODY_FONT + t.default_font_size = 22 + t.set_color("font_color", "Label", INK) + t.set_color("default_color", "RichTextLabel", INK) + t.set_color("selection_color", "RichTextLabel", Color(EDGE, 0.5)) + t.set_color("font_color", "LineEdit", INK) + t.set_color("font_placeholder_color", "LineEdit", Color(DIM, 0.7)) + t.set_color("caret_color", "LineEdit", GOLD) + t.set_constant("caret_width", "LineEdit", 2) + t.set_font_size("normal_font_size", "RichTextLabel", 22) + t.set_font("normal_font", "RichTextLabel", BODY_FONT) + t.set_constant("line_separation", "RichTextLabel", 2) + for state in ["normal", "focus", "read_only"]: + t.set_stylebox(state, "LineEdit", StyleBoxEmpty.new()) + var scroll := box(EDGE_DARK, EDGE_DARK, 0, Vector4.ZERO) + t.set_stylebox("scroll", "VScrollBar", box(Color(0, 0, 0, 0), EDGE_DARK, 0, Vector4.ONE * 2)) + t.set_stylebox("grabber", "VScrollBar", scroll) + t.set_stylebox("grabber_highlight", "VScrollBar", box(EDGE, EDGE, 0, Vector4.ZERO)) + t.set_stylebox("grabber_pressed", "VScrollBar", box(EDGE, EDGE, 0, Vector4.ZERO)) + var pad := Vector4(18, 6, 18, 8) + t.set_font("font", "Button", TITLE_FONT) + t.set_font_size("font_size", "Button", 22) + t.set_color("font_color", "Button", INK) + t.set_color("font_hover_color", "Button", GOLD) + t.set_color("font_focus_color", "Button", GOLD) + t.set_color("font_pressed_color", "Button", BG) + t.set_stylebox("normal", "Button", box(PANEL, EDGE_DARK, 2, pad)) + t.set_stylebox("hover", "Button", box(PANEL, EDGE, 2, pad)) + t.set_stylebox("focus", "Button", box(Color(0, 0, 0, 0), GOLD, 2, pad)) + t.set_stylebox("pressed", "Button", box(GOLD, GOLD, 2, pad)) + return t diff --git a/godot-jev/demo/style.gd.uid b/godot-jev/demo/style.gd.uid new file mode 100644 index 0000000..3a401c9 --- /dev/null +++ b/godot-jev/demo/style.gd.uid @@ -0,0 +1 @@ +uid://bahip40ue74jc diff --git a/godot-jev/project.godot b/godot-jev/project.godot index fea6cc3..d5bbab4 100644 --- a/godot-jev/project.godot +++ b/godot-jev/project.godot @@ -9,7 +9,7 @@ config_version=5 config/name="The Sunken Crypt" config/description="A dungeon crawl whose parser understands anything the player types, but can only choose moves the author wrote." -run/main_scene="res://demo/adventure.tscn" +run/main_scene="res://demo/start_menu.tscn" config/features=PackedStringArray("4.7") [autoload] diff --git a/godot-jev/tests/run_tests.gd b/godot-jev/tests/run_tests.gd index 6f4c711..aac2546 100644 --- a/godot-jev/tests/run_tests.gd +++ b/godot-jev/tests/run_tests.gd @@ -12,6 +12,7 @@ const SUITES := [ "res://tests/test_world.gd", "res://tests/test_parser.gd", "res://tests/test_transcript.gd", + "res://tests/test_menu.gd", ] var failures := 0 diff --git a/godot-jev/tests/test_menu.gd b/godot-jev/tests/test_menu.gd new file mode 100644 index 0000000..d62f315 --- /dev/null +++ b/godot-jev/tests/test_menu.gd @@ -0,0 +1,58 @@ +extends "res://tests/suite.gd" + +const Help := preload("res://demo/help.gd") +const Parser := preload("res://demo/parser.gd") +const HttpDecider := preload("res://addons/jev/http_decider.gd") +const ScriptedDecider := preload("res://addons/jev/scripted_decider.gd") +const MENU := "res://demo/start_menu.tscn" + + +func _open_menu() -> Control: + var menu: Control = load(MENU).instantiate() + (Engine.get_main_loop() as SceneTree).root.add_child(menu) + if not menu.is_node_ready(): + await menu.ready + return menu + + +func test_the_game_starts_at_the_menu() -> void: + eq(ProjectSettings.get_setting("application/run/main_scene"), MENU, "main scene") + + +func test_the_menu_links_to_the_game_and_the_help_page() -> void: + var menu: Control = await _open_menu() + eq(menu.play_button.text, "Enter the crypt", "play button") + eq(menu.help_button.text, "Help", "help button") + check(ResourceLoader.exists(menu.GAME), "the play button's scene exists") + check(not menu.help_view.visible, "help starts hidden") + menu.help_button.pressed.emit() + check(menu.help_view.visible and not menu.title_view.visible, "Help opens the help page") + check(menu.help_text.text == Help.page(Parser.new(null).clarify_confidence, Parser.new(null).act_confidence), "it shows the help page") + menu.back_button.pressed.emit() + check(menu.title_view.visible and not menu.help_view.visible, "Back returns to the menu") + menu.queue_free() + + +func test_the_help_page_explains_the_experiment() -> void: + var out := Help.page(0.4, 0.75) + for topic in ["Hypothesis", "How it works", "none of these", "Jev heard", "TYPESAFE_API_KEY"]: + check(out.contains(topic), "mentions %s" % topic) + + +func test_the_help_page_uses_the_parser_thresholds() -> void: + var out := Help.page(0.31, 0.82) + check(out.contains("0.82") and out.contains("0.31"), "thresholds come from the arguments") + check(not out.contains("0.75"), "no hard-coded act threshold") + + +func test_the_status_line_says_whether_jev_can_answer() -> void: + var offline := HttpDecider.new() + check(Help.status_line(offline).contains("offline"), "no key is offline") + var online := HttpDecider.new() + online.api_key = "sk-test" + online.model = "jev-test" + check(Help.status_line(online).contains("jev-test"), "with a key it names the model") + check(Help.status_line(ScriptedDecider.new(func(_s, _q): return {})).contains("scripted"), "a scripted decider says so") + check(Help.status_line(null).contains("offline"), "no decider is offline") + offline.free() + online.free() diff --git a/godot-jev/tests/test_menu.gd.uid b/godot-jev/tests/test_menu.gd.uid new file mode 100644 index 0000000..62814ea --- /dev/null +++ b/godot-jev/tests/test_menu.gd.uid @@ -0,0 +1 @@ +uid://cshi7px200ayl