From 21d3782655f7e2ff89ef6459f54d6c44ad9fbecb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 1 Jan 2026 16:54:26 -0600 Subject: [PATCH 1/5] Add preliminary backend support for long-runners --- .../lib/registrations/waydowntown.ex | 113 +++++++++++---- registrations/priv/concepts.yaml | 15 ++ .../test/models/specification_test.exs | 9 +- .../controllers/run_controller_test.exs | 66 +++++++++ .../submission_controller_test.exs | 130 ++++++++++++++++++ waydowntown_app/assets/concepts.yaml | 15 ++ 6 files changed, 321 insertions(+), 27 deletions(-) diff --git a/registrations/lib/registrations/waydowntown.ex b/registrations/lib/registrations/waydowntown.ex index b6e02e84..d1589709 100644 --- a/registrations/lib/registrations/waydowntown.ex +++ b/registrations/lib/registrations/waydowntown.ex @@ -28,8 +28,19 @@ defmodule Registrations.Waydowntown do Map.keys(concepts_yaml()) end + def concept_is_long_running(concept) do + concept_flag?(concept, "long_running") + end + def concept_is_placed(concept) do - !(concepts_yaml()[concept]["placeless"] == true) + concept_data = Map.get(concepts_yaml(), concept, %{}) + concept_data["placeless"] != true + end + + defp concept_flag?(concept, key) do + concepts_yaml() + |> Map.get(concept, %{}) + |> Map.get(key) == true end def list_regions do @@ -153,7 +164,7 @@ defmodule Registrations.Waydowntown do {:error, "No placed specification available"} specification -> - {:ok, Run.changeset(%Run{}, Map.put(attrs, "specification_id", specification.id))} + {:ok, build_run_changeset(attrs, specification)} end end @@ -163,7 +174,7 @@ defmodule Registrations.Waydowntown do {:error, "No specification found near the specified position"} specification -> - {:ok, Run.changeset(%Run{}, Map.put(attrs, "specification_id", specification.id))} + {:ok, build_run_changeset(attrs, specification)} end end @@ -178,7 +189,7 @@ defmodule Registrations.Waydowntown do {:error, "No specification with the specified concept available"} specification -> - {:ok, Run.changeset(%Run{}, Map.put(attrs, "specification_id", specification.id))} + {:ok, build_run_changeset(attrs, specification)} end end end @@ -189,7 +200,7 @@ defmodule Registrations.Waydowntown do {:error, "Specification not found"} specification -> - {:ok, Run.changeset(%Run{}, Map.put(attrs, "specification_id", specification.id))} + {:ok, build_run_changeset(attrs, specification)} end end @@ -209,7 +220,23 @@ defmodule Registrations.Waydowntown do task_description: concept_data["instructions"] }), {:ok, _} <- create_answers(specification, answers) do - {:ok, Run.changeset(%Run{}, Map.put(attrs, "specification_id", specification.id))} + {:ok, build_run_changeset(attrs, specification)} + end + end + + defp build_run_changeset(attrs, %Specification{concept: concept} = specification) do + attrs = Map.put(attrs, "specification_id", specification.id) + + %Run{} + |> Run.changeset(attrs) + |> maybe_start_long_running_run(concept) + end + + defp maybe_start_long_running_run(changeset, concept) do + if concept_is_long_running(concept) do + Ecto.Changeset.put_change(changeset, :started_at, DateTime.utc_now()) + else + changeset end end @@ -386,28 +413,32 @@ defmodule Registrations.Waydowntown do end defp check_submission_validity(current_user_id, run, submission_text, answer_id) do - case run.specification.concept do - "string_collector" -> - check_for_duplicate_normalised_submission(current_user_id, run, submission_text) + concept = run.specification.concept + + cond do + concept in ["string_collector", "payphone_collector", "elevator_collector"] -> + check_for_duplicate_normalised_submission(current_user_id, run, submission_text, concept) - concept when concept in ["food_court_frenzy", "fill_in_the_blank", "count_the_items"] -> + concept in ["food_court_frenzy", "fill_in_the_blank", "count_the_items"] -> check_for_paired_answer(current_user_id, run, answer_id) - concept when concept in ["orientation_memory", "cardinal_memory"] -> + concept in ["orientation_memory", "cardinal_memory"] -> check_for_ordered_answer(current_user_id, run, answer_id) - _ -> + true -> {:ok, nil} end end - defp check_for_duplicate_normalised_submission(current_user_id, run, submission_text) do - normalized_submission = normalize_string(submission_text) + defp check_for_duplicate_normalised_submission(current_user_id, run, submission_text, concept) do + normalised_submission = normalise_submission_for_concept(concept, submission_text) existing_submissions = - Enum.map(Enum.filter(run.submissions, &(&1.creator_id == current_user_id)), &normalize_string(&1.submission)) + run.submissions + |> Enum.filter(&(&1.creator_id == current_user_id)) + |> Enum.map(&normalise_submission_for_concept(concept, &1.submission)) - if normalized_submission in existing_submissions do + if normalised_submission in existing_submissions do {:error, "Submission already submitted"} else {:ok, nil} @@ -491,8 +522,10 @@ defmodule Registrations.Waydowntown do defp run_expired?(_), do: false def get_run_progress(run, current_user_id) do + concept = run.specification.concept + correct_submissions = - if run.specification.concept in ["orientation_memory", "cardinal_memory"] do + if concept in ["orientation_memory", "cardinal_memory"] do latest_submission = run.submissions |> Enum.filter(&(&1.creator_id == current_user_id)) @@ -547,15 +580,19 @@ defmodule Registrations.Waydowntown do submission_text, _answer_id ) - when concept in ["bluetooth_collector", "code_collector", "string_collector"] do - normalized_answer = if concept == "string_collector", do: normalize_string(submission_text), else: submission_text + when concept in [ + "bluetooth_collector", + "code_collector", + "string_collector", + "payphone_collector", + "elevator_collector" + ] do + normalised_submission = normalise_submission_for_concept(concept, submission_text) correct_answers = - if concept == "string_collector", - do: Enum.map(answers, &normalize_string(&1.answer)), - else: Enum.map(answers, & &1.answer) + Enum.map(answers, &normalize_answer_for_concept(concept, &1.answer)) - normalized_answer in correct_answers + normalised_submission in correct_answers end defp check_submission_correctness( @@ -602,7 +639,15 @@ defmodule Registrations.Waydowntown do "count_the_items" -> true - concept when concept in ["bluetooth_collector", "code_collector", "string_collector", "food_court_frenzy"] -> + concept + when concept in [ + "bluetooth_collector", + "code_collector", + "string_collector", + "payphone_collector", + "elevator_collector", + "food_court_frenzy" + ] -> run_answer_count = length(run.specification.answers) correct_submissions = Enum.filter(run.submissions, & &1.correct) correct_submissions_by_user = Enum.group_by(correct_submissions, & &1.creator_id) @@ -632,6 +677,26 @@ defmodule Registrations.Waydowntown do |> String.downcase() end + defp normalise_submission_for_concept(concept, submission) do + if concept_normalizes_submissions?(concept) do + normalize_string(submission) + else + submission + end + end + + defp normalize_answer_for_concept(concept, answer) do + if concept_normalizes_submissions?(concept) do + normalize_string(answer) + else + answer + end + end + + defp concept_normalizes_submissions?(concept) do + concept_flag?(concept, "normalise_submissions") or concept == "string_collector" + end + def start_run(current_user, %Run{} = run) do run_has_current_user_participation = run.participations |> Enum.map(& &1.user_id) |> Enum.member?(current_user.id) diff --git a/registrations/priv/concepts.yaml b/registrations/priv/concepts.yaml index e5ddea04..b36ec703 100644 --- a/registrations/priv/concepts.yaml +++ b/registrations/priv/concepts.yaml @@ -33,12 +33,27 @@ string_collector: name: "String Collector" marker: list_checks instructions: "enter the strings of text described until you’ve found them all. caps are ignored." + normalise_submissions: true fill_in_the_blank: name: "Fill in the Blank" marker: rectangle_ellipsis instructions: "submit the correct answer to fill in the blank." +payphone_collector: + name: "Payphone Collector" + marker: list_checks + instructions: "submit the phone numbers from payphones." + long_running: true + normalise_submissions: true + +elevator_collector: + name: "Elevator Collector" + marker: list_checks + instructions: "submit the serial numbers from elevator permits." + long_running: true + normalise_submissions: true + orientation_memory: name: "Orientation memory" marker: ratio diff --git a/registrations/test/models/specification_test.exs b/registrations/test/models/specification_test.exs index c8e9e367..7aba2e65 100644 --- a/registrations/test/models/specification_test.exs +++ b/registrations/test/models/specification_test.exs @@ -11,13 +11,16 @@ defmodule Registrations.Waydowntown.SpecificationTest do end test "validates concept" do - valid_concept = "bluetooth_collector" + valid_concepts = ["bluetooth_collector", "payphone_collector", "elevator_collector"] invalid_concept = "invalid_concept" - valid_changeset = Specification.changeset(%Specification{}, %{concept: valid_concept, task_description: "test"}) + Enum.each(valid_concepts, fn concept -> + valid_changeset = Specification.changeset(%Specification{}, %{concept: concept, task_description: "test"}) + assert valid_changeset.valid? + end) + invalid_changeset = Specification.changeset(%Specification{}, %{concept: invalid_concept, task_description: "test"}) - assert valid_changeset.valid? refute invalid_changeset.valid? assert "must be a known concept" in errors_on(invalid_changeset).concept end diff --git a/registrations/test/registrations_web/controllers/run_controller_test.exs b/registrations/test/registrations_web/controllers/run_controller_test.exs index 10c8a69a..d52ef5d6 100644 --- a/registrations/test/registrations_web/controllers/run_controller_test.exs +++ b/registrations/test/registrations_web/controllers/run_controller_test.exs @@ -488,6 +488,72 @@ defmodule RegistrationsWeb.RunControllerTest do specification = Waydowntown.get_specification!(run.specification_id) assert specification.concept == "food_court_frenzy" end + + test "creates run with payphone_collector concept", %{conn: conn} do + Repo.insert!(%Specification{ + concept: "payphone_collector", + answers: [ + %Answer{answer: "204-555-0112", hint: "Payphone near the mural"}, + %Answer{answer: "204-555-0199", hint: "Payphone by the red benches"} + ], + region: Repo.insert!(%Region{}) + }) + + conn = + post( + conn, + Routes.run_path(conn, :create) <> "?filter[specification.concept]=payphone_collector", + %{ + "data" => %{ + "type" => "runs", + "attributes" => %{} + } + } + ) + + assert %{"id" => id} = json_response(conn, 201)["data"] + assert %{"included" => included} = json_response(conn, 201) + + sideloaded_specification = Enum.find(included, &(&1["type"] == "specifications")) + assert sideloaded_specification["attributes"]["concept"] == "payphone_collector" + + run = Waydowntown.get_run!(id) + specification = Waydowntown.get_specification!(run.specification_id) + assert specification.concept == "payphone_collector" + end + + test "creates run with elevator_collector concept", %{conn: conn} do + Repo.insert!(%Specification{ + concept: "elevator_collector", + answers: [ + %Answer{answer: "EP-71-449", hint: "East elevator bank"}, + %Answer{answer: "EP-83-120", hint: "Service elevator by loading bay"} + ], + region: Repo.insert!(%Region{}) + }) + + conn = + post( + conn, + Routes.run_path(conn, :create) <> "?filter[specification.concept]=elevator_collector", + %{ + "data" => %{ + "type" => "runs", + "attributes" => %{} + } + } + ) + + assert %{"id" => id} = json_response(conn, 201)["data"] + assert %{"included" => included} = json_response(conn, 201) + + sideloaded_specification = Enum.find(included, &(&1["type"] == "specifications")) + assert sideloaded_specification["attributes"]["concept"] == "elevator_collector" + + run = Waydowntown.get_run!(id) + specification = Waydowntown.get_specification!(run.specification_id) + assert specification.concept == "elevator_collector" + end end describe "start run" do diff --git a/registrations/test/registrations_web/controllers/submission_controller_test.exs b/registrations/test/registrations_web/controllers/submission_controller_test.exs index 29c8c4cf..454dcf0d 100644 --- a/registrations/test/registrations_web/controllers/submission_controller_test.exs +++ b/registrations/test/registrations_web/controllers/submission_controller_test.exs @@ -848,6 +848,136 @@ defmodule RegistrationsWeb.SubmissionControllerTest do end end + describe "create submission for payphone_collector" do + setup do + user = insert(:user) + + specification = + Repo.insert!(%Specification{ + concept: "payphone_collector", + start_description: "Collect all the payphones", + answers: [%Answer{answer: "204-555-0112"}, %Answer{answer: "204-555-0199"}] + }) + + run = Repo.insert!(%Run{specification: specification, started_at: DateTime.utc_now()}) + + %{run: run, user: user} + end + + test "creates answer and returns 201 for correct submission", %{conn: conn, run: run} do + conn = + conn + |> setup_conn() + |> post( + Routes.submission_path(conn, :create), + %{ + "data" => %{ + "type" => "submissions", + "attributes" => %{"submission" => " 204-555-0112 "}, + "relationships" => %{ + "run" => %{ + "data" => %{"type" => "runs", "id" => run.id} + } + } + } + } + ) + + assert %{"correct" => true} = json_response(conn, 201)["data"]["attributes"] + end + + test "rejects a duplicate submission", %{conn: conn, run: run, user: user} do + Repo.insert!(%Submission{ + submission: "204-555-0112", + run_id: run.id, + correct: true, + creator: user + }) + + conn = + conn + |> setup_conn(user) + |> post(Routes.submission_path(conn, :create), %{ + "data" => %{ + "type" => "submissions", + "attributes" => %{"submission" => "204-555-0112"}, + "relationships" => %{ + "run" => %{ + "data" => %{"type" => "runs", "id" => run.id} + } + } + } + }) + + assert json_response(conn, 422)["errors"] == [%{"detail" => "Submission already submitted"}] + end + end + + describe "create submission for elevator_collector" do + setup do + user = insert(:user) + + specification = + Repo.insert!(%Specification{ + concept: "elevator_collector", + start_description: "Collect all the elevator permits", + answers: [%Answer{answer: "EP-71-449"}, %Answer{answer: "EP-83-120"}] + }) + + run = Repo.insert!(%Run{specification: specification, started_at: DateTime.utc_now()}) + + %{run: run, user: user} + end + + test "creates answer and returns 201 for correct submission", %{conn: conn, run: run} do + conn = + conn + |> setup_conn() + |> post( + Routes.submission_path(conn, :create), + %{ + "data" => %{ + "type" => "submissions", + "attributes" => %{"submission" => " ep-83-120 "}, + "relationships" => %{ + "run" => %{ + "data" => %{"type" => "runs", "id" => run.id} + } + } + } + } + ) + + assert %{"correct" => true} = json_response(conn, 201)["data"]["attributes"] + end + + test "rejects a duplicate submission", %{conn: conn, run: run, user: user} do + Repo.insert!(%Submission{ + submission: "ep-83-120", + run_id: run.id, + correct: true, + creator: user + }) + + conn = + conn + |> setup_conn(user) + |> post(Routes.submission_path(conn, :create), %{ + "data" => %{ + "type" => "submissions", + "attributes" => %{"submission" => "EP-83-120"}, + "relationships" => %{ + "run" => %{ + "data" => %{"type" => "runs", "id" => run.id} + } + } + } + }) + + assert json_response(conn, 422)["errors"] == [%{"detail" => "Submission already submitted"}] + end + end + describe "create answer for count_the_items" do setup do region = Repo.insert!(%Region{name: "Test Region"}) diff --git a/waydowntown_app/assets/concepts.yaml b/waydowntown_app/assets/concepts.yaml index e5ddea04..b36ec703 100644 --- a/waydowntown_app/assets/concepts.yaml +++ b/waydowntown_app/assets/concepts.yaml @@ -33,12 +33,27 @@ string_collector: name: "String Collector" marker: list_checks instructions: "enter the strings of text described until you’ve found them all. caps are ignored." + normalise_submissions: true fill_in_the_blank: name: "Fill in the Blank" marker: rectangle_ellipsis instructions: "submit the correct answer to fill in the blank." +payphone_collector: + name: "Payphone Collector" + marker: list_checks + instructions: "submit the phone numbers from payphones." + long_running: true + normalise_submissions: true + +elevator_collector: + name: "Elevator Collector" + marker: list_checks + instructions: "submit the serial numbers from elevator permits." + long_running: true + normalise_submissions: true + orientation_memory: name: "Orientation memory" marker: ratio From 253153a87788da3222f891f1c667422f70bb00f1 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 1 Jan 2026 17:01:36 -0600 Subject: [PATCH 2/5] Add app support for long-running games --- waydowntown_app/lib/app.dart | 18 ++++++++++++++++++ .../lib/routes/run_launch_route.dart | 2 ++ 2 files changed, 20 insertions(+) diff --git a/waydowntown_app/lib/app.dart b/waydowntown_app/lib/app.dart index e8b95ee5..88af0cad 100644 --- a/waydowntown_app/lib/app.dart +++ b/waydowntown_app/lib/app.dart @@ -189,6 +189,24 @@ class _HomeState extends State { }), const SizedBox(height: 20), const Divider(color: Colors.white), + const Text("Event-long Collections", + style: TextStyle(color: Colors.white)), + _buildFlexibleButtonGrid([ + ('Payphone\nCollector', 'payphone_collector'), + ('Elevator\nCollector', 'elevator_collector'), + ], (concept) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RequestRunRoute( + dio: dio, + concept: concept, + ), + ), + ); + }), + const SizedBox(height: 20), + const Divider(color: Colors.white), const Text("Placeless Games", style: TextStyle(color: Colors.white)), _buildFlexibleButtonGrid([ diff --git a/waydowntown_app/lib/routes/run_launch_route.dart b/waydowntown_app/lib/routes/run_launch_route.dart index a86dc420..d5d62fca 100644 --- a/waydowntown_app/lib/routes/run_launch_route.dart +++ b/waydowntown_app/lib/routes/run_launch_route.dart @@ -473,6 +473,8 @@ class _RunLaunchRouteState extends State { return OrientationMemoryGame( run: game, dio: widget.dio, channel: channel!); case 'string_collector': + case 'payphone_collector': + case 'elevator_collector': return StringCollectorGame( run: game, dio: widget.dio, channel: channel!); default: From c911ea35986f0ca6323f813b59b518dff7966e96 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 1 Jan 2026 23:15:38 -0600 Subject: [PATCH 3/5] Fix icons for new games --- registrations/priv/concepts.yaml | 4 ++-- waydowntown_app/assets/concepts.yaml | 4 ++-- waydowntown_app/lib/tools/map_route.dart | 4 ++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/registrations/priv/concepts.yaml b/registrations/priv/concepts.yaml index b36ec703..7c852a5a 100644 --- a/registrations/priv/concepts.yaml +++ b/registrations/priv/concepts.yaml @@ -42,14 +42,14 @@ fill_in_the_blank: payphone_collector: name: "Payphone Collector" - marker: list_checks + marker: phone instructions: "submit the phone numbers from payphones." long_running: true normalise_submissions: true elevator_collector: name: "Elevator Collector" - marker: list_checks + marker: arrow_up_down instructions: "submit the serial numbers from elevator permits." long_running: true normalise_submissions: true diff --git a/waydowntown_app/assets/concepts.yaml b/waydowntown_app/assets/concepts.yaml index b36ec703..7c852a5a 100644 --- a/waydowntown_app/assets/concepts.yaml +++ b/waydowntown_app/assets/concepts.yaml @@ -42,14 +42,14 @@ fill_in_the_blank: payphone_collector: name: "Payphone Collector" - marker: list_checks + marker: phone instructions: "submit the phone numbers from payphones." long_running: true normalise_submissions: true elevator_collector: name: "Elevator Collector" - marker: list_checks + marker: arrow_up_down instructions: "submit the serial numbers from elevator permits." long_running: true normalise_submissions: true diff --git a/waydowntown_app/lib/tools/map_route.dart b/waydowntown_app/lib/tools/map_route.dart index 2e01945c..59aa1e6e 100644 --- a/waydowntown_app/lib/tools/map_route.dart +++ b/waydowntown_app/lib/tools/map_route.dart @@ -147,6 +147,8 @@ class _MapRouteState extends State { IconData iconFromName(String name) { switch (name) { + case 'arrow_up_down': + return LucideIcons.arrow_up_down; case 'bluetooth_searching': return LucideIcons.bluetooth_searching; case 'compass': @@ -157,6 +159,8 @@ IconData iconFromName(String name) { return LucideIcons.utensils_crossed; case 'list_checks': return LucideIcons.list_checks; + case 'phone': + return LucideIcons.phone; case 'rectangle_ellipsis': return LucideIcons.rectangle_ellipsis; case 'ratio': From 4d68bfb48e856022caf723e4ddd7cd61b8b967f2 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 2 Jan 2026 10:25:29 -0600 Subject: [PATCH 4/5] Add regions for answers --- .../lib/registrations/waydowntown.ex | 21 +++++++--- .../lib/registrations/waydowntown/answer.ex | 4 +- .../registrations_web/views/answer_view.ex | 8 +++- .../views/owner/answer_view.ex | 5 ++- ...01230818_add_waydowntown_answer_region.exs | 10 +++++ .../controllers/run_controller_test.exs | 7 +++- .../specification_controller_test.exs | 39 ++++++++++++++++++- 7 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 registrations/priv/repo/migrations/20260101230818_add_waydowntown_answer_region.exs diff --git a/registrations/lib/registrations/waydowntown.ex b/registrations/lib/registrations/waydowntown.ex index d1589709..e93e1a31 100644 --- a/registrations/lib/registrations/waydowntown.ex +++ b/registrations/lib/registrations/waydowntown.ex @@ -330,14 +330,14 @@ defmodule Registrations.Waydowntown do end def list_specifications do - Specification |> Repo.all() |> Repo.preload(region: [parent: [parent: [:parent]]]) + Specification |> Repo.all() |> Repo.preload(answers: [:region], region: [parent: [parent: [:parent]]]) end def list_specifications_for(user) do from(i in Specification) |> where([i], i.creator_id == ^user.id) |> Repo.all() - |> Repo.preload(answers: [:reveals], region: [parent: [parent: [:parent]]]) + |> Repo.preload(answers: [:reveals, :region], region: [parent: [parent: [:parent]]]) end def get_specification!(id), do: Repo.get!(Specification, id) @@ -717,14 +717,23 @@ defmodule Registrations.Waydowntown do defp run_preloads do [ - participations: [run: [:participations, specification: [answers: [:reveals]], submissions: [answer: [:reveals]]]], - submissions: [answer: [:reveals]], - specification: [answers: [:reveals], region: [parent: [parent: [:parent]]]] + participations: [ + run: [ + :participations, + specification: [answers: [:reveals, :region]], + submissions: [answer: [:reveals, :region]] + ] + ], + submissions: [answer: [:reveals, :region]], + specification: [answers: [:reveals, :region], region: [parent: [parent: [:parent]]]] ] end defp submission_preloads do - [answer: [:reveals], run: [:participations, specification: [answers: [:reveals]], submissions: [answer: [:reveals]]]] + [ + answer: [:reveals, :region], + run: [:participations, specification: [answers: [:reveals, :region]], submissions: [answer: [:reveals, :region]]] + ] end def get_participation!(id), diff --git a/registrations/lib/registrations/waydowntown/answer.ex b/registrations/lib/registrations/waydowntown/answer.ex index 0ccd0b89..72210e21 100644 --- a/registrations/lib/registrations/waydowntown/answer.ex +++ b/registrations/lib/registrations/waydowntown/answer.ex @@ -14,6 +14,7 @@ defmodule Registrations.Waydowntown.Answer do field(:order, :integer) belongs_to(:specification, Registrations.Waydowntown.Specification, type: :binary_id) + belongs_to(:region, Registrations.Waydowntown.Region, type: :binary_id) has_many(:reveals, Registrations.Waydowntown.Reveal, on_delete: :delete_all) @@ -23,8 +24,9 @@ defmodule Registrations.Waydowntown.Answer do @doc false def changeset(answer, attrs) do answer - |> cast(attrs, [:answer, :order, :specification_id]) + |> cast(attrs, [:answer, :order, :specification_id, :region_id]) |> validate_required([:answer, :order, :specification_id]) |> assoc_constraint(:specification) + |> assoc_constraint(:region) end end diff --git a/registrations/lib/registrations_web/views/answer_view.ex b/registrations/lib/registrations_web/views/answer_view.ex index 264c7dcd..fd0bc143 100644 --- a/registrations/lib/registrations_web/views/answer_view.ex +++ b/registrations/lib/registrations_web/views/answer_view.ex @@ -8,7 +8,8 @@ defmodule RegistrationsWeb.AnswerView do def get_field(field, answer, conn) do cond do field == :hint -> - if Enum.find(answer.reveals, fn reveal -> reveal.user_id == conn.assigns.current_user.id end) do + if Ecto.assoc_loaded?(answer.reveals) and + Enum.find(answer.reveals, fn reveal -> reveal.user_id == conn.assigns.current_user.id end) do Map.fetch!(answer, field) end @@ -21,6 +22,9 @@ defmodule RegistrationsWeb.AnswerView do end def relationships do - [specification: {RegistrationsWeb.SpecificationView, :include}] + [ + specification: {RegistrationsWeb.SpecificationView, :include}, + region: {RegistrationsWeb.RegionView, :include} + ] end end diff --git a/registrations/lib/registrations_web/views/owner/answer_view.ex b/registrations/lib/registrations_web/views/owner/answer_view.ex index 90d4c5c7..6d98d0e3 100644 --- a/registrations/lib/registrations_web/views/owner/answer_view.ex +++ b/registrations/lib/registrations_web/views/owner/answer_view.ex @@ -6,6 +6,9 @@ defmodule RegistrationsWeb.Owner.AnswerView do end def relationships do - [specification: {RegistrationsWeb.Owner.SpecificationView, :include}] + [ + specification: {RegistrationsWeb.Owner.SpecificationView, :include}, + region: {RegistrationsWeb.RegionView, :include} + ] end end diff --git a/registrations/priv/repo/migrations/20260101230818_add_waydowntown_answer_region.exs b/registrations/priv/repo/migrations/20260101230818_add_waydowntown_answer_region.exs new file mode 100644 index 00000000..3508fb9a --- /dev/null +++ b/registrations/priv/repo/migrations/20260101230818_add_waydowntown_answer_region.exs @@ -0,0 +1,10 @@ +defmodule Registrations.Repo.Migrations.AddWaydowntownAnswerRegion do + @moduledoc false + use Ecto.Migration + + def change do + alter table(:answers, prefix: "waydowntown") do + add(:region_id, references(:regions, type: :uuid, on_delete: :nilify_all)) + end + end +end diff --git a/registrations/test/registrations_web/controllers/run_controller_test.exs b/registrations/test/registrations_web/controllers/run_controller_test.exs index d52ef5d6..76240042 100644 --- a/registrations/test/registrations_web/controllers/run_controller_test.exs +++ b/registrations/test/registrations_web/controllers/run_controller_test.exs @@ -52,7 +52,8 @@ defmodule RegistrationsWeb.RunControllerTest do Repo.insert!(%Answer{ label: "answer_1", hint: "hint_1", - specification: specification + specification: specification, + region_id: child_region.id }) answer_2 = @@ -171,7 +172,8 @@ defmodule RegistrationsWeb.RunControllerTest do user: user, conn: conn, run: run, - answers: [answer | _] + answers: [answer | _], + child_region: child_region } do {:ok, _reveal} = Waydowntown.create_reveal(user, answer.id, run.id) @@ -181,6 +183,7 @@ defmodule RegistrationsWeb.RunControllerTest do revealed_answer = Enum.find(included, &(&1["type"] == "answers" && &1["id"] == answer.id)) assert revealed_answer["attributes"]["hint"] == answer.hint + assert revealed_answer["relationships"]["region"]["data"]["id"] == child_region.id end test "answer hint is null when not revealed for this user", %{ diff --git a/registrations/test/registrations_web/controllers/specification_controller_test.exs b/registrations/test/registrations_web/controllers/specification_controller_test.exs index 649cdfd4..26d7de66 100644 --- a/registrations/test/registrations_web/controllers/specification_controller_test.exs +++ b/registrations/test/registrations_web/controllers/specification_controller_test.exs @@ -97,6 +97,12 @@ defmodule RegistrationsWeb.SpecificationControllerTest do setup do user = insert(:octavia, admin: true) + answer_region = + Repo.insert!(%Region{ + name: "Answer Region", + geom: %Geo.Point{coordinates: {-97.0, 40.1}, srid: 4326} + }) + my_specification_1 = Repo.insert!(%Specification{ creator_id: user.id, @@ -111,7 +117,14 @@ defmodule RegistrationsWeb.SpecificationControllerTest do notes: "This is a test note" }) - answer_1 = Repo.insert!(%Answer{answer: "Answer 1", hint: "Hint 1", specification_id: my_specification_1.id}) + answer_1 = + Repo.insert!(%Answer{ + answer: "Answer 1", + hint: "Hint 1", + specification_id: my_specification_1.id, + region_id: answer_region.id + }) + answer_2 = Repo.insert!(%Answer{answer: "Answer 2", hint: "Hint 2", specification_id: my_specification_2.id}) other_specification = Repo.insert!(%Specification{creator_id: insert(:user).id}) @@ -133,6 +146,7 @@ defmodule RegistrationsWeb.SpecificationControllerTest do other_specification: other_specification, answer_1: answer_1, answer_2: answer_2, + answer_region: answer_region, user: user } end @@ -144,7 +158,8 @@ defmodule RegistrationsWeb.SpecificationControllerTest do my_specification_2: my_specification_2, other_specification: other_specification, answer_1: answer_1, - answer_2: answer_2 + answer_2: answer_2, + answer_region: answer_region } do conn = conn @@ -210,6 +225,26 @@ defmodule RegistrationsWeb.SpecificationControllerTest do assert "Hint 1" in included_answer_hints assert "Hint 2" in included_answer_hints + + included_answers = + conn + |> json_response(200) + |> Map.get("included") + |> Enum.filter(fn item -> item["type"] == "answers" end) + + answer_1_data = Enum.find(included_answers, &(&1["id"] == answer_1.id)) + answer_2_data = Enum.find(included_answers, &(&1["id"] == answer_2.id)) + + assert answer_1_data["relationships"]["region"]["data"]["id"] == answer_region.id + assert answer_2_data["relationships"]["region"]["data"] == nil + + included_regions = + conn + |> json_response(200) + |> Map.get("included") + |> Enum.filter(fn item -> item["type"] == "regions" end) + + assert Enum.any?(included_regions, fn region -> region["id"] == answer_region.id end) end end From 327115e9017d94637b4ac08cd29e60d6b6b27963 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 2 Jan 2026 10:26:59 -0600 Subject: [PATCH 5/5] Add long-running games to map --- waydowntown_app/lib/models/answer.dart | 25 +++- waydowntown_app/lib/models/specification.dart | 2 +- waydowntown_app/lib/tools/map_route.dart | 116 +++++++++++++----- 3 files changed, 113 insertions(+), 30 deletions(-) diff --git a/waydowntown_app/lib/models/answer.dart b/waydowntown_app/lib/models/answer.dart index bf947fdc..4c53f284 100644 --- a/waydowntown_app/lib/models/answer.dart +++ b/waydowntown_app/lib/models/answer.dart @@ -1,7 +1,10 @@ +import 'package:waydowntown/models/region.dart'; + class Answer { final String id; final String? label; final int? order; + final Region? region; final String? hint; final bool hasHint; @@ -10,15 +13,35 @@ class Answer { required this.id, required this.label, this.order, + this.region, this.hint, this.hasHint = false, }); - factory Answer.fromJson(Map json) { + factory Answer.fromJson(Map json, + [List? included]) { + Region? region; + final relationships = json['relationships']; + if (included != null && + relationships != null && + relationships['region'] != null && + relationships['region']['data'] != null) { + final regionData = relationships['region']['data']; + final regionJson = included.firstWhere( + (item) => item['type'] == 'regions' && item['id'] == regionData['id'], + orElse: () => {}, + ); + + if (regionJson.isNotEmpty) { + region = Region.fromJson(regionJson, included); + } + } + return Answer( id: json['id'], label: json['attributes']['label'], order: json['attributes']['order'], + region: region, hint: json['attributes']['hint'], hasHint: json['attributes']['has_hint'], ); diff --git a/waydowntown_app/lib/models/specification.dart b/waydowntown_app/lib/models/specification.dart index ccbe21cc..a16c9960 100644 --- a/waydowntown_app/lib/models/specification.dart +++ b/waydowntown_app/lib/models/specification.dart @@ -60,7 +60,7 @@ class Specification { item['type'] == 'answers' && relationships['answers']['data'] .any((answer) => answer['id'] == item['id'])) - .map((item) => Answer.fromJson(item)) + .map((item) => Answer.fromJson(item, included)) .toList(), notes: attributes['notes'], ); diff --git a/waydowntown_app/lib/tools/map_route.dart b/waydowntown_app/lib/tools/map_route.dart index 59aa1e6e..8e515c73 100644 --- a/waydowntown_app/lib/tools/map_route.dart +++ b/waydowntown_app/lib/tools/map_route.dart @@ -25,6 +25,7 @@ class _MapRouteState extends State { bool isLoading = true; bool isRequestError = false; Map conceptMarkers = {}; + Map conceptLongRunning = {}; @override void initState() { @@ -44,7 +45,6 @@ class _MapRouteState extends State { setState(() { specifications = data .map((json) => Specification.fromJson(json, included)) - .where((specification) => specification.region != null) .toList(); isLoading = false; }); @@ -67,41 +67,69 @@ class _MapRouteState extends State { final yamlString = await rootBundle.loadString('assets/concepts.yaml'); final yamlMap = loadYaml(yamlString) as YamlMap; - conceptMarkers = Map.fromEntries(yamlMap.entries.map((entry) { + final markers = Map.fromEntries(yamlMap.entries.map((entry) { final conceptName = entry.key as String; final conceptData = entry.value as YamlMap; return MapEntry(conceptName, conceptData['marker'] as String); })); + + final longRunning = Map.fromEntries(yamlMap.entries.map((entry) { + final conceptName = entry.key as String; + final conceptData = entry.value as YamlMap; + return MapEntry(conceptName, conceptData['long_running'] == true); + })); + + if (mounted) { + setState(() { + conceptMarkers = markers; + conceptLongRunning = longRunning; + }); + } } List _buildMarkers() { - return specifications - .where((specification) => - specification.region != null && - specification.region!.latitude != null && - specification.region!.longitude != null) - .map((specification) { - final region = specification.region!; + final markers = []; + final seenAnswerRegions = {}; + + for (final specification in specifications) { final marker = conceptMarkers[specification.concept] ?? '📍'; - return Marker( - width: 40.0, - height: 40.0, - point: LatLng(region.latitude!, region.longitude!), - child: GestureDetector( - onTap: () => _onMarkerTap(specification), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - border: Border.all(color: Colors.black, width: 1), - ), - child: Center( - child: Icon(iconFromName(marker)), - ), - ), - ), - ); - }).toList(); + final isLongRunning = conceptLongRunning[specification.concept] == true; + + if (!isLongRunning && + specification.region != null && + specification.region!.latitude != null && + specification.region!.longitude != null) { + final region = specification.region!; + markers.add(_buildMarker( + LatLng(region.latitude!, region.longitude!), + marker, + () => _onMarkerTap(specification), + )); + } + + if (isLongRunning && specification.answers != null) { + for (final answer in specification.answers!) { + final region = answer.region; + if (region?.latitude == null || region?.longitude == null) { + continue; + } + + final key = '${specification.concept}:${region!.id}'; + if (seenAnswerRegions.contains(key)) { + continue; + } + seenAnswerRegions.add(key); + + markers.add(_buildMarker( + LatLng(region.latitude!, region.longitude!), + marker, + () => _onLongRunningMarkerTap(specification.concept), + )); + } + } + } + + return markers; } void _onMarkerTap(Specification specification) { @@ -115,6 +143,38 @@ class _MapRouteState extends State { ); } + void _onLongRunningMarkerTap(String concept) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => RequestRunRoute( + dio: widget.dio, + concept: concept, + ), + ), + ); + } + + Marker _buildMarker(LatLng point, String marker, VoidCallback onTap) { + return Marker( + width: 40.0, + height: 40.0, + point: point, + child: GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: Colors.black, width: 1), + ), + child: Center( + child: Icon(iconFromName(marker)), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { if (isRequestError) {