/**
 * @file physics_sim.c
 * @brief Whole-motion simulations of the arcade backend, in the shapes games actually use.
 *
 * `tests/physics.c` checks the pieces: that gravity accelerates, that drag
 * sheds, that the cap caps. Every one of those passes while the motion a player
 * would feel is still wrong, because feel lives in how the pieces compose over
 * a few hundred frames.
 *
 * Three shapes, chosen because they are the three a 2D game almost always
 * needs:
 *
 *  1. **Side-on jump and fall.** Gravity down, an upward impulse, land. The
 *     question is whether the arc is an arc.
 *  2. **Top-down walk.** No gravity, four-way input, stop on release. The
 *     question is whether stopping and diagonals behave.
 *  3. **Sudden reversal.** Full speed one way, then the other way. The question
 *     is whether the turn takes a believable amount of time.
 *
 * Every case runs at a fixed 60 Hz step and records the trajectory, so a
 * failure says *what the motion did*, not merely that a number was wrong.
 *
 * @note akgl_physics_simulate reads SDL_GetTicksNS() itself, so it cannot be
 *       handed a dt. It can be *bounded* to one, though: sim_step sets
 *       `max_timestep` to the step it wants and `gravity_time` to zero, so the
 *       measured interval is always longer than the bound and every step is
 *       exactly `max_timestep`. That is deterministic under any load, which
 *       matters -- the first version placed `gravity_time` at `now - dt` and
 *       let the real clock supply the step, and a machine busy enough to
 *       deschedule the process between that store and the SDL_GetTicksNS()
 *       inside simulate produced a longer step and a red suite. It failed
 *       exactly once, under a parallel ctest, which is the worst way to find
 *       out. That the engine cannot be stepped exactly is itself a finding;
 *       see issues #29 through #32.
 */

#include <SDL3/SDL.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>

#include <akgl/error.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/collision.h>
#include <akgl/physics.h>
#include <akgl/tilemap.h>
#include <akgl/registry.h>
#include <akgl/sprite.h>

#include "testutil.h"

/** @brief One simulation step, in seconds. 60 Hz, the rate the budgets assume. */
#define SIM_DT          (1.0f / 60.0f)
/** @brief How many steps a case may run before it is called a hang. */
#define SIM_MAX_STEPS   1200
/**
 * @brief How long a host is assumed to spend loading before its first frame.
 *
 * Short enough to keep the suite quick, long enough to be far outside a frame.
 * A real level load is longer, which only makes the effect larger.
 */
#define SIM_LOAD_TIME_MS 250

/** @brief The backend under test. Reconfigured per case rather than shared. */
static akgl_PhysicsBackend sim_physics;
/** @brief The character the simulated actor borrows its speed and acceleration from. */
static akgl_Character sim_character;

/**
 * @brief Advance the simulation by exactly one step of @p dt seconds.
 *
 * akgl_physics_simulate measures dt as `SDL_GetTicksNS() - self->gravity_time`
 * and then bounds it by `max_timestep`. Zeroing `gravity_time` makes the
 * measured interval the whole uptime of the process, which is always past the
 * bound, so the step the simulation takes is `max_timestep` and nothing else.
 * The scheduler cannot get in the way of that, which the earlier `now - dt`
 * version could not say.
 */
static akerr_ErrorContext *sim_step(float32_t dt)
{
    PREPARE_ERROR(errctx);

    sim_physics.max_timestep = (float64_t)dt;
    sim_physics.gravity_time = 0;
    PASS(errctx, sim_physics.simulate(&sim_physics, NULL));
    SUCCEED_RETURN(errctx);
}

/** @brief Build a fresh actor bound to a character with the given accel and top speed. */
static akerr_ErrorContext *sim_actor(akgl_Actor **dest, char *name,
				     float32_t accel, float32_t topspeed)
{
    PREPARE_ERROR(errctx);

    memset(&sim_character, 0x00, sizeof(akgl_Character));
    sim_character.ax = accel;
    sim_character.ay = accel;
    sim_character.sx = topspeed;
    sim_character.sy = topspeed;

    PASS(errctx, akgl_heap_next_actor(dest));
    PASS(errctx, akgl_actor_initialize(*dest, name));
    (*dest)->basechar = &sim_character;
    (*dest)->x = 0.0f;
    (*dest)->y = 0.0f;
    SUCCEED_RETURN(errctx);
}

/** @brief Zero the backend and point it at the arcade implementation. */
static void sim_arcade(float64_t gravity_y, float64_t drag_x, float64_t drag_y)
{
    memset(&sim_physics, 0x00, sizeof(akgl_PhysicsBackend));
    sim_physics.simulate = &akgl_physics_simulate;
    sim_physics.gravity = &akgl_physics_arcade_gravity;
    sim_physics.collide = &akgl_physics_arcade_collide;
    sim_physics.move = &akgl_physics_arcade_move;
    sim_physics.gravity_y = gravity_y;
    sim_physics.drag_x = drag_x;
    sim_physics.drag_y = drag_y;
}

/** @brief A synthetic key-up, for driving the release handlers the way input does. */
static void sim_keyevent(SDL_Event *event, bool pressed)
{
    memset(event, 0x00, sizeof(SDL_Event));
    event->type = pressed ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP;
}

/**
 * @brief Mario-esque: gravity, an upward impulse, and a landing.
 *
 * A side-on platformer jump is the arc of one impulse against constant
 * acceleration. `libakgl` has no jump entry point, so a game does what this
 * does: drive the environmental accumulator directly and let
 * akgl_physics_arcade_gravity pull it back.
 *
 * The assertions are the ones that hold for *any* believable jump, not for one
 * particular tuning: it must go up, it must come back, the apex must be in the
 * middle rather than at an end, and rise and fall must take about the same time
 * under constant gravity.
 */
akerr_ErrorContext *test_sim_jump_and_fall(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *actor = NULL;
    float32_t apex_y = 0.0f;
    int apex_step = 0;
    int landed_step = 0;
    int i = 0;

    ATTEMPT {
	// 1600 px/s^2 down and a 600 px/s launch: about a 0.75 s hop, which is
	// roughly what a platformer of this era feels like.
	sim_arcade(1600.0, 0.0, 0.0);
	CATCH(errctx, sim_actor(&actor, "jumper", 900.0f, 220.0f));

	// The impulse. y grows downward, so up is negative.
	actor->ey = -600.0f;

	for ( i = 0; i < SIM_MAX_STEPS; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	    if ( actor->y < apex_y ) {
		apex_y = actor->y;
		apex_step = i;
	    }
	    // Back to the ground it started on.
	    if ( (i > 0) && (actor->y >= 0.0f) ) {
		landed_step = i;
		break;
	    }
	}

	TEST_ASSERT(errctx, landed_step > 0,
		    "the jumper never came back down in %d steps (y is %f, vy %f)",
		    SIM_MAX_STEPS, actor->y, actor->vy);
	TEST_ASSERT(errctx, apex_y < -1.0f,
		    "the jump did not leave the ground: apex was y=%f", apex_y);

	// A constant-gravity arc is symmetric: the apex sits at the midpoint of
	// the airtime, within a step or two of rounding.
	TEST_ASSERT(errctx,
		    (apex_step > ((landed_step / 2) - 3)) && (apex_step < ((landed_step / 2) + 3)),
		    "apex at step %d of %d airborne steps; a constant-gravity arc peaks at the middle",
		    apex_step, landed_step);

	printf("  jump: apex %.1f px at step %d, landed at step %d (%.2f s airborne)\n",
	       -apex_y, apex_step, landed_step, (float)landed_step * SIM_DT);
    } CLEANUP {
	if ( actor != NULL ) {
	    IGNORE(akgl_heap_release_actor(actor));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Mario-esque, part two: steering in mid-air must not cancel the fall.
 *
 * Every platformer lets you steer while airborne, which means pressing and
 * releasing a horizontal key during a jump. Releasing must not disturb the
 * vertical arc: horizontal input and gravity are different axes.
 */
akerr_ErrorContext *test_sim_air_control_preserves_the_arc(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *plain = NULL;
    akgl_Actor *steered = NULL;
    SDL_Event event;
    float32_t plain_y = 0.0f;
    float32_t steered_y = 0.0f;
    int i = 0;

    ATTEMPT {
	sim_arcade(1600.0, 0.0, 0.0);
	CATCH(errctx, sim_actor(&plain, "plain", 900.0f, 220.0f));
	// sim_actor rewrites the shared character; both actors share it, which
	// is what a game does with two of the same enemy.
	CATCH(errctx, sim_actor(&steered, "steered", 900.0f, 220.0f));
	plain->basechar = &sim_character;

	plain->ey = -600.0f;
	steered->ey = -600.0f;

	// Both jump. The steered one taps right for ten frames, then lets go,
	// while both are still in the air.
	sim_keyevent(&event, true);
	CATCH(errctx, akgl_actor_cmhf_right_on(steered, &event));
	for ( i = 0; i < 10; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	sim_keyevent(&event, false);
	CATCH(errctx, akgl_actor_cmhf_right_off(steered, &event));
	for ( i = 0; i < 10; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}

	plain_y = plain->y;
	steered_y = steered->y;
	printf("  air control: plain y=%.1f vy=%.1f | steered y=%.1f vy=%.1f x=%.1f\n",
	       plain_y, plain->vy, steered_y, steered->vy, steered->x);

	TEST_ASSERT(errctx, steered->x > 1.0f,
		    "steering right in mid-air moved the actor %f px", steered->x);
	// The one that steered must be on the same vertical arc as the one that
	// did not. Anything else means a horizontal key changed the fall.
	TEST_ASSERT_FEQ(errctx, steered_y, plain_y,
			"steering in mid-air moved the vertical arc: y=%f against %f, "
			"a difference of %f px",
			steered_y, plain_y, (steered_y - plain_y));
    } CLEANUP {
	if ( steered != NULL ) {
	    IGNORE(akgl_heap_release_actor(steered));
	}
	if ( plain != NULL ) {
	    IGNORE(akgl_heap_release_actor(plain));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Zelda-style: top-down, no gravity, four-way walk that stops.
 *
 * The whole feel of a top-down game is in accelerate, hold, release, stop. The
 * assertions here are that the walk reaches its stated top speed and no more,
 * and that letting go actually stops the character rather than leaving them
 * drifting.
 */
akerr_ErrorContext *test_sim_topdown_walk(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *actor = NULL;
    SDL_Event event;
    float32_t speed_at_hold = 0.0f;
    float32_t x_at_release = 0.0f;
    float32_t drift = 0.0f;
    int i = 0;

    ATTEMPT {
	// No gravity anywhere: this is a floor seen from above.
	sim_arcade(0.0, 0.0, 0.0);
	CATCH(errctx, sim_actor(&actor, "link", 900.0f, 220.0f));

	sim_keyevent(&event, true);
	CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
	for ( i = 0; i < 60; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	speed_at_hold = actor->vx;

	TEST_ASSERT(errctx, speed_at_hold > 0.0f,
		    "a held right key produced vx=%f", speed_at_hold);
	// The cap is on thrust, and with no environmental component in a
	// top-down world vx is thrust, so it must not exceed the character's
	// stated top speed.
	TEST_ASSERT(errctx, speed_at_hold <= (sim_character.sx + 1.0f),
		    "walking reached vx=%f, above the character's top speed of %f",
		    speed_at_hold, sim_character.sx);

	x_at_release = actor->x;
	sim_keyevent(&event, false);
	CATCH(errctx, akgl_actor_cmhf_right_off(actor, &event));
	for ( i = 0; i < 60; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	drift = actor->x - x_at_release;

	printf("  top-down: held vx=%.1f, drifted %.1f px in the second after release\n",
	       speed_at_hold, drift);

	// A second after letting go, the character is stopped.
	TEST_ASSERT_FEQ(errctx, actor->vx, 0.0f,
			"a second after release vx is still %f", actor->vx);
    } CLEANUP {
	if ( actor != NULL ) {
	    IGNORE(akgl_heap_release_actor(actor));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Zelda-style, part two: diagonal movement must not be faster.
 *
 * Holding two directions at once composes two axes that are each capped
 * separately, so the resulting speed is the diagonal of the two caps rather
 * than the cap. A character who walks 41% faster on the diagonal is the oldest
 * bug in top-down movement.
 */
akerr_ErrorContext *test_sim_topdown_diagonal_speed(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *actor = NULL;
    SDL_Event event;
    float32_t straight = 0.0f;
    float32_t diagonal = 0.0f;
    int i = 0;

    ATTEMPT {
	sim_arcade(0.0, 0.0, 0.0);
	CATCH(errctx, sim_actor(&actor, "link_straight", 900.0f, 220.0f));

	sim_keyevent(&event, true);
	CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
	for ( i = 0; i < 60; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	straight = SDL_sqrtf((actor->vx * actor->vx) + (actor->vy * actor->vy));
	IGNORE(akgl_heap_release_actor(actor));
	actor = NULL;

	CATCH(errctx, sim_actor(&actor, "link_diagonal", 900.0f, 220.0f));
	sim_keyevent(&event, true);
	// Right and down together. cmhf_right_on clears MOVING_ALL, so the
	// down bit is set after it rather than before.
	CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
	AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN);
	actor->ay = sim_character.ay;
	for ( i = 0; i < 60; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	    // Holding down means the down bit stays set; the movement logic
	    // rewrites ay from the character each step.
	    AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN);
	}
	diagonal = SDL_sqrtf((actor->vx * actor->vx) + (actor->vy * actor->vy));

	printf("  diagonal: straight %.1f px/s, diagonal %.1f px/s (%.0f%% of straight)\n",
	       straight, diagonal, (double)((diagonal / straight) * 100.0f));

	TEST_ASSERT(errctx, straight > 0.0f, "the straight walk did not move");
	// Within a few percent of the same speed in both directions.
	TEST_ASSERT(errctx, (diagonal <= (straight * 1.05f)),
		    "walking diagonally is %.0f%% of the straight speed (%f against %f); "
		    "two independently capped axes compose to the diagonal of the caps",
		    (double)((diagonal / straight) * 100.0f), diagonal, straight);
    } CLEANUP {
	if ( actor != NULL ) {
	    IGNORE(akgl_heap_release_actor(actor));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Full speed one way, then the other: the turn has to take a moment.
 *
 * A character at top speed who reverses instantly reads as weightless, and one
 * who takes a second reads as stuck in treacle. With an acceleration of `a` and
 * a top speed of `s`, a turn should take about `2s/a` seconds -- the time to
 * shed the old speed plus the time to build the new one.
 */
akerr_ErrorContext *test_sim_sudden_reversal(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *actor = NULL;
    SDL_Event event;
    float32_t topspeed = 0.0f;
    float32_t expected_turn = 0.0f;
    float32_t actual_turn = 0.0f;
    int steps_to_reverse = 0;
    int i = 0;

    ATTEMPT {
	sim_arcade(0.0, 0.0, 0.0);
	CATCH(errctx, sim_actor(&actor, "runner", 900.0f, 220.0f));

	// Up to speed going right.
	sim_keyevent(&event, true);
	CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
	for ( i = 0; i < 60; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	topspeed = actor->vx;
	TEST_ASSERT(errctx, topspeed > 0.0f, "the runner never got up to speed (vx=%f)", topspeed);

	// Now the other way, without letting go first -- which is what a player
	// does, and which skips the release handler that zeroes everything.
	CATCH(errctx, akgl_actor_cmhf_left_on(actor, &event));
	for ( i = 0; i < SIM_MAX_STEPS; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	    if ( actor->vx <= -topspeed ) {
		steps_to_reverse = i + 1;
		break;
	    }
	}

	TEST_ASSERT(errctx, steps_to_reverse > 0,
		    "the runner never reached full speed the other way (vx=%f after %d steps)",
		    actor->vx, SIM_MAX_STEPS);

	actual_turn = (float32_t)steps_to_reverse * SIM_DT;
	expected_turn = (2.0f * topspeed) / sim_character.ax;
	printf("  reversal: %.3f s to turn around at %.0f px/s (2s/a predicts %.3f s)\n",
	       actual_turn, topspeed, expected_turn);

	// A turn that takes no time at all means the velocity was assigned
	// rather than accelerated.
	TEST_ASSERT(errctx, steps_to_reverse > 1,
		    "the runner reversed from +%f to -%f in %d step(s)",
		    topspeed, topspeed, steps_to_reverse);
	// And it should match the acceleration the character actually declares,
	// within a couple of frames of rounding.
	TEST_ASSERT(errctx,
		    (actual_turn > (expected_turn - (3.0f * SIM_DT))) &&
		    (actual_turn < (expected_turn + (3.0f * SIM_DT))),
		    "turning took %.3f s; the character's acceleration predicts %.3f s",
		    actual_turn, expected_turn);
    } CLEANUP {
	if ( actor != NULL ) {
	    IGNORE(akgl_heap_release_actor(actor));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The first step must not launch the world.
 *
 * `gravity_time` is the timestamp the step's dt is measured from, and nothing
 * initialises it -- akgl_physics_init_arcade sets every other field and leaves
 * this one at whatever the backend's storage held, which for the default
 * backend is zero. The first dt is then the whole time since SDL started.
 *
 * A game that spends a second loading before its first frame gets a one-second
 * step, and with gravity that is a fall of several hundred pixels between the
 * first frame and the second.
 */
akerr_ErrorContext *test_sim_first_step_is_not_a_leap(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *actor = NULL;
    float32_t worstcase = 0.0f;

    ATTEMPT {
	sim_arcade(1600.0, 0.0, 0.0);
	CATCH(errctx, sim_actor(&actor, "faller", 900.0f, 220.0f));

	// Exactly what a host does: initialize the backend, then start calling
	// simulate. Nothing here sets gravity_time, because there is no
	// documented way for a caller to.
	CATCH(errctx, akgl_physics_init_arcade(&sim_physics));
	sim_physics.gravity_y = 1600.0;

	// A host does not call simulate() microseconds after configuring the
	// physics; it loads a level first, and *then* starts its frame loop. dt
	// is measured from gravity_time, so that load lands in the first step
	// however gravity_time was initialized.
	SDL_Delay(SIM_LOAD_TIME_MS);

	CATCH(errctx, sim_physics.simulate(&sim_physics, NULL));

	printf("  first step: y=%.1f vy=%.1f after one simulate() from a fresh backend\n",
	       actor->y, actor->vy);

	// One 60 Hz step of 1600 px/s^2 is 0.44 px. Anything past a few pixels
	// means dt was not a frame -- and the same happens on any hitch a
	// running game takes: a level load, a breakpoint, a dragged window, an
	// alt-tab. Initializing gravity_time is necessary but does not cover
	// this on its own; the step has to be bounded.
	//
	// The bound this asserts is the worst case the backend still permits,
	// derived rather than written down: one step of at most max_timestep
	// accelerates to g*dt and then travels that for dt, so g*dt^2 -- 4 px
	// under this gravity at the default 0.05 s. Not 100 px, which is what a
	// 250 ms load bought before the step was bounded.
	worstcase = (float32_t)(1600.0 * AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP * AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP);
	TEST_ASSERT(errctx, (actor->y <= worstcase) && (actor->y > -worstcase),
		    "one step from a freshly initialized backend moved the actor %f px "
		    "after a %d ms load; a 60 Hz frame under this gravity is 0.44 px "
		    "and a step bounded to %.2f s cannot exceed %f px",
		    actor->y, SIM_LOAD_TIME_MS, AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP, worstcase);
    } CLEANUP {
	if ( actor != NULL ) {
	    IGNORE(akgl_heap_release_actor(actor));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Releasing a vertical key mid-air must not cancel gravity.
 *
 * akgl_actor_cmhf_up_off and _down_off zero `ay`, `ey`, `ty` and `vy`. `ey` is
 * the environmental accumulator -- the field gravity has been building up all
 * fall -- and it belongs to the world, not to the key.
 *
 * A platformer that maps down to crouch or fast-fall, or up to look up or climb,
 * releases those keys in mid-air constantly. Every release parks the actor in
 * the air with zero vertical velocity and starts the fall again from nothing.
 */
akerr_ErrorContext *test_sim_vertical_key_release_keeps_gravity(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *plain = NULL;
    akgl_Actor *crouched = NULL;
    SDL_Event event;
    int i = 0;

    ATTEMPT {
	sim_arcade(1600.0, 0.0, 0.0);
	CATCH(errctx, sim_actor(&plain, "faller_plain", 900.0f, 220.0f));
	CATCH(errctx, sim_actor(&crouched, "faller_crouch", 900.0f, 220.0f));
	plain->basechar = &sim_character;

	// Both fall from rest for a third of a second.
	for ( i = 0; i < 20; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}

	TEST_ASSERT(errctx, plain->vy > 100.0f,
		    "the plain faller is only doing %f px/s after 20 steps of 1600 px/s^2",
		    plain->vy);

	// One of them taps down and lets go, the way a fast-fall or a crouch
	// does. The fall is the world's, not the key's.
	sim_keyevent(&event, true);
	CATCH(errctx, akgl_actor_cmhf_down_on(crouched, &event));
	CATCH(errctx, sim_step(SIM_DT));
	sim_keyevent(&event, false);
	CATCH(errctx, akgl_actor_cmhf_down_off(crouched, &event));

	printf("  vertical release: plain vy=%.1f | after a down tap vy=%.1f\n",
	       plain->vy, crouched->vy);

	TEST_ASSERT(errctx, crouched->vy > (plain->vy * 0.5f),
		    "releasing the down key left vy at %f while the untouched faller is at "
		    "%f; the release handler zeroed the gravity accumulator",
		    crouched->vy, plain->vy);
    } CLEANUP {
	if ( crouched != NULL ) {
	    IGNORE(akgl_heap_release_actor(crouched));
	}
	if ( plain != NULL ) {
	    IGNORE(akgl_heap_release_actor(plain));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Run one simulation, report it, and return 1 if it failed.
 *
 * The context is released here rather than propagated, so one bad case does not
 * stop the rest from running.
 */
static int sim_run(const char *name, akerr_ErrorContext *(*simulation)(void))
{
    akerr_ErrorContext *result = NULL;

    printf("%s:\n", name);
    result = simulation();
    if ( result == NULL ) {
	return 0;
    }
    result->handled = true;
    result = akerr_release_error(result);
    printf("  ^^ FAILED\n");
    return 1;
}

/**
 * @brief A floor across the bottom, with a two-tile hole in it.
 *
 * Static, not local: sizeof(akgl_Tilemap) is about 26 MB and a local one
 * overflows the stack before the first assertion runs.
 */
static akgl_Tilemap sim_map;
static akgl_CollisionWorld sim_world;

static akerr_ErrorContext *sim_collision_world(void)
{
    int x = 0;

    PREPARE_ERROR(errctx);

    memset(&sim_map, 0x00, sizeof(akgl_Tilemap));
    sim_map.tilewidth = 16;
    sim_map.tileheight = 16;
    sim_map.width = 40;
    sim_map.height = 20;
    sim_map.numlayers = 1;
    sim_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
    for ( x = 0; x < sim_map.width; x++ ) {
	if ( (x == 20) || (x == 21) ) {
	    continue;   // a hole to fall through
	}
	sim_map.layers[0].data[(19 * sim_map.width) + x] = 1;
    }
    sim_map.collidablelayers = 1u;

    PASS(errctx, akgl_collision_world_init(&sim_world, NULL, 16.0f, 16.0f));
    PASS(errctx, akgl_collision_bind_tilemap(&sim_world, &sim_map));
    sim_physics.collision = &sim_world;
    SUCCEED_RETURN(errctx);
}

/**
 * @brief An actor dropped on a floor lands on it and stays there.
 *
 * The number that matters is the drift after it has settled. A resolver that
 * zeroes the environmental term rather than accounting for the gravity the next
 * step is about to add commits `gravity * dt^2` of penetration per step -- a
 * quarter of a pixel at 60 Hz, invisible, and cumulative.
 */
static akerr_ErrorContext *test_sim_lands_and_rests(void)
{
    akgl_Actor *actor = NULL;
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    float32_t settled = 0.0f;
    float32_t drift = 0.0f;
    int i = 0;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	sim_arcade(900.0, 0.0, 0.0);
	CATCH(errctx, sim_collision_world());
	CATCH(errctx, sim_actor(&actor, "faller", 600.0f, 200.0f));
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
	actor->shape = shape;
	actor->shape_override = true;
	actor->x = 32.0f;
	actor->y = 100.0f;

	for ( i = 0; i < 120; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	settled = actor->y;

	for ( i = 0; i < 120; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	drift = actor->y - settled;
	printf("    landed at y=%.3f, drift over 120 further steps %.4f px\n", settled, drift);

	TEST_ASSERT(errctx, (settled < 304.0f),
		    "the actor came to rest at y=%f; the floor is at 304 and it is inside it",
		    settled);
	TEST_ASSERT(errctx, (fabsf(drift) < 1.0f),
		    "a resting actor drifted %f px over 120 steps; it is sinking through the floor",
		    drift);
    } CLEANUP {
	sim_physics.collision = NULL;
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief And it can still walk once it has landed.
 *
 * The consequence a player sees. A quarter pixel of sink is invisible until the
 * horizontal test starts reporting blocked wherever the actor stands, and then
 * the character jerks backwards every time it tries to move.
 */
static akerr_ErrorContext *test_sim_walks_after_landing(void)
{
    akgl_Actor *actor = NULL;
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    float32_t startx = 0.0f;
    int i = 0;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	sim_arcade(900.0, 0.0, 0.0);
	CATCH(errctx, sim_collision_world());
	CATCH(errctx, sim_actor(&actor, "walker", 600.0f, 120.0f));
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
	actor->shape = shape;
	actor->shape_override = true;
	actor->x = 32.0f;
	actor->y = 100.0f;

	for ( i = 0; i < 120; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	startx = actor->x;

	AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
	for ( i = 0; i < 60; i++ ) {
	    CATCH(errctx, sim_step(SIM_DT));
	}
	printf("    walked from x=%.2f to x=%.2f in one second\n", startx, actor->x);

	TEST_ASSERT(errctx, (actor->x > (startx + 30.0f)),
		    "an actor resting on the floor walked from %f to %f in a second; it is "
		    "caught on the ground it is standing on", startx, actor->x);
    } CLEANUP {
	sim_physics.collision = NULL;
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief A fast fall does not pass through a one-tile floor.
 *
 * At 900 px/s^2 and the default 0.05 s bound a single step covers about 30
 * pixels -- nearly two 16-pixel tiles. Without sub-stepping the actor is above
 * the floor at one sample and below it at the next, and no test between them
 * ever sees an overlap.
 */
static akerr_ErrorContext *test_sim_fast_fall_does_not_tunnel(void)
{
    akgl_Actor *actor = NULL;
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    int i = 0;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	/*
	 * Zero gravity and a constant 1200 px/s, so every step is exactly 60
	 * pixels and the arithmetic is the same every time. With gravity on, the
	 * step size grows and the actor lands *inside* the floor by luck rather
	 * than passing through it -- which made the first version of this test
	 * pass with sub-stepping disabled and prove nothing.
	 *
	 * 60 pixels a step against a 16-pixel floor and a 16-pixel actor: the
	 * window in which an overlap exists is 32 pixels wide, so from y=100 the
	 * samples land at 160, 220, 280 and 340 and step clean over it. Sub-
	 * stepping cuts that to 7.5 pixels, which cannot miss.
	 */
	sim_arcade(0.0, 0.0, 0.0);
	CATCH(errctx, sim_collision_world());
	CATCH(errctx, sim_actor(&actor, "bullet", 600.0f, 200.0f));
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
	actor->shape = shape;
	actor->shape_override = true;
	actor->x = 32.0f;
	actor->y = 100.0f;
	actor->ey = 1200.0f;

	for ( i = 0; i < 20; i++ ) {
	    CATCH(errctx, sim_step(0.05f));
	}
	printf("    60 px per step at a 16 px floor: ended at y=%.2f\n", actor->y);

	TEST_ASSERT(errctx, (actor->y < 320.0f),
		    "an actor moving 60 px a step ended at y=%f, past the floor at 304; it "
		    "stepped straight over the 32 px window in which an overlap exists",
		    actor->y);
    } CLEANUP {
	sim_physics.collision = NULL;
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}


int main(void)
{
    PREPARE_ERROR(errctx);
    int failures = 0;
    int total = 0;

    SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
    SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");

    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	// SDL_GetTicksNS() counts from SDL's own initialization, and
	// akgl_physics_simulate measures dt against it. Without this the clock
	// epoch would be established by the first call inside the first
	// simulation, every reading would be a handful of nanoseconds, and the
	// first-step case could not reproduce what a real host sees.
	if ( !SDL_Init(SDL_INIT_VIDEO) ) {
	    FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
	}
	CATCH(errctx, akgl_heap_init());
	CATCH(errctx, akgl_registry_init());

	// Every case runs even when an earlier one fails. This suite exists to
	// describe the motion, and stopping at the first bad number would hide
	// the rest of the picture -- which is the whole picture.
	failures += sim_run("first step is a frame", &test_sim_first_step_is_not_a_leap);
	failures += sim_run("jump and fall", &test_sim_jump_and_fall);
	failures += sim_run("air control keeps the arc", &test_sim_air_control_preserves_the_arc);
	failures += sim_run("vertical release keeps gravity", &test_sim_vertical_key_release_keeps_gravity);
	failures += sim_run("top-down walk", &test_sim_topdown_walk);
	failures += sim_run("top-down diagonal speed", &test_sim_topdown_diagonal_speed);
	failures += sim_run("sudden reversal", &test_sim_sudden_reversal);
	total = 7;

	failures += sim_run("lands and rests", &test_sim_lands_and_rests);
	failures += sim_run("walks after landing", &test_sim_walks_after_landing);
	failures += sim_run("fast fall does not tunnel", &test_sim_fast_fall_does_not_tunnel);
	total += 3;

	// Derived, not written out. The literal that used to be here went stale
	// the first time a simulation was added, which is what a literal does.
	printf("\n%d of %d simulations failed\n", failures, total);
	FAIL_NONZERO_BREAK(errctx, failures, AKGL_ERR_BEHAVIOR,
			   "%d physics simulations do not behave as a game needs", failures);
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH_NORETURN(errctx);
    return 0;
}
