From 8252418db97d1081be30ab153b2ead94f85b31e7 Mon Sep 17 00:00:00 2001 From: Noah Metz Date: Mon, 27 Jul 2026 23:47:54 -0600 Subject: [PATCH] Added tests for editor --- client/Makefile | 14 ++- client/include/editor.h | 63 +++++++++++ client/include/gpu.h | 2 +- client/include/hex.h | 8 ++ client/src/editor.c | 218 ++++++++++++++++++++++++++++----------- client/src/editor_main.c | 6 ++ client/src/engine.c | 6 +- client/src/gpu.c | 3 +- client/src/hex.c | 30 ++++-- client/test/editor.c | 171 ++++++++++++++++++++++++++++++ 10 files changed, 443 insertions(+), 78 deletions(-) create mode 100644 client/include/editor.h create mode 100644 client/src/editor_main.c create mode 100644 client/test/editor.c diff --git a/client/Makefile b/client/Makefile index 18875b8..f4daa13 100644 --- a/client/Makefile +++ b/client/Makefile @@ -7,14 +7,16 @@ LDFLAGS += $(shell pkg-config --libs lua) ENGINE_SOURCES = src/engine.c src/draw.c src/ui.c src/ui_lua.c src/gpu.c src/hex.c src/hsv.c lib/spng.c lib/vma.cpp APP_SOURCES = src/main.c $(ENGINE_SOURCES) -EDITOR_SOURCES = src/editor.c $(ENGINE_SOURCES) +EDITOR_SOURCES = src/editor_main.c src/editor.c $(ENGINE_SOURCES) TEST_SOURCES = test/hsv.c $(ENGINE_SOURCES) +TEST_EDITOR_SOURCES = test/editor.c src/editor.c $(ENGINE_SOURCES) APP_OBJECTS = $(addsuffix .o, $(basename $(APP_SOURCES))) EDITOR_OBJECTS = $(addsuffix .o, $(basename $(EDITOR_SOURCES))) TEST_OBJECTS = $(addsuffix .o, $(basename $(TEST_SOURCES))) +TEST_EDITOR_OBJECTS = $(addsuffix .o, $(basename $(TEST_EDITOR_SOURCES))) -ALL_OBJECTS = $(sort $(APP_OBJECTS) $(EDITOR_OBJECTS) $(TEST_OBJECTS)) +ALL_OBJECTS = $(sort $(APP_OBJECTS) $(EDITOR_OBJECTS) $(TEST_OBJECTS) $(TEST_EDITOR_OBJECTS)) DEP_FILES = $(ALL_OBJECTS:.o=.d) VERT_SPV = $(addsuffix .vert.spv, $(basename $(wildcard shader/*.vert))) @@ -52,9 +54,13 @@ editor: $(EDITOR_OBJECTS) | $(SPV_FILES) test/test_hsv: $(TEST_OBJECTS) $(CXX) $(CFLAGS) $(LDFLAGS) -o $@ $^ +test/test_editor: $(TEST_EDITOR_OBJECTS) | $(SPV_FILES) + $(CXX) $(CFLAGS) $(LDFLAGS) -o $@ $^ + .PHONY: test -test: test/test_hsv +test: test/test_hsv test/test_editor ./test/test_hsv + ./test/test_editor %.o: %.cpp $(CXX) $(CFLAGS) -std=c++17 -Wno-nullability-completeness -Wno-unused-parameter -Wno-missing-field-initializers -Wno-unused-private-field -Wno-unused-variable -c -o $@ $< @@ -74,7 +80,7 @@ compile_commands.json: Makefile clean: rm -f $(SPV_FILES) rm -f $(ALL_OBJECTS) $(DEP_FILES) - rm -f roleplay editor test/test_hsv + rm -f roleplay editor test/test_hsv test/test_editor rm -rf $(EXTRA_DEBUG_REQUIREMENTS) clean_compdb: diff --git a/client/include/editor.h b/client/include/editor.h new file mode 100644 index 0000000..5ec7ba2 --- /dev/null +++ b/client/include/editor.h @@ -0,0 +1,63 @@ +#ifndef EDITOR_H +#define EDITOR_H + +#include "engine.h" + +typedef enum EditorModeEnum { + MODE_NONE, + MODE_VERTEX, + MODE_NEIGHBOR, + MODE_HEX, + MODE_REGION, + MODE_MAX_ENUM, +} EditorMode; + +typedef struct EditorDataStruct EditorData; +typedef struct ModeKeyStruct ModeKey; + +typedef void(*ModeKeyCallback)(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); + +struct ModeKeyStruct { + int key; + ModeKeyCallback logic; + int axis; + float amount; +}; + +struct EditorDataStruct { + EditorMode mode; + + ModeKey* mode_keys[MODE_MAX_ENUM]; + uint32_t mode_key_counts[MODE_MAX_ENUM]; + + uint32_t selected_max; + uint32_t selected_count; + + uint32_t* selected_regions; + uint32_t* selected_hexes; + uint32_t* selected_vertices; +}; + +EditorData* create_editor_data(void); + +void editor_startup(ClientContext* context); +void editor_frame_callback(ClientContext* context); +void editor_key_callback(ClientContext* context, int key, int action, int mods); +void editor_scroll_callback(ClientContext* context, double x, double y); +void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods); + +void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); +void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); +void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); +void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); +void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); + +void resize_selected(EditorData* data, unsigned int size); +int32_t find_selected_hex(EditorData* data, uint32_t region, uint32_t hex); +int32_t find_selected_vertex(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex); +void add_selected(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex); +void remove_selected_at(EditorData* data, uint32_t index); + +bool editor_pick(ClientContext* context, double cursor[2], uint32_t* rid, uint32_t* hid, uint32_t* vertex, bool edge_only); + +#endif diff --git a/client/include/gpu.h b/client/include/gpu.h index b0cdc1d..409a3a2 100644 --- a/client/include/gpu.h +++ b/client/include/gpu.h @@ -166,7 +166,7 @@ typedef struct RenderContextStruct { bool framebuffer_recreated; } RenderContext; -GLFWwindow* init_window(); +GLFWwindow* init_window(bool visible); VkResult init_vulkan( GLFWwindow* window, diff --git a/client/include/hex.h b/client/include/hex.h index dd23a13..b65c05d 100644 --- a/client/include/hex.h +++ b/client/include/hex.h @@ -139,6 +139,13 @@ VkResult free_hex_region( HexContext* hex, RenderContext* gpu); +void cursor_to_world_ray( + RenderContext* gpu, + mat4 inverse, + double cursor[2], + vec4 start, + vec4 end); + bool ray_world_intersect( float* distance, uint32_t* vertex, @@ -146,6 +153,7 @@ bool ray_world_intersect( uint32_t* hid, vec4 ray_start, vec4 ray_end, + bool edge_only, HexContext* context); VkResult update_hex_proj( diff --git a/client/src/editor.c b/client/src/editor.c index 43c19d7..7cf2a21 100644 --- a/client/src/editor.c +++ b/client/src/editor.c @@ -1,6 +1,7 @@ #include #include "hex.h" #include "engine.h" +#include "editor.h" #include "ui_lua.h" #include "vulkan/vulkan_core.h" @@ -10,14 +11,9 @@ #define COLOR_PICK_CONTAINER_ID 0x02 #define MODE_STRING_CONTAINER_ID 0x01 -typedef enum EditorModeEnum { - MODE_NONE, - MODE_VERTEX, - MODE_NEIGHBOR, - MODE_HEX, - MODE_REGION, - MODE_MAX_ENUM, -} EditorMode; +#define SELECTION_HIGHLIGHT_OFFSET 0.05f +#define SELECTION_POINT_SIZE 12.0f +#define SELECTION_POINT_OFFSET 0.05f const char* ModeStrings[] = { "None", @@ -27,33 +23,6 @@ const char* ModeStrings[] = { "Region", }; -typedef struct EditorDataStruct EditorData; -typedef struct ModeKeyStruct ModeKey; - -typedef void(*ModeKeyCallback)(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); - -struct ModeKeyStruct { - int key; - ModeKeyCallback logic; - int axis; - float amount; -}; - -struct EditorDataStruct { - EditorMode mode; - - ModeKey* mode_keys[MODE_MAX_ENUM]; - uint32_t mode_key_counts[MODE_MAX_ENUM]; - - uint32_t selected_max; - uint32_t selected_count; - - uint32_t* selected_regions; - uint32_t* selected_hexes; - uint32_t* selected_vertices; -}; - - uint32_t hex_color(vec4 color) { return ((lrint(color[0]*255.0) & 0xFF) << 24) + ((lrint(color[1]*255.0) & 0xFF) << 16) @@ -207,6 +176,120 @@ void editor_scroll_callback(ClientContext* context, double x, double y) { context->zoom = (int32_t)y; } +void resize_selected(EditorData* data, unsigned int size) { + uint32_t* new_regions = malloc(sizeof(uint32_t)*size); + uint32_t* new_hexes = malloc(sizeof(uint32_t)*size); + uint32_t* new_vertices = malloc(sizeof(uint32_t)*size); + + if(data->selected_regions != NULL) { + memcpy(new_regions, data->selected_regions, data->selected_count*sizeof(uint32_t)); + free(data->selected_regions); + } + + if(data->selected_hexes != NULL) { + memcpy(new_hexes, data->selected_hexes, data->selected_count*sizeof(uint32_t)); + free(data->selected_hexes); + } + + if(data->selected_vertices != NULL) { + memcpy(new_vertices, data->selected_vertices, data->selected_count*sizeof(uint32_t)); + free(data->selected_vertices); + } + + data->selected_regions = new_regions; + data->selected_hexes = new_hexes; + data->selected_vertices = new_vertices; + data->selected_max = size; +} + +int32_t find_selected_hex(EditorData* data, uint32_t region, uint32_t hex) { + for(uint32_t i = 0; i < data->selected_count; i++) { + if(data->selected_regions[i] == region && data->selected_hexes[i] == hex) { + return (int32_t)i; + } + } + return -1; +} + +int32_t find_selected_vertex(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex) { + for(uint32_t i = 0; i < data->selected_count; i++) { + if(data->selected_regions[i] == region + && data->selected_hexes[i] == hex + && data->selected_vertices[i] == vertex) { + return (int32_t)i; + } + } + return -1; +} + +void add_selected(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex) { + if(data->selected_count >= data->selected_max) { + resize_selected(data, data->selected_max == 0 ? 1 : data->selected_max*2); + } + + data->selected_regions[data->selected_count] = region; + data->selected_hexes[data->selected_count] = hex; + data->selected_vertices[data->selected_count] = vertex; + data->selected_count++; +} + +void remove_selected_at(EditorData* data, uint32_t index) { + for(uint32_t i = index+1; i < data->selected_count; i++) { + data->selected_regions[i-1] = data->selected_regions[i]; + data->selected_hexes[i-1] = data->selected_hexes[i]; + data->selected_vertices[i-1] = data->selected_vertices[i]; + } + data->selected_count--; +} + +bool editor_pick(ClientContext* context, double cursor[2], uint32_t* rid, uint32_t* hid, uint32_t* vertex, bool edge_only) { + vec4 start, end; + float distance; + cursor_to_world_ray(&context->render, context->hex.inverse, cursor, start, end); + return ray_world_intersect(&distance, vertex, rid, hid, start, end, edge_only, &context->hex); +} + +void sync_hex_highlights(EditorData* data, ClientContext* context) { + for(uint32_t i = 0; i < MAX_HIGHLIGHTS; i++) { + if(i < data->selected_count) { + GPUHighlight temp = { + .color = {1.0f, 0.85f, 0.05f, 1.0f}, + .region = data->selected_regions[i], + .hex = data->selected_hexes[i], + .offset = SELECTION_HIGHLIGHT_OFFSET, + }; + add_transfers(&temp, context->hex.highlights, sizeof(GPUHighlight)*i, sizeof(GPUHighlight), &context->render); + } else { + uint32_t disabled = 0xFFFFFFFF; + add_transfers(&disabled, context->hex.highlights, sizeof(GPUHighlight)*i + offsetof(GPUHighlight, hex), sizeof(uint32_t), &context->render); + } + } +} + +void sync_vertex_points(EditorData* data, ClientContext* context) { + for(uint32_t i = 0; i < MAX_POINTS; i++) { + if(i < data->selected_count) { + GPUPoint temp = { + .color = {1.0f, 0.85f, 0.05f, 1.0f}, + .region = data->selected_regions[i], + .hex = data->selected_hexes[i], + .vertex = data->selected_vertices[i], + .size = SELECTION_POINT_SIZE, + .offset = SELECTION_POINT_OFFSET, + }; + add_transfers(&temp, context->hex.points, sizeof(GPUPoint)*i, sizeof(GPUPoint), &context->render); + } else { + uint32_t disabled = 0xFFFFFFFF; + add_transfers(&disabled, context->hex.points, sizeof(GPUPoint)*i + offsetof(GPUPoint, hex), sizeof(uint32_t), &context->render); + } + } +} + +void refresh_selection_visuals(EditorData* data, ClientContext* context) { + sync_hex_highlights(data, context); + sync_vertex_points(data, context); +} + void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { (void)binding; (void)mods; @@ -214,6 +297,7 @@ void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int data->mode = MODE_NONE; update_mode_string(context, data); data->selected_count = 0; + refresh_selection_visuals(data, context); unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui); } } @@ -225,6 +309,7 @@ void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* bindin data->mode = MODE_VERTEX; update_mode_string(context, data); data->selected_count = 0; + refresh_selection_visuals(data, context); color_ui(context); } } @@ -236,6 +321,7 @@ void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* bind data->mode = MODE_NEIGHBOR; update_mode_string(context, data); data->selected_count = 0; + refresh_selection_visuals(data, context); color_ui(context); } } @@ -247,6 +333,7 @@ void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding, data->mode = MODE_HEX; update_mode_string(context, data); data->selected_count = 0; + refresh_selection_visuals(data, context); color_ui(context); } } @@ -258,42 +345,49 @@ void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* bindin data->mode = MODE_REGION; update_mode_string(context, data); data->selected_count = 0; + refresh_selection_visuals(data, context); unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui); } } -void editor_startup(ClientContext* context) { - mode_string_ui(context); - // TODO: Remove when region mode is implemented - add_hex_region(context); -} - -void resize_selected(EditorData* data, unsigned int size) { - uint32_t* new_regions = malloc(sizeof(uint32_t)*size); - uint32_t* new_hexes = malloc(sizeof(uint32_t)*size); - uint32_t* new_vertices = malloc(sizeof(uint32_t)*size); - - if(data->selected_regions != NULL) { - memcpy(new_regions, data->selected_regions, data->selected_count); - free(data->selected_regions); - } +void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods) { + EditorData* data = context->app_data; - if(data->selected_hexes != NULL) { - memcpy(new_hexes, data->selected_hexes, data->selected_count); - free(data->selected_hexes); + if(button != GLFW_MOUSE_BUTTON_LEFT || action != GLFW_PRESS) return; + if(data->mode != MODE_VERTEX && data->mode != MODE_HEX) return; + + double cursor[2] = {x, y}; + uint32_t rid, hid, vertex; + // Vertex mode requests edge-only picking so a click always resolves to a + // real, editable vertex instead of frequently landing on the hex center + // (vertex 0) and giving no feedback. + bool hit = editor_pick(context, cursor, &rid, &hid, &vertex, data->mode == MODE_VERTEX); + if(!hit) return; + + uint32_t v = (data->mode == MODE_VERTEX) ? vertex : 0; + int32_t idx = (data->mode == MODE_VERTEX) + ? find_selected_vertex(data, rid, hid, v) + : find_selected_hex(data, rid, hid); + + // Left click adds to the selection; Shift is reserved for camera movement + // (GLFW_KEY_LEFT_SHIFT) so it carries no selection meaning here. Ctrl + // removes. Mode changes remain the only way to reset the selection. + if(mods & GLFW_MOD_CONTROL) { + if(idx != -1) remove_selected_at(data, (uint32_t)idx); + } else if(idx == -1) { + add_selected(data, rid, hid, v); } - if(data->selected_vertices != NULL) { - memcpy(new_vertices, data->selected_vertices, data->selected_count); - } + refresh_selection_visuals(data, context); +} - data->selected_regions = new_regions; - data->selected_hexes = new_hexes; - data->selected_vertices = new_vertices; - data->selected_max = size; +void editor_startup(ClientContext* context) { + mode_string_ui(context); + // TODO: Remove when region mode is implemented + add_hex_region(context); } -int main() { +EditorData* create_editor_data(void) { EditorData* data = malloc(sizeof(EditorData)); memset(data, 0, sizeof(EditorData)); data->selected_count = 0; @@ -325,5 +419,5 @@ int main() { data->mode_keys[MODE_NONE][13] = (ModeKey){GLFW_KEY_W, spin_cam_key, 1, 1}; data->mode_keys[MODE_NONE][14] = (ModeKey){GLFW_KEY_S, spin_cam_key, 1, -1}; - return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, NULL, editor_scroll_callback, NULL); + return data; } diff --git a/client/src/editor_main.c b/client/src/editor_main.c new file mode 100644 index 0000000..e6eb26e --- /dev/null +++ b/client/src/editor_main.c @@ -0,0 +1,6 @@ +#include "editor.h" + +int main() { + EditorData* data = create_editor_data(); + return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, NULL); +} diff --git a/client/src/engine.c b/client/src/engine.c index 7af4019..f09000c 100644 --- a/client/src/engine.c +++ b/client/src/engine.c @@ -2,6 +2,7 @@ #include "ui_lua.h" #include "gpu.h" #include "draw.h" +#include void text_callback(GLFWwindow* window, unsigned int codepoint) { ClientContext* context = (ClientContext*)glfwGetWindowUserPointer(window); @@ -276,8 +277,9 @@ int run_app( } memset(context, 0, sizeof(ClientContext)); - - context->window = init_window(); + + bool visible = getenv("ROLEPLAY_HEADLESS_TEST") == NULL; + context->window = init_window(visible); context->rotation[0] = 3*M_PI/2; context->rotation[1] = M_PI/4; diff --git a/client/src/gpu.c b/client/src/gpu.c index 12424e4..ccdbcc3 100644 --- a/client/src/gpu.c +++ b/client/src/gpu.c @@ -48,13 +48,14 @@ void glfw_error(int error, const char* description) { fprintf(stderr, "GLFW_ERR: 0x%02x - %s\n", error, description); } -GLFWwindow* init_window() { +GLFWwindow* init_window(bool visible) { glfwInit(); glfwSetErrorCallback(glfw_error); glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); + glfwWindowHint(GLFW_VISIBLE, visible ? GLFW_TRUE : GLFW_FALSE); GLFWwindow* window = glfwCreateWindow(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT, "roleplay", 0, 0); glfwSetWindowSizeLimits(window, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT, GLFW_DONT_CARE, GLFW_DONT_CARE); diff --git a/client/src/hex.c b/client/src/hex.c index 97ab7c2..1f594cc 100644 --- a/client/src/hex.c +++ b/client/src/hex.c @@ -933,6 +933,7 @@ bool ray_hex_intersect( vec3 dir, vec3 region_offset, uint32_t hex_index, + bool edge_only, HexRegion* region) { GPUHex* hex = ®ion->data.hexes[hex_index]; @@ -1011,13 +1012,24 @@ bool ray_hex_intersect( float intersect_distance = glm_vec3_dot(v0v2, q) / det; if(intersect_distance < *distance) { *distance = intersect_distance; - float w = 1 - u - v; - if(u >= v && u >= w) { - *vertex = ((triangle + 1) % 6) + 1; - } else if(v >= u && v >= w) { - *vertex = triangle + 1; + if(edge_only) { + // Never report the hex center (vertex 0) - always snap to whichever + // of the triangle's two edge vertices is closer, so vertex-mode + // picking always lands on a real, editable vertex. + if(u >= v) { + *vertex = ((triangle + 1) % 6) + 1; + } else { + *vertex = triangle + 1; + } } else { - *vertex = 0; + float w = 1 - u - v; + if(u >= v && u >= w) { + *vertex = ((triangle + 1) % 6) + 1; + } else if(v >= u && v >= w) { + *vertex = triangle + 1; + } else { + *vertex = 0; + } } } } @@ -1031,6 +1043,7 @@ bool ray_region_intersect( uint32_t* hid, vec3 start, vec3 dir, + bool edge_only, HexRegion* region) { bool intersect = false; float intersect_distance = INFINITY; @@ -1042,7 +1055,7 @@ bool ray_region_intersect( }; for(uint32_t intersect_hid = 0; intersect_hid < REGION_HEX_COUNT; intersect_hid++) { - if(ray_hex_intersect(&intersect_distance, &intersection_vertex, start, dir, region_offset, intersect_hid, region)) { + if(ray_hex_intersect(&intersect_distance, &intersection_vertex, start, dir, region_offset, intersect_hid, edge_only, region)) { if(intersect_distance < *distance) { intersect = true; *hid = intersect_hid; @@ -1062,6 +1075,7 @@ bool ray_world_intersect( uint32_t* hid, vec4 ray_start, vec4 ray_end, + bool edge_only, HexContext* context) { vec3 start; start[0] = ray_start[0]/ray_start[3]; @@ -1095,7 +1109,7 @@ bool ray_world_intersect( continue; } - if(ray_region_intersect(&intersect_distance, &intersection_vertex, &intersect_hid, start, dir, region)) { + if(ray_region_intersect(&intersect_distance, &intersection_vertex, &intersect_hid, start, dir, edge_only, region)) { if(intersect_distance < *distance) { intersect = true; *hid = intersect_hid; diff --git a/client/test/editor.c b/client/test/editor.c new file mode 100644 index 0000000..5edd834 --- /dev/null +++ b/client/test/editor.c @@ -0,0 +1,171 @@ +#include "editor.h" +#include +#include + +static int failures = 0; + +#define CHECK(cond, msg) do { \ + if(!(cond)) { \ + fprintf(stderr, "FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); \ + failures++; \ + } \ +} while(0) + +// Inverse of cursor_to_world_ray (src/hex.c): forward-projects a world point +// through the current proj*view and solves for the pixel coordinate that +// would ray-cast back to it, so tests can compute exactly which (x, y) will +// land a synthetic click on a given hex vertex/center. +void world_to_cursor(ClientContext* context, vec3 world_point, double cursor[2]) { + mat4 view_proj; + glm_mat4_mul(context->hex.data.proj, context->hex.data.view, view_proj); + + vec4 point = {world_point[0], world_point[1], world_point[2], 1.0f}; + vec4 clip; + glm_mat4_mulv(view_proj, point, clip); + + double ndc[2] = {clip[0]/clip[3], clip[1]/clip[3]}; + + cursor[0] = (ndc[0]/2 + 0.5) * context->render.swapchain_extent.width / context->render.window_scale[0]; + cursor[1] = (ndc[1]/2 + 0.5) * context->render.swapchain_extent.height / context->render.window_scale[1]; +} + +// Positions the camera at a fixed, known, non-degenerate angle above hex 0 +// (loaded at the origin by editor_startup) and pushes it through the same +// matrices the real per-frame update would - required up front since a +// one-shot app_frame callback runs before app_main's own camera-update step. +void setup_camera(ClientContext* context) { + context->position[0] = 0; + context->position[1] = 0; + context->position[2] = 0; + // rotation[1] intentionally avoids M_PI/2 (straight down), which is + // parallel to the {0,1,0} up vector and singular for glm_lookat. + context->rotation[0] = 0; + context->rotation[1] = 1.3f; + context->distance = 10; + + update_hex_proj(&context->render, &context->hex); + update_hex_view(context->position, context->rotation, context->distance, &context->render, &context->hex); +} + +void click(ClientContext* context, vec3 world_point, int mods) { + double cursor[2]; + world_to_cursor(context, world_point, cursor); + editor_button_callback(context, (float)cursor[0], (float)cursor[1], GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, mods); +} + +void test_hex_mode_click_adds(ClientContext* context) { + EditorData* data = context->app_data; + clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + + vec3 hex0_center = {0, 0, 0}; + click(context, hex0_center, 0); + CHECK(data->selected_count == 1, "clicking hex 0 should select it"); + CHECK(data->selected_count != 1 || (data->selected_regions[0] == 0 && data->selected_hexes[0] == 0), + "selection should record region 0, hex 0"); + + // hex_starts[0] is the world-space offset of hex index 1 (radius 1, ring + // 0, side 0 in the hex-ring indexing ray_hex_intersect uses). + vec3 hex1_center = {hex_starts[0][0], hex_starts[0][1], hex_starts[0][2]}; + click(context, hex1_center, 0); + CHECK(data->selected_count == 2, "clicking a second hex should add to, not replace, the selection"); +} + +void test_hex_mode_ctrl_click_removes(ClientContext* context) { + EditorData* data = context->app_data; + clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + + vec3 hex0_center = {0, 0, 0}; + vec3 hex1_center = {hex_starts[0][0], hex_starts[0][1], hex_starts[0][2]}; + click(context, hex0_center, 0); + click(context, hex1_center, 0); + CHECK(data->selected_count == 2, "setup: both hexes should be selected"); + + click(context, hex0_center, GLFW_MOD_CONTROL); + CHECK(data->selected_count == 1, "ctrl-click should remove the clicked hex"); + CHECK(data->selected_count != 1 || data->selected_hexes[0] != 0, + "the remaining selection entry should not be the removed hex"); +} + +void test_vertex_mode_edge_only_picking(ClientContext* context) { + EditorData* data = context->app_data; + clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + + // Clicking dead center of the hex would, without edge-only picking, hit + // vertex 0 (the center) and select nothing. + vec3 hex0_center = {0, 0, 0}; + click(context, hex0_center, 0); + CHECK(data->selected_count == 1, "a center click in vertex mode should still select something"); + CHECK(data->selected_count != 1 || (data->selected_vertices[0] >= 1 && data->selected_vertices[0] <= 6), + "vertex mode picking must never resolve to vertex 0"); +} + +void test_vertex_mode_click_adds_and_ctrl_removes(ClientContext* context) { + EditorData* data = context->app_data; + clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + + vec3 v1 = {hex_vertices[0][0], hex_vertices[0][1], hex_vertices[0][2]}; + vec3 v4 = {hex_vertices[3][0], hex_vertices[3][1], hex_vertices[3][2]}; + + click(context, v1, 0); + click(context, v4, 0); + CHECK(data->selected_count == 2, "clicking two different vertices should add both"); + + click(context, v1, GLFW_MOD_CONTROL); + CHECK(data->selected_count == 1, "ctrl-click should remove one vertex from the selection"); +} + +void test_mode_switch_resets_selection(ClientContext* context) { + EditorData* data = context->app_data; + enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + click(context, (vec3){0, 0, 0}, 0); + CHECK(data->selected_count == 1, "setup: a hex should be selected before the mode switch"); + + enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + CHECK(data->selected_count == 0, "switching modes should reset the selection"); +} + +void test_empty_space_click_is_noop(ClientContext* context) { + EditorData* data = context->app_data; + clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + + // A cursor coordinate this far outside the window unprojects to a ray + // angled far off the view axis - guaranteed to miss the small hex region + // clustered near the origin regardless of camera specifics. + editor_button_callback(context, 1e6f, 1e6f, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + CHECK(data->selected_count == 0, "clicking empty space should not select anything"); +} + +void test_frame_callback(ClientContext* context) { + static bool ran = false; + if(ran) return; + ran = true; + + setup_camera(context); + + test_hex_mode_click_adds(context); + test_hex_mode_ctrl_click_removes(context); + test_vertex_mode_edge_only_picking(context); + test_vertex_mode_click_adds_and_ctrl_removes(context); + test_mode_switch_resets_selection(context); + test_empty_space_click_is_noop(context); + + glfwSetWindowShouldClose(context->window, GLFW_TRUE); +} + +int main() { + setenv("ROLEPLAY_HEADLESS_TEST", "1", 1); + + EditorData* data = create_editor_data(); + int result = run_app(data, editor_startup, test_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, NULL); + + if(failures > 0) { + fprintf(stderr, "%d test(s) failed\n", failures); + return 1; + } + return result; +}