/**
 * @file collision.c
 * @brief Collision shapes: construction, defaults, and the extrusion invariant.
 *
 * The invariant is the one worth staring at. A 2D shape handed to a 3D
 * narrowphase has to be thick enough that separating two of them along z is
 * never cheaper than separating them in the plane -- otherwise the narrowphase
 * answers "push it into the screen", the resolver does, and the actor sinks
 * through the floor while every test that only asks "did they collide" passes.
 * test_shape_extrusion_beats_planar_penetration checks the inequality directly
 * rather than checking that the number is 2.
 */

#include <math.h>
#include <string.h>

#include <akerror.h>

#include <akgl/actor.h>
#include <akgl/collision.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/registry.h>

#include "testutil.h"

akerr_ErrorContext *test_shape_box(void)
{
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));

	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_BOX), "a box is not a box");
	// Top-left-and-size in, centre-and-half-extent out.
	TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "half width is %f, expected 8", shape.hx);
	TEST_ASSERT_FEQ(errctx, shape.hy, 16.0f, "half height is %f, expected 16", shape.hy);
	TEST_ASSERT_FEQ(errctx, shape.ox, 16.0f, "centre x is %f, expected 16", shape.ox);
	TEST_ASSERT_FEQ(errctx, shape.oy, 16.0f, "centre y is %f, expected 16", shape.oy);

	// The defaults exist so that giving an actor a hitbox does not silently
	// make it shove every other actor on the map.
	TEST_ASSERT(errctx, (shape.layermask == AKGL_COLLISION_LAYER_ACTOR),
		    "a new shape is not on the actor layer");
	TEST_ASSERT(errctx, (shape.collidemask == AKGL_COLLISION_LAYER_STATIC),
		    "a new shape responds to something other than map geometry");
	TEST_ASSERT(errctx, (shape.flags == AKGL_COLLISION_FLAG_NONE),
		    "a new shape carries flags nobody set");

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(NULL, &body, 0.0f),
			   "a box with no destination");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(&shape, NULL, 0.0f),
			   "a box with no rectangle");

	body.w = 0.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
			   "a box with zero width");
	body.w = -4.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
			   "a box with negative width");
	body.w = 16.0f;
	body.h = 0.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
			   "a box with zero height");
	body.h = 32.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, -1.0f),
			   "a box with negative depth");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_shape_circle_and_capsule(void)
{
    akgl_CollisionShape shape;
    SDL_FRect wide = { .x = 0.0f, .y = 0.0f, .w = 40.0f, .h = 10.0f };
    SDL_FRect tall = { .x = 0.0f, .y = 0.0f, .w = 10.0f, .h = 40.0f };

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_circle(&shape, 16.0f, 16.0f, 8.0f, 0.0f));
	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CIRCLE), "a circle is not a circle");
	TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "circle radius is %f, expected 8", shape.hx);
	TEST_ASSERT_FEQ(errctx, shape.hy, 8.0f, "a circle's hy should mirror its radius, got %f", shape.hy);

	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_circle(&shape, 0.0f, 0.0f, 0.0f, 0.0f),
			   "a circle of zero radius");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_circle(NULL, 0.0f, 0.0f, 8.0f, 0.0f),
			   "a circle with no destination");

	CATCH(errctx, akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f));
	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_X), "an x capsule is not one");
	CATCH(errctx, akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f));
	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_Y), "a y capsule is not one");

	/*
	 * A capsule capped on its *short* axis is a circle described
	 * confusingly. Refusing it is the difference between a caller getting an
	 * error and a caller getting a shape that is not the one they drew.
	 */
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f),
			   "a tall rectangle capped on x");
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f),
			   "a wide rectangle capped on y");
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_BOX, 0.0f),
			   "a capsule on an axis that is not an axis");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The inequality that keeps a 2D game flat, checked rather than assumed.
 *
 * For any two shapes the setters produce, the overlap along z when they are
 * concentric must exceed the largest penetration they can reach in the plane.
 * If it does not, the narrowphase is entitled to answer "separate along z", and
 * a resolver acting on that pushes the actor into the screen -- invisible, and
 * fatal, because the actor is still inside the floor.
 *
 * This tests the property over a spread of sizes rather than testing that the
 * ratio constant equals 2, so that changing the constant to something that does
 * not work fails here instead of failing in a game.
 */
akerr_ErrorContext *test_shape_extrusion_beats_planar_penetration(void)
{
    akgl_CollisionShape a;
    akgl_CollisionShape b;
    SDL_FRect body;
    float32_t sizes[] = { 1.0f, 4.0f, 16.0f, 32.0f, 512.0f };
    float32_t zoverlap = 0.0f;
    float32_t planarmax = 0.0f;
    float32_t worstz = 0.0f;
    float32_t worstplanar = 0.0f;
    float32_t worsta = 0.0f;
    float32_t worstb = 0.0f;
    bool violated = false;
    int i = 0;
    int j = 0;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	/*
	 * The check is recorded into a flag and asserted after the loops rather
	 * than asserted inside them. TEST_ASSERT expands to FAIL_BREAK, which
	 * reports by `break`ing, and a `break` inside a `for` leaves the loop
	 * rather than the ATTEMPT block -- so an assertion written in here
	 * cannot fail the test. AGENTS.md documents that hazard for CATCH; it
	 * applies to every macro in the family, and this test was written with
	 * the bug before it was written without it.
	 */
	for ( i = 0; i < (int)(sizeof(sizes) / sizeof(sizes[0])); i++ ) {
	    for ( j = 0; j < (int)(sizeof(sizes) / sizeof(sizes[0])); j++ ) {
		body.x = 0.0f;
		body.y = 0.0f;
		body.w = sizes[i];
		body.h = sizes[i];
		if ( akgl_collision_shape_box(&a, &body, 0.0f) != NULL ) {
		    violated = true;
		    break;
		}
		body.w = sizes[j];
		body.h = sizes[j];
		if ( akgl_collision_shape_box(&b, &body, 0.0f) != NULL ) {
		    violated = true;
		    break;
		}

		// Concentric is the worst case: every axis is fully overlapped.
		zoverlap = 2.0f * ((a.hz < b.hz) ? a.hz : b.hz);
		planarmax = 2.0f * ((a.hx < b.hx) ? a.hx : b.hx);
		if ( zoverlap <= planarmax ) {
		    violated = true;
		    worstz = zoverlap;
		    worstplanar = planarmax;
		    worsta = sizes[i];
		    worstb = sizes[j];
		}
	    }
	}

	TEST_ASSERT(errctx, (violated == false),
		    "a %fx%f box against a %fx%f one overlaps %f on z and up to %f in the "
		    "plane; z is the cheaper separation, so the narrowphase will answer "
		    "\"push it into the screen\" and the actor will sink through the floor",
		    worsta, worsta, worstb, worstb, worstz, worstplanar);

	// A depth given by hand is taken at face value. A caller who does that
	// has opted out of the guarantee above, which is why the setters choose
	// for you unless you say otherwise.
	body.w = 16.0f;
	body.h = 16.0f;
	CATCH(errctx, akgl_collision_shape_box(&a, &body, 3.0f));
	TEST_ASSERT_FEQ(errctx, a.hz, 3.0f, "an explicit depth of 3 became %f", a.hz);
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_shape_bounds(void)
{
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 8.0f, .y = 4.0f, .w = 16.0f, .h = 32.0f };
    SDL_FRect bounds;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));

	// At the origin the bounds are the rectangle it was built from. That
	// round trip is the whole contract, and it is easy to get wrong by half
	// an extent in either direction.
	CATCH(errctx, akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, &bounds));
	TEST_ASSERT_FEQ(errctx, bounds.x, 8.0f, "bounds x is %f, expected 8", bounds.x);
	TEST_ASSERT_FEQ(errctx, bounds.y, 4.0f, "bounds y is %f, expected 4", bounds.y);
	TEST_ASSERT_FEQ(errctx, bounds.w, 16.0f, "bounds w is %f, expected 16", bounds.w);
	TEST_ASSERT_FEQ(errctx, bounds.h, 32.0f, "bounds h is %f, expected 32", bounds.h);

	// And they translate with the owner rather than staying put.
	CATCH(errctx, akgl_collision_shape_bounds(&shape, 100.0f, -50.0f, &bounds));
	TEST_ASSERT_FEQ(errctx, bounds.x, 108.0f, "bounds x is %f, expected 108", bounds.x);
	TEST_ASSERT_FEQ(errctx, bounds.y, -46.0f, "bounds y is %f, expected -46", bounds.y);

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_bounds(NULL, 0.0f, 0.0f, &bounds),
			   "bounds of no shape");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, NULL),
			   "bounds with no destination");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The mask test, and the asymmetry that is the reason it exists.
 */
akerr_ErrorContext *test_shape_interacts(void)
{
    akgl_CollisionShape player;
    akgl_CollisionShape npc;
    akgl_CollisionShape none;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    bool interacts = false;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&player, &body, 0.0f));
	CATCH(errctx, akgl_collision_shape_box(&npc, &body, 0.0f));
	memset(&none, 0x00, sizeof(akgl_CollisionShape));

	// Out of the box, two actors ignore each other. This is the default that
	// keeps a town full of NPCs from shoving itself apart.
	CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
	TEST_ASSERT(errctx, (interacts == false),
		    "two default shapes collide with each other; the default masks are wrong");

	// The player opts in to blocking against actors. The NPC does not opt in
	// to blocking against the player, and must not be dragged along by it --
	// that asymmetry is the entire point of two masks instead of one.
	player.collidemask |= AKGL_COLLISION_LAYER_ACTOR;
	CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
	TEST_ASSERT(errctx, (interacts == true), "the player does not respond to an actor it opted in to");
	CATCH(errctx, akgl_collision_shape_interacts(&npc, &player, &interacts));
	TEST_ASSERT(errctx, (interacts == false),
		    "the NPC responded to the player because the player responded to it");

	// A shape with no kind takes no part, in either direction.
	CATCH(errctx, akgl_collision_shape_interacts(&player, &none, &interacts));
	TEST_ASSERT(errctx, (interacts == false), "a shape of no kind was collided with");
	CATCH(errctx, akgl_collision_shape_interacts(&none, &player, &interacts));
	TEST_ASSERT(errctx, (interacts == false), "a shape of no kind collided with something");

	// And neither does a disabled one, which is the cheap way to switch an
	// actor out without losing its shape.
	npc.layermask = AKGL_COLLISION_LAYER_ACTOR;
	npc.flags |= AKGL_COLLISION_FLAG_DISABLED;
	CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
	TEST_ASSERT(errctx, (interacts == false), "a disabled shape was collided with");

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_interacts(NULL, &npc, &interacts),
			   "interacts with no self");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_interacts(&player, NULL, &interacts),
			   "interacts with no other");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_interacts(&player, &npc, NULL),
			   "interacts with no destination");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The pool convention, and the window it deliberately leaves open.
 */
akerr_ErrorContext *test_proxy_pool(void)
{
    akgl_CollisionProxy *first = NULL;
    akgl_CollisionProxy *second = NULL;
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    akerr_ErrorContext *inner = NULL;
    int claimed = 0;
    int i = 0;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_heap_init());
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));

	/*
	 * Acquiring does not claim the slot, and that is the convention rather
	 * than an oversight: an acquire abandoned before initialization leaks
	 * nothing, because the slot still reads as free. The cost is this window,
	 * where two acquires in a row return the same pointer -- which is why the
	 * library writes the acquire and the initialize adjacent.
	 */
	CATCH(errctx, akgl_heap_next_collision_proxy(&first));
	TEST_ASSERT(errctx, (first->refcount == 0),
		    "acquiring a proxy claimed the slot; that is not this pool's convention");
	CATCH(errctx, akgl_heap_next_collision_proxy(&second));
	TEST_ASSERT(errctx, (first == second),
		    "two acquires with no initialize between them returned different slots");

	// Initializing is what closes it.
	CATCH(errctx, akgl_collision_proxy_initialize(first, NULL, &shape, 10.0f, 20.0f, 0.0f));
	TEST_ASSERT(errctx, (first->refcount == 1), "initializing did not take the reference");
	CATCH(errctx, akgl_heap_next_collision_proxy(&second));
	TEST_ASSERT(errctx, (first != second), "an initialized slot was handed out again");

	// The proxy carries a copy of the shape, not a pointer to it, so a later
	// edit to the caller's shape cannot change what the broad phase indexed.
	shape.hx = 999.0f;
	TEST_ASSERT_FEQ(errctx, first->shape.hx, 8.0f,
			"the proxy tracked an edit to the caller's shape; it holds %f", first->shape.hx);
	// Body {0,0,16,16} centres at (8,8) with half-extents 8, so at owner x=10
	// the bounds start at 10 and are 16 wide.
	TEST_ASSERT_FEQ(errctx, first->bounds.x, 10.0f,
			"proxy bounds x is %f, expected 10", first->bounds.x);
	TEST_ASSERT_FEQ(errctx, first->bounds.y, 20.0f,
			"proxy bounds y is %f, expected 20", first->bounds.y);
	TEST_ASSERT_FEQ(errctx, first->bounds.w, 16.0f,
			"proxy bounds w is %f, expected 16", first->bounds.w);

	CATCH(errctx, akgl_heap_release_collision_proxy(first));
	TEST_ASSERT(errctx, (first->refcount == 0), "releasing did not free the slot");

	// Exhaustion is an ordinary reported error, not a crash and not a silent
	// hand-out of a slot somebody else is using.
	CATCH(errctx, akgl_heap_init());
	for ( i = 0; i < (AKGL_MAX_HEAP_COLLISION_PROXY + 4); i++ ) {
	    inner = akgl_heap_next_collision_proxy(&first);
	    if ( inner != NULL ) {
		break;
	    }
	    inner = akgl_collision_proxy_initialize(first, NULL, &shape, 0.0f, 0.0f, 0.0f);
	    if ( inner != NULL ) {
		break;
	    }
	    claimed += 1;
	}
	TEST_ASSERT(errctx, (inner != NULL), "the proxy pool never ran out");
	TEST_EXPECT_STATUS(errctx, AKGL_ERR_HEAP, inner, "exhausting the proxy pool");
	inner = NULL;
	TEST_ASSERT(errctx, (claimed == AKGL_MAX_HEAP_COLLISION_PROXY),
		    "the pool handed out %d of %d slots before refusing",
		    claimed, AKGL_MAX_HEAP_COLLISION_PROXY);

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_heap_next_collision_proxy(NULL),
			   "acquiring into no destination");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_heap_release_collision_proxy(NULL),
			   "releasing no proxy");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_proxy_initialize(NULL, NULL, &shape, 0.0f, 0.0f, 0.0f),
			   "initializing no proxy");
    } CLEANUP {
	if ( inner != NULL ) {
	    inner->handled = true;
	    inner = akerr_release_error(inner);
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief A proxy must not outlive the actor it points at.
 *
 * akgl_heap_release_actor zeroes the actor slot. A proxy left registered would
 * hold a borrowed `owner` into a slot that now reads as free, and the next
 * contact against it would be a collision with nothing -- so the release has to
 * reach through and take the proxy with it.
 */
akerr_ErrorContext *test_proxy_dies_with_its_actor(void)
{
    akgl_Actor *actor = NULL;
    akgl_CollisionProxy *proxy = NULL;
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_heap_init());
	CATCH(errctx, akgl_registry_init_actor());
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));

	CATCH(errctx, akgl_heap_next_actor(&actor));
	CATCH(errctx, akgl_actor_initialize(actor, "collider"));
	CATCH(errctx, akgl_heap_next_collision_proxy(&proxy));
	CATCH(errctx, akgl_collision_proxy_initialize(proxy, actor, &shape, 0.0f, 0.0f, 0.0f));
	actor->proxy = proxy;

	TEST_ASSERT(errctx, (proxy->refcount == 1), "the proxy was not claimed");
	CATCH(errctx, akgl_heap_release_actor(actor));
	TEST_ASSERT(errctx, (proxy->refcount == 0),
		    "releasing the actor left its proxy registered, pointing at a freed slot");
	TEST_ASSERT(errctx, (proxy->owner == NULL), "the released proxy still names an owner");

	// An actor with no proxy releases without complaint, which is every
	// actor a game written before this existed.
	CATCH(errctx, akgl_heap_next_actor(&actor));
	CATCH(errctx, akgl_actor_initialize(actor, "plain"));
	CATCH(errctx, akgl_heap_release_actor(actor));
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/** @brief Build a stack proxy around a shape at a position, for a test. */
static akerr_ErrorContext *at(akgl_CollisionProxy *dest, akgl_CollisionShape *shape, float32_t x, float32_t y)
{
    PREPARE_ERROR(errctx);
    PASS(errctx, akgl_collision_proxy_initialize(dest, NULL, shape, x, y, 0.0f));
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The normal points the way a resolver needs it to, and the depth undoes the overlap.
 *
 * The sign convention is the thing every caller would otherwise have to
 * rediscover: the normal points **out of the second shape and toward the
 * first**, so `a` moving along it by `depth` separates them. Getting it
 * backwards does not fail to compile and does not fail a "do these collide"
 * test -- it drags actors *into* walls, which is why it is asserted directly.
 */
akerr_ErrorContext *test_narrowphase_box_normal_and_depth(void)
{
    akgl_CollisionShape shape;
    akgl_CollisionProxy a;
    akgl_CollisionProxy b;
    akgl_Contact contact;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    bool hit = false;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));

	// b is 4 to the right of a and overlapping by 12.
	CATCH(errctx, at(&a, &shape, 0.0f, 0.0f));
	CATCH(errctx, at(&b, &shape, 4.0f, 0.0f));
	CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
	TEST_ASSERT(errctx, (hit == true), "two overlapping boxes did not collide");
	TEST_ASSERT_FEQ(errctx, contact.nx, -1.0f,
			"the normal is %f on x; a is left of b so it must leave to the left",
			contact.nx);
	TEST_ASSERT_FEQ(errctx, contact.ny, 0.0f, "a purely horizontal overlap produced ny %f", contact.ny);
	TEST_ASSERT_FEQ(errctx, contact.depth, 12.0f, "depth is %f, expected 12", contact.depth);

	// Mirrored: the answer must mirror with it.
	CATCH(errctx, at(&b, &shape, -4.0f, 0.0f));
	CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
	TEST_ASSERT(errctx, (hit == true), "the mirrored pair did not collide");
	TEST_ASSERT_FEQ(errctx, contact.nx, 1.0f, "the mirrored normal is %f, expected 1", contact.nx);

	// Standing on a floor: the smallest overlap is vertical, so the normal is
	// vertical, and it is exactly vertical rather than nearly so.
	CATCH(errctx, at(&b, &shape, 0.0f, 14.0f));
	CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
	TEST_ASSERT(errctx, (hit == true), "an actor resting on a box did not collide");
	TEST_ASSERT_FEQ(errctx, contact.ny, -1.0f, "resting normal is %f on y, expected -1", contact.ny);
	TEST_ASSERT_FEQ(errctx, contact.nx, 0.0f,
			"resting normal has %f on x; a converged normal creeps along a floor",
			contact.nx);
	TEST_ASSERT_FEQ(errctx, contact.depth, 2.0f, "resting depth is %f, expected 2", contact.depth);

	// Separated, and touching exactly, are both "no push".
	CATCH(errctx, at(&b, &shape, 100.0f, 0.0f));
	CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
	TEST_ASSERT(errctx, (hit == false), "boxes 100 apart collided");

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_test(NULL, &b, 0, &contact, &hit), "a test with no first proxy");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_test(&a, &b, 0, NULL, &hit), "a test with no contact");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_test(&a, &b, 0, &contact, NULL), "a test with no hit flag");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief A shape whose depth was set badly must still not resolve along z.
 *
 * This is the failure the planar flag exists for, and it is invisible without
 * it: the narrowphase reports a contact, the resolver pushes the actor into the
 * screen, the actor does not move on screen, and it stays inside the floor. The
 * shape here is deliberately built with a depth that defeats the extrusion
 * invariant, which is the only way a caller can reach the case.
 */
akerr_ErrorContext *test_narrowphase_planar_guard(void)
{
    akgl_CollisionShape thin;
    akgl_CollisionShape thincircle;
    akgl_CollisionProxy a;
    akgl_CollisionProxy b;
    akgl_Contact contact;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    bool hit = false;
    float32_t len = 0.0f;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	// Depth 0.5 against half-extents of 8: z is now by far the cheapest axis.
	CATCH(errctx, akgl_collision_shape_box(&thin, &body, 0.5f));
	CATCH(errctx, at(&a, &thin, 0.0f, 0.0f));
	CATCH(errctx, at(&b, &thin, 2.0f, 0.0f));

	// Without the flag the narrowphase is free to answer along z, and does.
	CATCH(errctx, akgl_collision_test(&a, &b, 0, &contact, &hit));
	TEST_ASSERT(errctx, (hit == true), "two thin overlapping boxes did not collide");
	// Either sign is correct -- the pair is concentric on z, so both
	// directions separate it equally. That z was chosen at all is the point.
	TEST_ASSERT_FEQ(errctx, fabsf(contact.nz), 1.0f,
			"a badly extruded pair resolved along %f on z; if this is not +/-1 the "
			"fixture no longer reproduces the case the guard exists for", contact.nz);

	// With it, the answer is planar and still a unit vector.
	CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
	TEST_ASSERT(errctx, (hit == true), "the guarded test lost the collision");
	TEST_ASSERT_FEQ(errctx, contact.nz, 0.0f, "the guard left %f on z", contact.nz);
	len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
	TEST_ASSERT_FEQ(errctx, len, 1.0f, "the flattened normal has length %f, expected 1", len);
	TEST_ASSERT_FEQ(errctx, contact.nx, -1.0f, "the flattened normal is %f on x, expected -1", contact.nx);

	/*
	 * Exactly concentric. There is no planar direction to separate along, and
	 * a zero-length normal would be a wall that moves nothing -- so the guard
	 * has to invent one rather than pass the degenerate answer through. Level
	 * authors put things on top of each other constantly.
	 */
	CATCH(errctx, at(&b, &thin, 0.0f, 0.0f));
	CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
	TEST_ASSERT(errctx, (hit == true), "two concentric shapes did not collide");
	len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
	TEST_ASSERT_FEQ(errctx, len, 1.0f,
			"concentric shapes produced a normal of length %f; a zero normal is a "
			"wall that does not stop anything", len);
	TEST_ASSERT(errctx, (contact.depth > 0.0f), "concentric shapes produced depth %f", contact.depth);

	/*
	 * And again through the iterative solver, which is a different branch
	 * with its own copy of the guard. Circles cannot take the box fast path,
	 * so this is the only way to reach it -- the box version above proves
	 * nothing about this code.
	 */
	CATCH(errctx, akgl_collision_shape_circle(&thincircle, 0.0f, 0.0f, 8.0f, 0.5f));
	CATCH(errctx, at(&a, &thincircle, 0.0f, 0.0f));
	CATCH(errctx, at(&b, &thincircle, 2.0f, 0.0f));

	/*
	 * Unlike the closed-form box path, the iterative solver is seeded from
	 * the line between the two centres, so for a planar offset it converges
	 * to a planar answer and picks z only rarely -- measured, not assumed:
	 * this pair comes back with nz of about 0 even unguarded. The guard on
	 * this branch is therefore a net rather than a routine correction, and
	 * what is asserted below is that it produces a well-formed planar normal,
	 * not that it rescues one. The box path above is where the guard earns
	 * its place, and that is the assertion that fails if it is removed.
	 */
	CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
	TEST_ASSERT(errctx, (hit == true), "the guarded circle test lost the collision");
	TEST_ASSERT_FEQ(errctx, contact.nz, 0.0f,
			"the iterative solver's guard left %f on z", contact.nz);
	len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
	TEST_ASSERT_FEQ(errctx, len, 1.0f,
			"the flattened circle normal has length %f, expected 1", len);
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The box fast path and the general solver must agree.
 *
 * Two implementations of one answer are two chances to be wrong, and the fast
 * path exists only because it is cheaper and exact. Driving the same
 * arrangements through both -- by asking a box pair, then asking the same
 * geometry as capsules, which cannot take the fast path -- is what keeps the
 * shortcut honest. The agreement is on the *decision* and the *axis*; the
 * iterative solver's depth converges rather than being exact, so it is compared
 * with tolerance.
 */
akerr_ErrorContext *test_narrowphase_fast_path_agrees(void)
{
    akgl_CollisionShape box;
    akgl_CollisionShape circle;
    akgl_CollisionProxy a;
    akgl_CollisionProxy b;
    akgl_Contact boxcontact;
    akgl_Contact mprcontact;
    SDL_FRect body = { .x = -8.0f, .y = -8.0f, .w = 16.0f, .h = 16.0f };
    float32_t offsets[] = { 0.0f, 4.0f, 12.0f, 15.9f, 16.0f, 24.0f };
    bool boxhit = false;
    bool mprhit = false;
    bool disagreed = false;
    float32_t worst = 0.0f;
    int i = 0;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&box, &body, 0.0f));
	// A circle inscribed in the same box: it cannot take the fast path.
	CATCH(errctx, akgl_collision_shape_circle(&circle, 0.0f, 0.0f, 8.0f, 0.0f));

	/*
	 * Recorded and asserted after the loop, not inside it. TEST_ASSERT
	 * reports by breaking, and a break inside a `for` leaves the loop rather
	 * than the ATTEMPT block -- an assertion written in here cannot fail.
	 */
	for ( i = 0; i < (int)(sizeof(offsets) / sizeof(offsets[0])); i++ ) {
	    if ( at(&a, &box, 0.0f, 0.0f) != NULL ) { disagreed = true; break; }
	    if ( at(&b, &box, offsets[i], 0.0f) != NULL ) { disagreed = true; break; }
	    if ( akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &boxcontact, &boxhit) != NULL ) {
		disagreed = true;
		break;
	    }

	    if ( at(&a, &circle, 0.0f, 0.0f) != NULL ) { disagreed = true; break; }
	    if ( at(&b, &circle, offsets[i], 0.0f) != NULL ) { disagreed = true; break; }
	    if ( akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &mprcontact, &mprhit) != NULL ) {
		disagreed = true;
		break;
	    }

	    // Two circles of radius 8 and two boxes of half-extent 8 overlap on
	    // this axis over exactly the same range, so the decision must match.
	    if ( boxhit != mprhit ) {
		disagreed = true;
		worst = offsets[i];
		break;
	    }
	    if ( (boxhit == true) && (mprhit == true) ) {
		if ( (boxcontact.nx * mprcontact.nx) < 0.0f ) {
		    disagreed = true;
		    worst = offsets[i];
		    break;
		}
	    }
	}

	TEST_ASSERT(errctx, (disagreed == false),
		    "the box fast path and the iterative solver disagree at an offset of %f; "
		    "two implementations of one answer are two chances to be wrong", worst);
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/** @brief Build a contact by hand, so a response can be tested without a step. */
static void contact_at(akgl_Contact *dest, float32_t nx, float32_t ny, float32_t depth, bool sensor)
{
    memset(dest, 0x00, sizeof(akgl_Contact));
    dest->tilex = -1;
    dest->tiley = -1;
    dest->tilelayer = -1;
    dest->nx = nx;
    dest->ny = ny;
    dest->depth = depth;
    dest->sensor = sensor;
}

/**
 * @brief The blocking response, and the four ways it is easy to write wrongly.
 */
akerr_ErrorContext *test_collide_block(void)
{
    akgl_Actor actor;
    akgl_Contact contact;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	// Landing on a floor: the normal points up, out of the surface.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	actor.y = 100.0f;
	actor.ey = 300.0f;      // falling
	actor.tx = 90.0f;       // and running right
	contact_at(&contact, 0.0f, -1.0f, 4.0f, false);

	CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
	TEST_ASSERT_FEQ(errctx, actor.y, 96.0f,
			"the actor was not pushed out of the floor; y is %f, expected 96", actor.y);
	TEST_ASSERT_FEQ(errctx, actor.ey, 0.0f,
			"the fall was not stopped; ey is %f", actor.ey);

	/*
	 * The run survives. Removing the whole thrust vector rather than its
	 * component into the surface is what makes a character scraping a
	 * ceiling stop dead, and it is the bug the sidescroller example has to
	 * work around by hand.
	 */
	TEST_ASSERT_FEQ(errctx, actor.tx, 90.0f,
			"landing on a floor cost the actor its horizontal run; tx is %f", actor.tx);

	// v is recomputed from e + t, because that is what simulate reads.
	TEST_ASSERT_FEQ(errctx, actor.vx, 90.0f, "vx is %f, expected e + t", actor.vx);
	TEST_ASSERT_FEQ(errctx, actor.vy, 0.0f, "vy is %f, expected e + t", actor.vy);

	/*
	 * A separating contact changes nothing but the position. An actor
	 * already moving out of a surface that has its velocity zeroed is an
	 * actor stuck to a wall it is walking away from.
	 */
	memset(&actor, 0x00, sizeof(akgl_Actor));
	actor.ey = -200.0f;     // rising
	contact_at(&contact, 0.0f, -1.0f, 2.0f, false);
	CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
	TEST_ASSERT_FEQ(errctx, actor.ey, -200.0f,
			"a separating velocity was removed; ey is %f, expected -200. An actor "
			"moving away from a surface must not be stopped by it", actor.ey);

	// The thrust into a wall goes, and the motion along it stays.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	actor.tx = 120.0f;      // running right
	actor.ty = 40.0f;       // and drifting down
	contact_at(&contact, -1.0f, 0.0f, 3.0f, false);   // wall on the right
	CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
	TEST_ASSERT_FEQ(errctx, actor.tx, 0.0f, "the run into the wall survived; tx is %f", actor.tx);
	TEST_ASSERT_FEQ(errctx, actor.ty, 40.0f,
			"hitting a wall cost the actor its vertical motion; ty is %f. Sliding "
			"along a wall is the difference between a wall and glue", actor.ty);

	// A sensor reports and never pushes.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	actor.x = 50.0f;
	actor.ey = 300.0f;
	contact_at(&contact, 0.0f, -1.0f, 8.0f, true);
	CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
	TEST_ASSERT_FEQ(errctx, actor.x, 50.0f, "a sensor moved the actor; x is %f", actor.x);
	TEST_ASSERT_FEQ(errctx, actor.ey, 300.0f, "a sensor stopped the actor; ey is %f", actor.ey);

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_actor_collide_block(NULL, &contact),
			   "a response with no actor");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_actor_collide_block(&actor, NULL),
			   "a response with no contact");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/** @brief Every actor gets the default, and a game can replace it. */
akerr_ErrorContext *test_collidefunc_is_installed(void)
{
    akgl_Actor *actor = NULL;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_heap_init());
	CATCH(errctx, akgl_registry_init_actor());
	CATCH(errctx, akgl_heap_next_actor(&actor));
	CATCH(errctx, akgl_actor_initialize(actor, "hooked"));

	TEST_ASSERT(errctx, (actor->collidefunc == &akgl_actor_collide_block),
		    "akgl_actor_initialize did not install the default collision response");

	// The other six are still there. A seventh hook that displaced one of
	// them would be a silent regression in something unrelated.
	TEST_ASSERT(errctx, (actor->updatefunc != NULL), "updatefunc was lost");
	TEST_ASSERT(errctx, (actor->renderfunc != NULL), "renderfunc was lost");
	TEST_ASSERT(errctx, (actor->facefunc != NULL), "facefunc was lost");
	TEST_ASSERT(errctx, (actor->movementlogicfunc != NULL), "movementlogicfunc was lost");
	TEST_ASSERT(errctx, (actor->changeframefunc != NULL), "changeframefunc was lost");
	TEST_ASSERT(errctx, (actor->addchild != NULL), "addchild was lost");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

int main(void)
{
    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	CATCH(errctx, test_shape_box());
	CATCH(errctx, test_shape_circle_and_capsule());
	CATCH(errctx, test_shape_extrusion_beats_planar_penetration());
	CATCH(errctx, test_shape_bounds());
	CATCH(errctx, test_shape_interacts());
	CATCH(errctx, test_proxy_pool());
	CATCH(errctx, test_proxy_dies_with_its_actor());
	CATCH(errctx, test_narrowphase_box_normal_and_depth());
	CATCH(errctx, test_narrowphase_planar_guard());
	CATCH(errctx, test_narrowphase_fast_path_agrees());
	CATCH(errctx, test_collide_block());
	CATCH(errctx, test_collidefunc_is_installed());
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH_NORETURN(errctx);

    return 0;
}
