#include "Game_Dev.h"

#include <box3d/box3d.h>
#include <imgui.h>
#include <SDL3/SDL.h>

#include "crApp.h"

#include "crAudio.h"
#include "crCamera.h"
#include "crEcs.h"
#include "crFontAtlas.h"
#include "crGraphics.h"
#include "crGraphicsUniforms.h"
#include "crInputSystem.h"
#include "crMath.h"
#include "crPhysics.h"
#include "crPostProcess.h"
#include "crScreenRef.h"
#include "crUi.h"

static const color4 BLUE_COLOR     = color4( 0.28f, 0.72f, 1.00f, 1.0f );
static const color4 RED_COLOR      = color4( 1.00f, 0.36f, 0.30f, 1.0f );
static const color4 BOUNDARY_COLOR = color4( 0.30f, 0.72f, 1.00f, 1.0f );

// green-dominant is deliberate: no hull wears it, so anything a side EMITS reads as ordnance rather
// than as another hull shade, and the second channel says whose it is
static const color4 BLUE_ENERGY_COLOR = color4( 0.20f, 1.00f, 0.55f, 1.0f );   // spring green
static const color4 RED_ENERGY_COLOR  = color4( 0.80f, 1.00f, 0.15f, 1.0f );   // chartreuse

// per-hull constants. index-aligned with EHull — keep in sync
struct HullSpec
{
    EPrimitive shape;
    float      radius;
    float      hp;
    float      speed;
    float      accel;         // velocity-servo gain (1/s)
    float      density;
    float      range;
    float      fireInterval;  // seconds
    float      damage;
    float      shotSpeed;
    float      shotLife;      // seconds
    float      emissive;
    float      turnGain;
    float      rippleRadius;  // death shockwave; ripple cost is screen AREA, so only heavy hulls get a big one
    float      rippleLife;
    // hull class is told by VALUE inside the faction hue, not by size — at these radii the silhouette
    // carries almost nothing
    float      tintWhite;
    float      tintValue;     // brightness scale applied after
};

static const HullSpec HULL_SPECS[ static_cast<int32_t>( EHull::_SIZE ) ] =
{
    { EPrimitive::OCTAHEDRON,  0.266f,  2.0f, 16.90f, 7.80f, 0.6f,  9.0f, 0.34f, 1.0f, 22.0f, 0.55f, 2.2f, 33.8f, 1.2f, 0.20f, 0.55f, 1.00f },   // INTERCEPTOR
    { EPrimitive::BOX,         0.350f,  4.5f, 11.70f, 5.46f, 1.0f, 17.0f, 0.80f, 2.6f, 24.0f, 0.80f, 1.5f, 20.8f, 2.0f, 0.28f, 0.05f, 0.95f },   // LANCER
    { EPrimitive::ICOSAHEDRON, 0.595f, 12.0f,  7.15f, 3.90f, 2.6f,  8.5f, 0.95f, 3.4f, 19.0f, 0.60f, 0.9f, 11.7f, 3.6f, 0.45f, 0.00f, 0.42f },   // BULWARK
};

// no CCD on shots: box3d's own guidance is that bullet bodies are the wrong tool for projectiles.
// discrete collision instead, and what governs it is CLOSING speed, not the shot's own — a 24 m/s shot
// meeting a 16.9 m/s interceptor head-on covers 0.68 m in a step against a reach of 0.266 + 0.24.
// 2 * reach (1.01) still exceeds that, so a shot can never skip a hull outright, but grazing paths do
// fall through. raising a shot speed OR a hull speed erodes this, and it fails silently
static constexpr float SHOT_RADIUS = 0.24f;

// ABSOLUTE sim steps, not per-match: the step index never resets, so these fire once per run and stay
// comparable across platforms even when a match boundary lands on a different step
static const uint64_t HASH_STAMP_STEPS[] = { 100, 250, 500, 3000 };

// covers only Tuning's sim span — simSpeed sits ahead of it and the camera block behind, and moving
// either changes nothing a step does. a knob added outside the span stops being covered silently, so
// the size assert is the only warning that the span needs revisiting
static uint64_t HashTuning( const Tuning& tune )
{
    static_assert( sizeof( Tuning ) == 120, "Tuning changed - recheck the span HashTuning covers" );

    const size_t begin = offsetof( Tuning, poolPerSide );
    const size_t end   = offsetof( Tuning, camTravelRate );

    return crMath::HashFnv1a( ( reinterpret_cast<const uint8_t*>( &tune ) + begin ), ( end - begin ), crMath::HASH_FNV1A_SEED );
}

// entity handle carried on the body so a contact event resolves in O(1). +1 because entity id 0 is
// legal — a null userData has to mean "untagged" and nothing else
static void* ToUserData( entt::entity e )
{
    return reinterpret_cast<void*>( static_cast<uintptr_t>( static_cast<uint32_t>( e ) ) + 1 );
}
static entt::entity FromShape( b3ShapeId shape )
{
    const uintptr_t raw = reinterpret_cast<uintptr_t>( b3Body_GetUserData( b3Shape_GetBody( shape ) ) );
    if( raw == 0 )
        return entt::null;

    return static_cast<entt::entity>( static_cast<uint32_t>( raw - 1 ) );
}

static uint64_t ToSteps( float seconds )
{
    return static_cast<uint64_t>( crMath::Round( seconds / crApp::FIXED_TIMESTEP ) );
}

// entity composition is the game's. no CVelocity here: that component is what puts an entity in
// crEcs::SyncDynamicTransforms, and a body that never moves already has its final CTransform
static entt::entity CreateStaticEntity( entt::registry& registry, b3BodyId body, b3Pos position, b3Quat rotation )
{
    const entt::entity e = registry.create();

    registry.emplace<CPhysicsBody>( e, body );

    CTransform t;
    t.current  = { position, rotation };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    return e;
}
static entt::entity CreateDynamicEntity( entt::registry& registry, b3BodyId body, b3Pos position, b3Quat rotation )
{
    const entt::entity e = CreateStaticEntity( registry, body, position, rotation );
    registry.emplace<CVelocity>( e );
    return e;
}

// uniform point on the unit sphere — height first, then the angle around it. the cylinder
// projection is area-preserving, so this does not clump at the poles the way per-axis rolls do
static float3 RandomDirection( crRandom* rng )
{
    const float  y  = rng->NextFloat32( -1.0f, 1.0f );
    const float  r  = crMath::Sqrt( crMath::Clamp01( 1.0f - ( y * y ) ) );
    const float2 cs = crMath::CosSin( rng->NextFloat32( 0.0f, ( 2.0f * crMath::PI ) ) );

    return float3( ( r * cs.x ), y, ( r * cs.y ) );
}

// shortest rotation taking one unit axis onto another. `from` is local: +X is every hull's nose,
// +Z is a ring's plane normal
static b3Quat QuatFromTo( float3 from, float3 to )
{
    const float d = crMath::Dot3( from, to );

    if( d > 0.99999f )
        return { { 0.0f, 0.0f, 0.0f }, 1.0f };

    if( d < -0.99999f )
    {// antiparallel — the cross product is degenerate, so any perpendicular axis will do
        float3 axis = crMath::Cross3( from, float3( 1.0f, 0.0f, 0.0f ) );
        if( crMath::Dot3( axis, axis ) < 0.0001f )
            axis = crMath::Cross3( from, float3( 0.0f, 1.0f, 0.0f ) );

        return crMath::QuatFromAxisAngle( axis, crMath::PI );
    }

    return crMath::QuatFromAxisAngle( crMath::Cross3( from, to ), crMath::Acos( d ) );
}
static b3Quat QuatFromXTo( float3 dir )
{
    return QuatFromTo( float3( 1.0f, 0.0f, 0.0f ), dir );
}

void Game::Init( crApp* app )
{
    _app = app;
    _rng.Seed( 0x5A1F0E5F );
    _decorRng.Seed( 0xB1A5EE );

    for( int32_t i = 0; i < RING_COUNT; ++i )
        _boundaryRings[ i ] = entt::null;

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
        _fighters[ f ].Reserve( 256 );
    _shots.Reserve( 512 );
    _dead.Reserve( 128 );

    _font16 = app->graphics->FontAtlas()->LoadFont( "Orbitron-Medium", 16 );
    _font12 = app->graphics->FontAtlas()->LoadFont( "Orbitron-Medium", 12 );

    _fxSpark = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "sq16" );
    _uiFill  = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "sq16" );
    _uiPanel = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "roundsq64edge" );

    app->physics->substeps = crPhysics::PHYSICS_SUBSTEPS;

    app->graphics->clearColor = color4( 0.015f, 0.018f, 0.030f, 1.0f );

    crPostProcess* pp = app->graphics->PostProcess();
    pp->fog.enabled = true;
    pp->fog.color   = float3( 0.015f, 0.018f, 0.030f );
    pp->fog.start   = 160.0f;   // must clear the starfield shell (ARENA_RADIUS + 45..260) or it eats it
    pp->fog.end     = 520.0f;
    pp->vignette.enabled = true;

    crGraphicsUniforms* uniforms = app->graphics->Uniforms();
    uniforms->lightDir      = float3( 0.35f, 0.55f, 0.75f );
    uniforms->lightColor    = color4( 0.85f, 0.90f, 1.00f, 0.75f );
    uniforms->ambientSky    = color4( 0.30f, 0.40f, 0.70f, 0.30f );
    uniforms->ambientGround = color4( 0.20f, 0.16f, 0.28f, 0.18f );
    // spec is added OUTSIDE albedo ( outColor += lightColor * spec ), so dimming the arena shell's
    // colour cannot reach it — against this near-black background it reads as a white sun-glint
    uniforms->specStrength  = 0.01f;

    b3World_SetGravity( app->physics->World(), { 0.0f, 0.0f, 0.0f } );   // the arena is a ball in free space

    _probe.Configure( app, _font16, _uiPanel );

    SpawnBoundary( app );
    SpawnBlastRings( app );
    SpawnStarfield( app );
    SpawnCore( app, EFaction::BLUE );
    SpawnCore( app, EFaction::RED );

    _camTarget = float3( 0.0f, 0.0f, 0.0f );
    _camDist   = _tune.camDistMax;
    app->camera->SetTarget( _camTarget );
    app->camera->SetOrbit( 0.24f, 0.26f );   // frame-0 seed; UpdateCamera takes over from _camTravel
    app->camera->SetDistance( _camDist );
    app->camera->SetClip( crCamera::NEAR_DEFAULT, 600.0f );   // the starfield shell sits well past the default far plane

    StartMatch( app );

    app->SetSimulationActive( true );   // runs forever — no title/play state to gate it

    app->audio->PlayBgm( "celestial_echoes", true );

    _sfxId[ 0 ] = app->audio->LoadSfx( "duelyst_f3_orbweaver_impact" );
    _sfxId[ 1 ] = app->audio->LoadSfx( "duelyst_f4_siren_attack_impact" );
    _sfxId[ 2 ] = app->audio->LoadSfx( "duelyst_ui_error" );
    _sfxId[ 3 ] = app->audio->LoadSfx( "duelyst_ui_modalwindow_swoosh_enter" );
    _sfxId[ 4 ] = app->audio->LoadSfx( "duelyst_ui_modalwindow_swoosh_exit" );
    _sfxId[ 5 ] = app->audio->LoadSfx( "duelyst_ui_panel_swoosh_enter" );
    _sfxId[ 6 ] = app->audio->LoadSfx( "duelyst_ui_panel_swoosh_exit" );
    _sfxId[ 7 ] = app->audio->LoadSfx( "duelyst_ui_select" );
}
void Game::Cleanup()
{
}

void Game::Update( crApp* app )
{
    _renderDt = app->FrameDelta().unscaled;

    {
        const float speed = ( _tune.simSpeed * ( 1.0f - ( _sloMo * _tune.ultSloMo ) ) );
        if( app->SimulationSpeed() != speed )
            app->SetSimulationSpeed( speed );
    }

    _camTime += _renderDt;
    if( _camTime > ( 2000.0f * crMath::PI ) )
        _camTime -= ( 2000.0f * crMath::PI );

    UpdatePresentation( app );
    UpdateCamera( app );
}
void Game::FixedUpdatePre( const crApp* app )
{
    _probe.FixedStep( app );

    if( _matchState == EMatchState::RESOLVE )
    {
        if( app->SimulationStepIndex() >= _resolveStep )
            StartMatch( app );

        return;
    }

    UpdateCommanders( app );
    UpdateUltimates( app );
    UpdateDeployment( app );
    UpdateFighters( app );
}
void Game::FixedUpdatePost( const crApp* app )
{
    // these two run through RESOLVE as well, so shots in flight land and wind down
    UpdateImpacts( app );
    UpdateExpiry( app );

    if( _matchState == EMatchState::BATTLE )
        UpdateMatchEnd( app );

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
        Compact( app, &_fighters[ f ] );
    Compact( app, &_shots );

    StampDeterminism( app );
}
void Game::HandleEvent( const crApp* app, const SDL_Event* event )
{
    ( void )app;
    ( void )event;
}
void Game::Render( const crApp* app )
{
    ( void )app;
}
void Game::RenderUi( crApp* app )
{
    crUi* ui = app->ui;
    ui->Begin( app );

    RenderScoreboard( app );
    _probe.Render( app );

    ui->End();
}
void Game::RenderDevUi( const crApp* app )
{
    {
        const ImGuiViewport* viewport = ImGui::GetMainViewport();

        ImVec2 pos;
        pos.x = ( viewport->WorkPos.x + 10.0f );
        pos.y = ( viewport->WorkPos.y + viewport->WorkSize.y - 10.0f );
        ImGui::SetNextWindowPos( pos, ImGuiCond_FirstUseEver, ImVec2( 0.0f, 1.0f ) );   // pivot bottom-left
        ImGui::SetNextWindowCollapsed( true, ImGuiCond_FirstUseEver );
    }

    if( ImGui::Begin( "Skirmish", nullptr, ImGuiWindowFlags_NoSavedSettings ) == false )
    {
        ImGui::End();
        return;
    }

    const float elapsed = ( static_cast<float>( app->SimulationStepIndex() - _matchStartStep ) * crApp::FIXED_TIMESTEP );
    ImGui::Text( "match %d   %s   %.0fs", _matchIndex, ( _matchState == EMatchState::BATTLE ) ? "BATTLE" : "RESOLVE", elapsed );
    ImGui::Text( "series  BLUE %d : %d RED", _wins[ 0 ], _wins[ 1 ] );
    ImGui::Text( "bodies  fighters %d + %d   shots %d",
                 _fighters[ 0 ].Size(), _fighters[ 1 ].Size(), _shots.Size() );

    ImGui::Separator();

    static constexpr const char* POSTURE_NAMES[] = { "PUSH", "HOLD", "REGROUP" };
    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        const Commander& cmd = _commanders[ f ];
        const EFaction   fac = static_cast<EFaction>( f );

        ImGui::Text( "%s  %s  %s  live %d/%d  pool %d  core %.0f",
                     FactionName( fac ), DoctrineName( cmd ), POSTURE_NAMES[ static_cast<int32_t>( cmd.posture ) ],
                     cmd.live, cmd.deployTarget, cmd.pool, _cores[ f ].hp );
        ImGui::Text( "   aggr %.2f  focus %.2f  reach %.1f  stand %.2f  intel %.2f  mix %d/%d/%d  k %d  l %d",
                     cmd.aggression, cmd.focusFire, cmd.engageRange, cmd.standoff, cmd.intelBias,
                     cmd.hullWeight[ 0 ], cmd.hullWeight[ 1 ], cmd.hullWeight[ 2 ], cmd.kills, cmd.losses );
        const bool cooling = ( app->SimulationStepIndex() < cmd.ultimateReadyStep );
        ImGui::Text( "   %s  %d/%d  nerve %d  streak %d%s%s", UltimateName( fac ),
                     cmd.ultimateCharges, cmd.ultimateGranted, cmd.ultimateNerve, _lossStreak[ f ],
                     cooling ? "  (cooling)" : "", ( cmd.cascadeLeft > 0 ) ? "  (walking)" : "" );
    }

    ImGui::Separator();

    if( ImGui::Button( "rematch" ) )
    {
        _resolveStep = 0;
        _matchState  = EMatchState::RESOLVE;
    }

    ImGui::SliderFloat( "sim speed", &_tune.simSpeed, 0.25f, 3.0f );
    ImGui::SliderInt( "pool / side", &_tune.poolPerSide, 50, 4000 );
    ImGui::SliderInt( "deploy start", &_tune.deployStart, 4, 400 );
    ImGui::SliderInt( "deploy max", &_tune.deployMax, 4, 800 );
    ImGui::SliderFloat( "ramp s", &_tune.rampSeconds, 10.0f, 400.0f );
    ImGui::SliderInt( "squad size", &_tune.squadSize, 1, 24 );
    ImGui::SliderFloat( "deploy interval", &_tune.deployInterval, 0.05f, 2.0f );
    ImGui::SliderFloat( "think interval", &_tune.thinkInterval, 0.2f, 5.0f );
    ImGui::SliderFloat( "push commit s", &_tune.pushCommit, 0.0f, 20.0f );
    ImGui::SliderFloat( "retarget interval", &_tune.retargetInterval, 0.1f, 3.0f );
    ImGui::SliderFloat( "core hp", &_tune.coreHp, 100.0f, 12000.0f );
    ImGui::SliderFloat( "damage scale", &_tune.damageScale, 0.1f, 6.0f );
    ImGui::SliderFloat( "ult arm s", &_tune.ultArmSeconds, 0.0f, 90.0f );
    ImGui::SliderFloat( "ult cooldown s", &_tune.ultCooldown, 0.0f, 120.0f );
    ImGui::SliderFloat( "ult slomo", &_tune.ultSloMo, 0.0f, 0.9f );
    ImGui::SliderFloat( "ult radius", &_tune.ultRadius, 5.0f, 44.0f );
    ImGui::SliderFloat( "ult damage", &_tune.ultDamage, 1.0f, 60.0f );
    ImGui::SliderFloat( "ult kick", &_tune.ultKick, 0.0f, 60.0f );
    ImGui::SliderInt( "ult chain", &_tune.ultChainCount, 1, 16 );
    ImGui::SliderFloat( "ult chain period", &_tune.ultChainPeriod, 0.1f, 2.0f );
    ImGui::SliderFloat( "sudden death s", &_tune.suddenDeath, 30.0f, 900.0f );

    ImGui::Separator();

    ImGui::SliderFloat( "cam travel rate", &_tune.camTravelRate, -0.5f, 0.5f );
    ImGui::SliderFloat( "cam precess rate", &_tune.camPrecessRate, -0.2f, 0.2f );
    ImGui::SliderFloat( "cam shake", &_tune.camShake, 0.0f, 2.0f );
    ImGui::SliderFloat( "cam follow", &_tune.camFollow, 0.2f, 8.0f );
    ImGui::SliderFloat( "cam dist min", &_tune.camDistMin, 10.0f, 200.0f );
    ImGui::SliderFloat( "cam dist max", &_tune.camDistMax, 10.0f, 300.0f );
    ImGui::SliderFloat( "cam margin", &_tune.camMargin, 1.0f, 3.0f );

    ImGui::End();
}

void Game::SpawnBoundary( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    {
        const entt::entity e = registry.create();
        registry.emplace<CTag_NonHashTarget>( e );

        CTransform t;
        t.current  = { { 0.0f, 0.0f, 0.0f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        // the shader is albedo * ( lit + emissive + rim * fresnel ) and `lit` alone is already ~1.05,
        // so an additive shell's transparency has to come from a DIM albedo, with fresnel raised until
        // the rim still lands. SPHERE_SHELL also draws the far hemisphere, so the body wash arrives twice
        CPrimitive shell;
        shell.shape    = EPrimitive::SPHERE_SHELL;
        shell.flags    = EPrimitiveFlags::NONE;
        shell.blend    = EBlendMode::ADDITIVE;
        shell.scale    = float3( ARENA_RADIUS, ARENA_RADIUS, ARENA_RADIUS );
        shell.color    = color4( ( BOUNDARY_COLOR.r * 0.08f ), ( BOUNDARY_COLOR.g * 0.08f ), ( BOUNDARY_COLOR.b * 0.08f ), 1.0f );
        shell.emissive = 0.0f;
        shell.fresnel  = 12.0f;
        registry.emplace<CPrimitive>( e, shell );
    }

    // any spin about the arena centre keeps a circle lying on the sphere, so these can drift freely.
    // all three tables are sized by RING_COUNT so the count can never outrun one of them
    const color4 hue[ RING_COUNT ] =
    {
        color4( 0.30f, 0.85f, 1.00f, 1.0f ),   // cyan
        color4( 0.58f, 0.42f, 1.00f, 1.0f ),   // violet
        color4( 0.25f, 1.00f, 0.78f, 1.0f ),   // teal
    };
    const float3 drift[ RING_COUNT ] =
    {
        float3(  0.000f,  0.055f,  0.000f ),
        float3(  0.026f,  0.012f, -0.031f ),
        float3( -0.014f, -0.019f,  0.009f ),
    };
    const b3Quat tilt[ RING_COUNT ] =
    {
        crMath::QuatFromAxisAngle( float3( 1.0f, 0.0f, 0.0f ), ( crMath::PI * 0.5f ) ),   // equatorial
        crMath::QuatFromAxisAngle( float3( 0.0f, 1.0f, 0.0f ), ( crMath::PI * 0.5f ) ),
        crMath::QuatFromAxisAngle( float3( 0.6f, 0.2f, 0.4f ), 1.1f ),
    };

    for( int32_t i = 0; i < RING_COUNT; ++i )
    {
        const entt::entity e = registry.create();
        registry.emplace<CTag_NonHashTarget>( e );

        CTransform t;
        t.current  = { { 0.0f, 0.0f, 0.0f }, tilt[ i ] };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive ring;
        ring.shape    = EPrimitive::RING_THIN;
        ring.flags    = EPrimitiveFlags::NONE;
        ring.blend    = EBlendMode::ADDITIVE;
        ring.scale    = float3( ARENA_RADIUS, ARENA_RADIUS, 1.0f );
        ring.color    = hue[ i ];
        ring.emissive = 1.8f;
        registry.emplace<CPrimitive>( e, ring );

        _boundaryRings[ i ] = e;
        _ringAxis[ i ]      = drift[ i ];
    }

    {// re-aimed every frame onto the sphere's silhouette, so it never foreshortens (UpdatePresentation)
        const entt::entity e = registry.create();
        registry.emplace<CTag_NonHashTarget>( e );

        CTransform t;
        t.current  = { { 0.0f, 0.0f, 0.0f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive halo;
        halo.shape    = EPrimitive::RING_THIN;
        halo.flags    = EPrimitiveFlags::NONE;
        halo.blend    = EBlendMode::ADDITIVE;
        halo.scale    = float3( ARENA_RADIUS, ARENA_RADIUS, 1.0f );
        halo.color    = BOUNDARY_COLOR;
        halo.emissive = 5.0f;   // well past the bloom threshold — the halo is meant to bleed
        registry.emplace<CPrimitive>( e, halo );

        _glowRing = e;
    }
}
void Game::SpawnBlastRings( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    for( int32_t i = 0; i < BLAST_RING_POOL; ++i )
    {
        const entt::entity e = registry.create();
        registry.emplace<CTag_NonHashTarget>( e );

        CTransform t;
        t.current  = { { 0.0f, 0.0f, 0.0f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive ring;
        ring.shape = EPrimitive::RING_THIN;   // reassigned per use — a slot can also run as the flash sphere
        ring.flags = EPrimitiveFlags::NONE;
        ring.blend = EBlendMode::ADDITIVE;
        ring.scale = float3( 0.0f, 0.0f, 0.0f );   // idle
        registry.emplace<CPrimitive>( e, ring );

        _blastRings[ i ].entity = e;
        _blastRings[ i ].life   = 0.0f;
    }
}
void Game::SpawnStarfield( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    // stellar classes, weighted roughly by how common each looks in a night sky
    struct StarClass
    {
        float  weight;
        color4 color;
    };
    const StarClass CLASSES[ 4 ] =
    {
        { 0.55f, color4( 0.72f, 0.80f, 1.00f, 1.0f ) },   // blue-white
        { 0.25f, color4( 1.00f, 0.96f, 0.88f, 1.0f ) },   // white-yellow
        { 0.13f, color4( 1.00f, 0.76f, 0.52f, 1.0f ) },   // orange
        { 0.07f, color4( 1.00f, 0.52f, 0.40f, 1.0f ) },   // red
    };

    for( int32_t i = 0; i < STAR_COUNT; ++i )
    {
        const entt::entity e = registry.create();
        registry.emplace<CTag_NonHashTarget>( e );

        const float3 dir      = RandomDirection( &_decorRng );
        const float  distance = _decorRng.NextFloat32( ( ARENA_RADIUS + 45.0f ), ( ARENA_RADIUS + 260.0f ) );

        CTransform t;
        t.current  = { { ( dir.x * distance ), ( dir.y * distance ), ( dir.z * distance ) }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        // ANGULAR size, not absolute: a fixed world size leaves the far stars sub-pixel, and sub-pixel
        // geometry scintillates as the orbiting camera slides it across the pixel grid. ~3-11 px at
        // any depth removes the cause; depth is then told by brightness and hue instead
        const float angular = _decorRng.NextFloat32( 0.0030f, 0.0105f );
        const float size    = ( distance * angular );

        color4 hue  = CLASSES[ 3 ].color;
        float  roll = _decorRng.NextFloat32( 0.0f, 1.0f );
        for( int32_t c = 0; c < 4; ++c )
        {
            roll -= CLASSES[ c ].weight;
            if( roll < 0.0f )
            {
                hue = CLASSES[ c ].color;
                break;
            }
        }

        // most stay under the bloom threshold; only a minority is meant to bleed
        const bool  beacon   = _decorRng.NextBool( 0.15f );
        const float emissive = beacon ? _decorRng.NextFloat32( 1.6f, 3.8f ) : _decorRng.NextFloat32( 0.20f, 0.75f );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::SPHERE;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::ADDITIVE;
        primitive.scale    = float3( size, size, size );
        primitive.color    = crMath::LerpColor( hue, color4( 1.0f, 1.0f, 1.0f, 1.0f ), _decorRng.NextFloat32( 0.0f, 0.25f ) );
        primitive.emissive = emissive;
        registry.emplace<CPrimitive>( e, primitive );
    }
}
void Game::SpawnCore( const crApp* app, EFaction faction )
{
    entt::registry& registry = app->ecs->registry;
    const int32_t   index    = static_cast<int32_t>( faction );
    const bool      blue     = ( faction == EFaction::BLUE );

    Core& core = _cores[ index ];
    core.pos   = float3( blue ? -CORE_X : CORE_X, 0.0f, 0.0f );
    core.hpMax = _tune.coreHp;
    core.hp    = core.hpMax;
    core.flash = 0.0f;

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.position = { core.pos.x, core.pos.y, core.pos.z };

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density              = 1.0f;
    shapeDef.enableContactEvents  = true;
    shapeDef.filter.categoryBits  = blue ? CAT_CORE_BLUE : CAT_CORE_RED;
    shapeDef.filter.maskBits      = ( CAT_SHIP_BLUE | CAT_SHIP_RED | ( blue ? CAT_SHOT_RED : CAT_SHOT_BLUE ) );
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, CORE_RADIUS };
    b3CreateSphereShape( body, &shapeDef, &sphere );

    core.hull = CreateStaticEntity( registry, body, bodyDef.position, bodyDef.rotation );
    b3Body_SetUserData( body, ToUserData( core.hull ) );

    CPrimitive hullPrim;
    hullPrim.shape    = EPrimitive::ICOSAHEDRON;
    hullPrim.scale    = float3( CORE_RADIUS, CORE_RADIUS, CORE_RADIUS );
    hullPrim.color    = FactionColor( faction );
    hullPrim.emissive = 1.1f;
    hullPrim.fresnel  = 0.5f;
    registry.emplace<CPrimitive>( core.hull, hullPrim );

    {// shield shell — no body; radius and glow read out remaining hp (UpdatePresentation)
        const entt::entity e = registry.create();
        registry.emplace<CTag_NonHashTarget>( e );

        CTransform t;
        t.current  = { { core.pos.x, core.pos.y, core.pos.z }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive shell;
        shell.shape    = EPrimitive::SPHERE;
        shell.flags    = EPrimitiveFlags::NONE;
        shell.blend    = EBlendMode::ADDITIVE;
        shell.scale    = float3( ( CORE_RADIUS * 1.9f ), ( CORE_RADIUS * 1.9f ), ( CORE_RADIUS * 1.9f ) );
        shell.color    = FactionColor( faction );
        shell.emissive = 0.35f;
        shell.fresnel  = 1.6f;
        registry.emplace<CPrimitive>( e, shell );

        core.shield = e;
    }

    {// crossed into XY and YZ, so at least one is always presenting its face
        const float radius = ( CORE_RADIUS * 2.5f );

        for( int32_t k = 0; k < 2; ++k )
        {
            const entt::entity e = registry.create();
            registry.emplace<CTag_NonHashTarget>( e );

            CTransform t;
            t.current  = { { core.pos.x, core.pos.y, core.pos.z },
                           ( k == 0 ) ? b3Quat{ { 0.0f, 0.0f, 0.0f }, 1.0f }
                                      : crMath::QuatFromAxisAngle( float3( 0.0f, 1.0f, 0.0f ), ( crMath::PI * 0.5f ) ) };
            t.previous = t.current;
            registry.emplace<CTransform>( e, t );

            CPrimitive ring;
            ring.shape    = EPrimitive::RING_THIN;
            ring.flags    = EPrimitiveFlags::NONE;
            ring.blend    = EBlendMode::ADDITIVE;
            ring.scale    = float3( radius, radius, 1.0f );
            ring.color    = EnergyColor( faction );
            ring.emissive = 2.6f;
            registry.emplace<CPrimitive>( e, ring );

            core.halo[ k ] = e;
        }
    }
}

void Game::StartMatch( const crApp* app )
{
    ClearBattlefield( app );

    ++_matchIndex;
    _matchState     = EMatchState::BATTLE;
    _matchStartStep = app->SimulationStepIndex();

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        RollDoctrine( static_cast<EFaction>( f ) );

        Commander& cmd     = _commanders[ f ];
        cmd.pool           = _tune.poolPerSide;
        cmd.deployTarget   = _tune.deployStart;
        cmd.live           = 0;
        cmd.losses         = 0;
        cmd.kills          = 0;
        cmd.posture         = EPosture::HOLD;
        cmd.pushLineX       = 0.0f;
        cmd.nextDeployStep  = _matchStartStep;
        cmd.nextThinkStep   = _matchStartStep;
        cmd.postureLockStep = 0;
        cmd.cascadeLeft     = 0;
        cmd.cascadeStep     = 0;
        cmd.cascadeT        = 0.0f;

        int32_t charges = ( 1 + _lossStreak[ f ] );
        if( charges > ULT_MAX_CHARGES )
            charges = ULT_MAX_CHARGES;

        cmd.ultimateCharges   = charges;
        cmd.ultimateGranted   = charges;
        cmd.ultimateReadyStep = 0;

        Core& core = _cores[ f ];
        core.hpMax = _tune.coreHp;
        core.hp    = core.hpMax;
        core.flash = 0.0f;
    }

    SDL_LogInfo( SDL_LOG_CATEGORY_APPLICATION, "[tune:%016" SDL_PRIx64 "] match:%d", HashTuning( _tune ), _matchIndex );
}
void Game::ClearBattlefield( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        for( int32_t i = 0; i < _fighters[ f ].Size(); ++i )
        {
            const entt::entity e = _fighters[ f ].At( i );
            if( registry.valid( e ) == false )
                continue;

            b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
            registry.destroy( e );
        }
        _fighters[ f ].Clear();
    }

    for( int32_t i = 0; i < _shots.Size(); ++i )
    {
        const entt::entity e = _shots.At( i );
        if( registry.valid( e ) == false )
            continue;

        b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
        registry.destroy( e );
    }
    _shots.Clear();
    _dead.Clear();

    app->graphics->PostProcess()->ClearQueuedRipples();
}
void Game::RollDoctrine( EFaction faction )
{
    Commander& cmd = _commanders[ static_cast<int32_t>( faction ) ];

    cmd.aggression  = _rng.NextFloat32( 0.15f, 0.95f );
    cmd.focusFire   = _rng.NextFloat32( 0.00f, 1.00f );
    cmd.engageRange = _rng.NextFloat32( 11.0f, 26.0f );
    cmd.standoff    = _rng.NextFloat32( 0.35f, 0.95f );
    cmd.intelBias     = _rng.NextFloat32( 0.85f, 1.10f );
    // bounded by how thin a 3D front is: a radius-22 sphere is an eighth of the arena, so even a packed
    // line rarely puts 40 hulls in one. a bar above that is never met, and the shot falls through to
    // the desperation clause instead — which ignores posture, undoing the split between the two weapons
    cmd.ultimateNerve = _rng.NextInt32( 10, 26 );

    const int32_t backbone = _rng.NextInt32( 0, HULL_COUNT );
    for( int32_t h = 0; h < HULL_COUNT; ++h )
        cmd.hullWeight[ h ] = ( h == backbone ) ? _rng.NextInt32( 45, 75 ) : _rng.NextInt32( 8, 35 );

    cmd.backbone = static_cast<EHull>( backbone );
}

void Game::UpdateCommanders( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    const uint64_t  step     = app->SimulationStepIndex();

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        Commander& cmd = _commanders[ f ];
        if( step < cmd.nextThinkStep )
            continue;

        cmd.nextThinkStep = ( step + ToSteps( _tune.thinkInterval ) );

        const EFaction faction = static_cast<EFaction>( f );
        const EFaction enemy   = Enemy( faction );
        const int32_t  e       = static_cast<int32_t>( enemy );

        cmd.live = _fighters[ f ].Size();

        if( step < cmd.postureLockStep )
            continue;

        // committed strength: what is on the field plus what is still in the hangar, discounted. the
        // enemy's half is scaled by this commander's own misreading of it
        const float mine  = ( static_cast<float>( cmd.live ) + ( static_cast<float>( cmd.pool ) * 0.35f ) );
        const float their = ( ( static_cast<float>( _fighters[ e ].Size() ) + ( static_cast<float>( _commanders[ e ].pool ) * 0.35f ) ) * cmd.intelBias );
        const float ratio = ( mine / ( ( their > 1.0f ) ? their : 1.0f ) );

        // how much of the enemy is already sitting on our doorstep
        const float3 ownCore = _cores[ f ].pos;
        int32_t      raiders = 0;
        for( int32_t i = 0; i < _fighters[ e ].Size(); ++i )
        {
            const entt::entity other = _fighters[ e ].At( i );
            if( registry.valid( other ) == false )
                continue;

            const float3 p  = float3( registry.get<CTransform>( other ).current.p );
            const float  dx = ( p.x - ownCore.x );
            const float  dy = ( p.y - ownCore.y );
            const float  dz = ( p.z - ownCore.z );
            if( ( ( dx * dx ) + ( dy * dy ) + ( dz * dz ) ) < ( 18.0f * 18.0f ) )
                ++raiders;
        }

        const float pushBar = ( 1.35f - ( cmd.aggression * 0.55f ) );   // aggressive doctrines commit at a worse ratio

        if( ( raiders > 12 ) && ( ratio < 1.6f ) )
            cmd.posture = EPosture::REGROUP;
        else if( ratio > pushBar )
            cmd.posture = EPosture::PUSH;
        else if( ratio < 0.72f )
            cmd.posture = EPosture::REGROUP;
        else
            cmd.posture = EPosture::HOLD;

        // re-locked on every PUSH tick, not only on entry: otherwise a commander that simply re-affirms
        // the charge walks free after the first lock and it costs nothing
        if( cmd.posture == EPosture::PUSH )
            cmd.postureLockStep = ( step + ToSteps( _tune.pushCommit ) );

        const float enemyX = _cores[ e ].pos.x;
        if( cmd.posture == EPosture::PUSH )
            cmd.pushLineX = ( enemyX * 0.92f );
        else if( cmd.posture == EPosture::HOLD )
            cmd.pushLineX = ( enemyX * ( 0.10f + ( cmd.aggression * 0.35f ) ) );
        else
            cmd.pushLineX = ( ownCore.x * 0.55f );
    }
}
void Game::UpdateUltimates( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    const uint64_t  step     = app->SimulationStepIndex();
    const float     elapsed  = ( static_cast<float>( step - _matchStartStep ) * crApp::FIXED_TIMESTEP );

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        Commander&     cmd     = _commanders[ f ];
        const EFaction faction = static_cast<EFaction>( f );
        const int32_t  e       = static_cast<int32_t>( Enemy( faction ) );

        if( cmd.cascadeLeft > 0 )
        {// already walking — a chain is aimed once, at the moment it is spent
            if( step < cmd.cascadeStep )
                continue;

            const float3 at = crMath::Lerp3( cmd.cascadeFrom, cmd.cascadeTo, cmd.cascadeT );
            Detonate( app, faction, at, ( _tune.ultRadius * 0.5f ), ( _tune.ultDamage * 0.5f ), ( _tune.ultKick * 0.5f ) );

            // ring life outlives the chain period several times over, so the ones behind this are
            // still in the air and the walk reads as a line
            PushBlastLook( at, ( _tune.ultRadius * 0.5f ), 1.4f, EnergyColor( faction ) );

            if( cmd.cascadeLeft == _tune.ultChainCount )
                _sloMo = 1.0f;   // the opening beat only — the rest of the chain runs at speed

            --cmd.cascadeLeft;
            cmd.cascadeStep = ( step + ToSteps( _tune.ultChainPeriod ) );
            if( _tune.ultChainCount > 1 )
                cmd.cascadeT += ( 1.0f / static_cast<float>( _tune.ultChainCount - 1 ) );
            continue;
        }

        if( ( cmd.ultimateCharges <= 0 ) || ( elapsed < _tune.ultArmSeconds ) || ( step < cmd.ultimateReadyStep ) )
            continue;

        {// the scan below is O(n^2) over a fleet: affordable once a think interval, ruinous every step.
            // the two sides are offset so their scans never land on the same one
            const uint64_t period = ToSteps( _tune.thinkInterval );
            if( ( ( step + static_cast<uint64_t>( f ) ) % ( ( period > 0 ) ? period : 1 ) ) != 0 )
                continue;
        }

        // thickest knot of enemy hulls: for each, how many others share its footprint. hangar hulls
        // are skipped as centre AND as neighbour, so a launch cluster cannot pad anyone's count
        const float3 theirHome = _cores[ e ].pos;
        const float  guard2    = ( ULT_HOME_GUARD * ULT_HOME_GUARD );
        const float  footprint = ( _tune.ultRadius * _tune.ultRadius );

        float3  bestAt    = _cores[ e ].pos;
        int32_t bestCount = 0;

        for( int32_t i = 0; i < _fighters[ e ].Size(); ++i )
        {
            const entt::entity a = _fighters[ e ].At( i );
            if( registry.valid( a ) == false )
                continue;

            const float3 pa = float3( registry.get<CTransform>( a ).current.p );
            {
                const float3 h = float3( ( pa.x - theirHome.x ), ( pa.y - theirHome.y ), ( pa.z - theirHome.z ) );
                if( crMath::Dot3( h, h ) < guard2 )
                    continue;
            }

            int32_t count = 0;
            for( int32_t j = 0; j < _fighters[ e ].Size(); ++j )
            {
                const entt::entity b = _fighters[ e ].At( j );
                if( registry.valid( b ) == false )
                    continue;

                const float3 pb = float3( registry.get<CTransform>( b ).current.p );
                {
                    const float3 h = float3( ( pb.x - theirHome.x ), ( pb.y - theirHome.y ), ( pb.z - theirHome.z ) );
                    if( crMath::Dot3( h, h ) < guard2 )
                        continue;
                }

                const float dx = ( pb.x - pa.x );
                const float dy = ( pb.y - pa.y );
                const float dz = ( pb.z - pa.z );
                if( ( ( dx * dx ) + ( dy * dy ) + ( dz * dz ) ) <= footprint )
                    ++count;
            }

            if( count > bestCount )
            {
                bestCount = count;
                bestAt    = pa;
            }
        }

        // out of core or out of time: holding the charge is worth nothing, and one that never fires
        // is not a last resort
        const bool desperate = ( ( _cores[ f ].hp < ( _cores[ f ].hpMax * 0.35f ) ) || ( elapsed > _tune.suddenDeath ) );

        // each weapon waits for the posture it is FOR, and the two postures cannot both hold — which
        // is what keeps the sides from firing together off the same knot
        const bool postureFits = ( faction == EFaction::BLUE ) ? ( cmd.posture == EPosture::REGROUP )
                                                               : ( cmd.posture == EPosture::PUSH );

        if( desperate )
        {
            if( bestCount < 4 )
                continue;
        }
        else if( ( postureFits == false ) || ( bestCount < cmd.ultimateNerve ) )
        {
            continue;
        }

        --cmd.ultimateCharges;
        cmd.ultimateReadyStep = ( step + ToSteps( _tune.ultCooldown ) );

        _ultBannerStep    = ( step + ToSteps( 2.5f ) );
        _ultBannerFaction = faction;

        if( faction == EFaction::BLUE )
        {// CASCADE — half-strength blasts walked from the knot into the enemy core
            cmd.cascadeFrom = bestAt;
            cmd.cascadeTo   = _cores[ e ].pos;
            cmd.cascadeT    = 0.0f;
            cmd.cascadeLeft = ( ( _tune.ultChainCount > 0 ) ? _tune.ultChainCount : 1 );
            cmd.cascadeStep = step;
        }
        else
        {// OVERLOAD — the whole budget in one place, one time
            Detonate( app, faction, bestAt, _tune.ultRadius, _tune.ultDamage, _tune.ultKick );
            PushBlastLook( bestAt, _tune.ultRadius, 0.7f, EnergyColor( faction ) );
            _sloMo = 1.0f;
        }
    }
}
void Game::Detonate( const crApp* app, EFaction owner, float3 pos, float radius, float damage, float kick )
{
    entt::registry& registry = app->ecs->registry;
    const float     r2       = ( radius * radius );

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        const bool hostile = ( static_cast<EFaction>( f ) != owner );

        for( int32_t i = 0; i < _fighters[ f ].Size(); ++i )
        {
            const entt::entity e = _fighters[ f ].At( i );
            if( registry.valid( e ) == false )
                continue;

            const float3 p  = float3( registry.get<CTransform>( e ).current.p );
            const float3 d  = float3( ( p.x - pos.x ), ( p.y - pos.y ), ( p.z - pos.z ) );
            const float  d2 = crMath::Dot3( d, d );
            if( d2 > r2 )
                continue;

            const float dist    = crMath::Sqrt( d2 );
            const float falloff = ( 1.0f - ( dist / radius ) );

            // shoves EVERY hull it reaches, the owner's included; damage stays hostile-only
            const float3 away = ( dist > 0.001f ) ? float3( ( d.x / dist ), ( d.y / dist ), ( d.z / dist ) )
                                                  : float3( 0.0f, 1.0f, 0.0f );
            const b3BodyId body = registry.get<CPhysicsBody>( e ).bodyId;
            const float    mag  = ( kick * falloff * b3Body_GetMass( body ) );
            b3Body_ApplyLinearImpulseToCenter( body, { ( away.x * mag ), ( away.y * mag ), ( away.z * mag ) }, true );

            if( hostile )
                DamageFighter( app, e, ( damage * falloff * _tune.damageScale ) );
        }
    }

    {// the enemy core is a legal target, which is what lets a walked chain end as a siege
        const int32_t victim = static_cast<int32_t>( Enemy( owner ) );
        const float3  cp     = _cores[ victim ].pos;
        const float3  d      = float3( ( cp.x - pos.x ), ( cp.y - pos.y ), ( cp.z - pos.z ) );
        const float   dist   = crMath::Sqrt( crMath::Dot3( d, d ) );
        if( dist < ( radius + CORE_RADIUS ) )
            DamageCore( app, static_cast<EFaction>( victim ), ( damage * 3.0f * _tune.damageScale ), cp );
    }

    SpawnBurst( app, pos, EnergyColor( owner ), ( radius * 0.55f ), 0.5f );
    app->graphics->PostProcess()->QueueRipple( pos, ( radius * 1.35f ), 1.1f, 0.075f );

    _hitPulse = 1.0f;
    _shakeMag = 1.0f;
}
void Game::PushBlastRing( float3 pos, float radius, float life, float grow, color4 color, float emissive, EPrimitive shape )
{
    // a full pool sacrifices whichever ring is furthest through its life
    int32_t slot = -1;
    float   best = -1.0f;

    for( int32_t i = 0; i < BLAST_RING_POOL; ++i )
    {
        if( _blastRings[ i ].life <= 0.0f )
        {
            slot = i;
            break;
        }

        const float progress = ( _blastRings[ i ].age / _blastRings[ i ].life );
        if( progress > best )
        {
            best = progress;
            slot = i;
        }
    }

    if( slot < 0 )
        return;

    BlastRing& ring = _blastRings[ slot ];
    ring.color    = color;
    ring.pos      = pos;
    ring.age      = 0.0f;
    ring.life     = life;
    ring.radius   = radius;
    ring.grow     = ( ( grow > 0.01f ) ? grow : 0.01f );
    ring.emissive = emissive;
    ring.shape    = shape;
}
void Game::PushBlastLook( float3 pos, float radius, float ringLife, color4 color )
{
    const color4 white = color4( 1.0f, 1.0f, 1.0f, 1.0f );

    PushBlastRing( pos, ( radius * 0.55f ), 0.16f, 0.03f, crMath::LerpColor( color, white, 0.70f ), 7.0f, EPrimitive::SPHERE );
    PushBlastRing( pos, radius, ringLife, ( ringLife * 0.45f ), crMath::LerpColor( color, white, 0.25f ), 5.0f, EPrimitive::RING_THIN );
    PushBlastRing( pos, ( radius * 1.45f ), ( ringLife * 1.7f ), ( ringLife * 1.1f ), color, 2.0f, EPrimitive::RING_THIN );

    // the blur re-pulls per blast so a chain drags the focus with it; the slow motion is set by the
    // caller instead, since six dips down one chain would only make it drag
    _blastFocus = pos;
    _blastFlash = 1.0f;
}
void Game::UpdateDeployment( const crApp* app )
{
    const uint64_t step    = app->SimulationStepIndex();
    const float    elapsed = ( static_cast<float>( step - _matchStartStep ) * crApp::FIXED_TIMESTEP );
    const float    ramp    = crMath::Clamp01( elapsed / ( ( _tune.rampSeconds > 0.01f ) ? _tune.rampSeconds : 0.01f ) );

    const int32_t target = static_cast<int32_t>( crMath::Round( crMath::Lerp( static_cast<float>( _tune.deployStart ),
                                                                             static_cast<float>( _tune.deployMax ), ramp ) ) );

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        Commander& cmd   = _commanders[ f ];
        cmd.deployTarget = target;

        if( ( cmd.pool <= 0 ) || ( step < cmd.nextDeployStep ) )
            continue;

        if( _fighters[ f ].Size() >= cmd.deployTarget )
            continue;

        cmd.nextDeployStep = ( step + ToSteps( _tune.deployInterval ) );

        int32_t total = 0;
        for( int32_t h = 0; h < HULL_COUNT; ++h )
            total += cmd.hullWeight[ h ];

        int32_t roll = _rng.NextInt32( 0, ( total > 0 ) ? total : 1 );
        int32_t hull = ( HULL_COUNT - 1 );
        for( int32_t h = 0; h < HULL_COUNT; ++h )
        {
            roll -= cmd.hullWeight[ h ];
            if( roll < 0 )
            {
                hull = h;
                break;
            }
        }

        // one roll for the whole squadron; the mix still follows the doctrine weights across launches
        int32_t count = ( cmd.deployTarget - _fighters[ f ].Size() );
        if( count > _tune.squadSize )
            count = _tune.squadSize;
        if( count > cmd.pool )
            count = cmd.pool;

        SpawnSquadron( app, static_cast<EFaction>( f ), static_cast<EHull>( hull ), count );
        cmd.pool -= count;
    }
}
void Game::UpdateFighters( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    const uint64_t  step     = app->SimulationStepIndex();

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        const Commander& cmd       = _commanders[ f ];
        const float3     enemyCore = _cores[ static_cast<int32_t>( Enemy( static_cast<EFaction>( f ) ) ) ].pos;

        for( int32_t i = 0; i < _fighters[ f ].Size(); ++i )
        {
            const entt::entity self = _fighters[ f ].At( i );
            if( registry.valid( self ) == false )
                continue;

            // pos is copied, not referenced: FireShot below emplaces a CTransform, which can move
            // that pool out from under any reference into it
            CFighter&       fighter = registry.get<CFighter>( self );
            const HullSpec& spec    = HULL_SPECS[ static_cast<int32_t>( fighter.hull ) ];
            const float3    pos     = float3( registry.get<CTransform>( self ).current.p );
            const b3BodyId  body    = registry.get<CPhysicsBody>( self ).bodyId;

            if( step >= fighter.retargetStep )
            {
                fighter.target       = PickTarget( app, fighter, pos );
                fighter.retargetStep = ( step + ToSteps( _tune.retargetInterval ) );
            }

            bool   hasTarget = false;
            float3 targetPos = enemyCore;
            float3 targetVel = float3( 0.0f, 0.0f, 0.0f );   // a core does not move, so it leads to zero

            if( registry.valid( fighter.target ) && registry.all_of<CFighter>( fighter.target ) )
            {
                targetPos = float3( registry.get<CTransform>( fighter.target ).current.p );
                targetVel = float3( b3Body_GetLinearVelocity( registry.get<CPhysicsBody>( fighter.target ).bodyId ) );
                hasTarget = true;
            }

            const float3 toTarget   = float3( ( targetPos.x - pos.x ), ( targetPos.y - pos.y ), ( targetPos.z - pos.z ) );
            const float  targetDist = crMath::Sqrt( crMath::Dot3( toTarget, toTarget ) );

            float3 desired  = float3( 0.0f, 0.0f, 0.0f );
            float3 aim      = float3( 0.0f, 0.0f, 0.0f );
            bool   steering = false;

            const float  invDist     = ( ( targetDist > 0.001f ) ? ( 1.0f / targetDist ) : 0.0f );
            const float3 toTargetDir = float3( ( toTarget.x * invDist ), ( toTarget.y * invDist ), ( toTarget.z * invDist ) );

            const bool inWeaponRange = hasTarget ? ( targetDist <= spec.range )
                                                 : ( targetDist <= ( spec.range + CORE_RADIUS ) );

            // movement and targeting are separate decisions. a PUSH keeps flying at the core with its
            // guns on whatever is beside it — coupled, any enemy in engage range pins a hull in place,
            // and with hundreds a side there always is one, so a breakthrough never breaks through
            const bool pushing = ( cmd.posture == EPosture::PUSH );

            if( hasTarget && ( pushing == false ) )
            {
                // hold the doctrine's preferred range, plus a tangential term so a duel orbits instead
                // of stalling nose-to-nose
                const float preferred = ( spec.range * cmd.standoff );
                const float closing   = crMath::Clamp( ( ( targetDist - preferred ) * 0.35f ), -1.0f, 1.0f );

                float3 tangent = crMath::Cross3( toTargetDir, fighter.orbitAxis );
                if( crMath::Dot3( tangent, tangent ) < 0.0001f )
                    tangent = crMath::Cross3( toTargetDir, float3( 0.0f, 0.0f, 1.0f ) );   // the aim lay on the axis
                tangent = crMath::Normalize3( tangent );

                const float3 move = float3( ( ( toTargetDir.x * closing ) + ( tangent.x * 0.55f ) ),
                                            ( ( toTargetDir.y * closing ) + ( tangent.y * 0.55f ) ),
                                            ( ( toTargetDir.z * closing ) + ( tangent.z * 0.55f ) ) );

                const float len = crMath::Sqrt( crMath::Dot3( move, move ) );
                if( len > 0.001f )
                {
                    desired  = float3( ( ( move.x / len ) * spec.speed ),
                                       ( ( move.y / len ) * spec.speed ),
                                       ( ( move.z / len ) * spec.speed ) );
                    steering = true;
                }
            }
            else
            {
                // PUSH converges on the core; the rest hold rank at whatever y/z the hull already has,
                // which keeps the front a broad shell rather than a column
                const float3 goal = pushing ? enemyCore : float3( cmd.pushLineX, pos.y, pos.z );

                const float3 toGoal = float3( ( goal.x - pos.x ), ( goal.y - pos.y ), ( goal.z - pos.z ) );
                const float  len    = crMath::Sqrt( crMath::Dot3( toGoal, toGoal ) );
                if( len > 0.001f )
                {
                    const float approach = crMath::Clamp( ( len * 0.35f ), 0.0f, 1.0f );
                    desired  = float3( ( ( toGoal.x / len ) * spec.speed * approach ),
                                       ( ( toGoal.y / len ) * spec.speed * approach ),
                                       ( ( toGoal.z / len ) * spec.speed * approach ) );
                    steering = true;
                }
            }

            // the nose follows the guns when there is something to shoot, and the flight path otherwise
            if( inWeaponRange && ( invDist > 0.0f ) )
                aim = toTargetDir;
            else if( steering )
                aim = crMath::Normalize3( desired );

            const float  mass = b3Body_GetMass( body );
            const b3Vec3 vel  = b3Body_GetLinearVelocity( body );

            {// velocity servo — a force, not a velocity write, so collisions can still shove a hull off its line
                const float gain = ( mass * spec.accel );
                b3Body_ApplyForceToCenter( body, { ( ( desired.x - vel.x ) * gain ),
                                                   ( ( desired.y - vel.y ) * gain ),
                                                   ( ( desired.z - vel.z ) * gain ) }, true );
            }

            {// PD, not a plain spring: the stiffness needed to beat a hull steering flat-out at the rim
                // would otherwise leave it bouncing there (see CONTAIN_GAIN)
                const float r2   = crMath::Dot3( pos, pos );
                const float soft = ( ARENA_RADIUS - SOFT_MARGIN );
                if( r2 > ( soft * soft ) )
                {
                    const float  r  = crMath::Sqrt( r2 );
                    const float3 up = float3( ( pos.x / r ), ( pos.y / r ), ( pos.z / r ) );   // outward normal

                    const float radialVel = crMath::Dot3( float3( vel ), up );
                    float       push      = ( ( ( r - soft ) * CONTAIN_GAIN ) + ( radialVel * CONTAIN_DAMP ) );
                    if( push < 0.0f )
                        push = 0.0f;   // a hull already heading back in is not shoved outward

                    push *= mass;
                    b3Body_ApplyForceToCenter( body, { ( -up.x * push ), ( -up.y * push ), ( -up.z * push ) }, true );
                }
            }

            if( steering )
            {// align the nose (local +X) onto the aim. cross() yields axis * sin(angle) directly, so
                // this needs no quaternion error term and no angle wrapping
                const b3Vec3 forward = b3RotateVector( b3Body_GetRotation( body ), { 1.0f, 0.0f, 0.0f } );
                const float3 err     = crMath::Cross3( float3( forward ), aim );
                const b3Vec3 spin    = b3Body_GetAngularVelocity( body );
                const float  inertia = ( mass * spec.radius * spec.radius );

                b3Body_ApplyTorque( body, { ( ( ( err.x * spec.turnGain ) - ( spin.x * 2.2f ) ) * inertia ),
                                            ( ( ( err.y * spec.turnGain ) - ( spin.y * 2.2f ) ) * inertia ),
                                            ( ( ( err.z * spec.turnGain ) - ( spin.z * 2.2f ) ) * inertia ) }, true );
            }

            if( step >= fighter.nextFireStep )
            {
                if( inWeaponRange )
                {
                    // lead the mark: flight times here run long enough that unled shots mostly miss.
                    // computed apart from the steering aim, which points at the target's position
                    const float  flight = ( targetDist / spec.shotSpeed );
                    const float3 lead   = float3( ( ( targetPos.x + ( targetVel.x * flight ) ) - pos.x ),
                                                  ( ( targetPos.y + ( targetVel.y * flight ) ) - pos.y ),
                                                  ( ( targetPos.z + ( targetVel.z * flight ) ) - pos.z ) );
                    const float leadLen = crMath::Sqrt( crMath::Dot3( lead, lead ) );

                    if( leadLen > 0.001f )
                    {
                        fighter.nextFireStep = ( step + ToSteps( spec.fireInterval ) );
                        FireShot( app, fighter, pos, float3( ( lead.x / leadLen ), ( lead.y / leadLen ), ( lead.z / leadLen ) ) );
                    }
                }
            }
        }
    }
}
void Game::UpdateImpacts( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const b3ContactEvents events = b3World_GetContactEvents( app->physics->World() );

    for( int32_t i = 0; i < events.beginCount; ++i )
    {
        const b3ContactBeginTouchEvent& touch = events.beginEvents[ i ];

        const entt::entity a = FromShape( touch.shapeIdA );
        const entt::entity b = FromShape( touch.shapeIdB );

        // exactly one side is a shot — filtering already guarantees it never met a friendly
        entt::entity shot   = entt::null;
        entt::entity victim = entt::null;
        if( registry.valid( a ) && registry.all_of<CShot>( a ) )
        {
            shot   = a;
            victim = b;
        }
        else if( registry.valid( b ) && registry.all_of<CShot>( b ) )
        {
            shot   = b;
            victim = a;
        }

        if( shot == entt::null )
            continue;

        CShot& shotData = registry.get<CShot>( shot );
        if( shotData.expireStep == 0 )
            continue;   // already spent earlier in this same batch

        const float3 at     = float3( registry.get<CTransform>( shot ).current.p );
        const float  damage = ( shotData.damage * _tune.damageScale );

        shotData.expireStep = 0;
        _dead.Add( shot );

        if( registry.valid( victim ) == false )
            continue;

        if( registry.all_of<CFighter>( victim ) )
        {
            DamageFighter( app, victim, damage );
        }
        else
        {
            for( int32_t f = 0; f < FACTION_COUNT; ++f )
            {
                if( _cores[ f ].hull == victim )
                {
                    DamageCore( app, static_cast<EFaction>( f ), damage, at );
                    break;
                }
            }
        }
    }

    for( int32_t i = 0; i < _dead.Size(); ++i )
    {
        const entt::entity e = _dead.At( i );
        if( registry.valid( e ) == false )
            continue;

        if( registry.all_of<CFighter>( e ) )
        {
            DestroyFighter( app, e );
        }
        else
        {
            b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
            registry.destroy( e );
        }
    }
    _dead.Clear();
}
void Game::UpdateExpiry( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    const uint64_t  step     = app->SimulationStepIndex();

    // nothing stops a shot at the rim, so anything past the shell is culled here too
    const float bound = ( ARENA_RADIUS * ARENA_RADIUS );

    for( int32_t i = 0; i < _shots.Size(); ++i )
    {
        const entt::entity e = _shots.At( i );
        if( registry.valid( e ) == false )
            continue;

        const CShot& shot = registry.get<CShot>( e );
        if( shot.expireStep == 0 )
            continue;   // spent this step; the impact pass owns its teardown

        if( step < shot.expireStep )
        {
            const float3 p = float3( registry.get<CTransform>( e ).current.p );
            if( crMath::Dot3( p, p ) < bound )
                continue;
        }

        b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
        registry.destroy( e );
    }
}
void Game::UpdateMatchEnd( const crApp* app )
{
    const uint64_t step    = app->SimulationStepIndex();
    const float    elapsed = ( static_cast<float>( step - _matchStartStep ) * crApp::FIXED_TIMESTEP );

    if( elapsed > _tune.suddenDeath )
    {// nothing may hang: past the deadline both cores bleed, and the bleed itself accelerates
        const float over = ( elapsed - _tune.suddenDeath );
        const float rate = ( _tune.suddenRate * ( 1.0f + ( over * 0.05f ) ) * crApp::FIXED_TIMESTEP );

        for( int32_t f = 0; f < FACTION_COUNT; ++f )
            _cores[ f ].hp -= rate;
    }

    bool decided = false;

    if( ( _cores[ 0 ].hp <= 0.0f ) || ( _cores[ 1 ].hp <= 0.0f ) )
    {
        _winner = ( _cores[ 0 ].hp > _cores[ 1 ].hp ) ? EFaction::BLUE : EFaction::RED;
        decided = true;
    }
    else
    {// both hangars empty and no hull left flying — neither can act again, so score it on core damage
        bool exhausted = true;
        for( int32_t f = 0; f < FACTION_COUNT; ++f )
        {
            if( ( _commanders[ f ].pool > 0 ) || ( _fighters[ f ].Size() > 0 ) )
            {
                exhausted = false;
                break;
            }
        }

        if( exhausted )
        {
            const float blueFrac = ( _cores[ 0 ].hp / _cores[ 0 ].hpMax );
            const float redFrac  = ( _cores[ 1 ].hp / _cores[ 1 ].hpMax );
            _winner = ( blueFrac >= redFrac ) ? EFaction::BLUE : EFaction::RED;
            decided = true;
        }
    }

    if( decided == false )
        return;

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        if( _cores[ f ].hp < 0.0f )
            _cores[ f ].hp = 0.0f;
    }

    {// the streak the next match arms from
        const int32_t won  = static_cast<int32_t>( _winner );
        const int32_t lost = static_cast<int32_t>( Enemy( _winner ) );

        _lossStreak[ won ]  = 0;
        _lossStreak[ lost ] += 1;
    }

    ++_wins[ static_cast<int32_t>( _winner ) ];
    _matchState  = EMatchState::RESOLVE;
    _resolveStep = ( step + ToSteps( _tune.resolveSeconds ) );
    _hitPulse    = 1.0f;
    _shakeMag    = 1.0f;

    _hashStampPending = true;

    const int32_t loser = static_cast<int32_t>( Enemy( _winner ) );
    SpawnBurst( app, _cores[ loser ].pos, FactionColor( static_cast<EFaction>( loser ) ), 5.0f, 1.2f );
    app->graphics->PostProcess()->QueueRipple( _cores[ loser ].pos, 26.0f, 1.2f, 0.05f );
}
void Game::StampDeterminism( const crApp* app )
{
    const uint64_t step = app->SimulationStepIndex();

    bool stamp = _hashStampPending;
    for( size_t i = 0; i < SDL_arraysize( HASH_STAMP_STEPS ); ++i )
    {
        if( step == HASH_STAMP_STEPS[ i ] )
        {
            stamp = true;
            break;
        }
    }

    if( stamp == false )
        return;

    // the rng stream separates the two ways a run diverges: transforms drifting apart is float noise,
    // the stream itself moving is a branch that was taken differently and dragged every later draw with it
    const crRandomState rngState = _rng.GetState();

    char tail[ 32 ] = "";
    if( _hashStampPending )
    {
        SDL_snprintf( tail, sizeof( tail ), " match-end:%d", _matchIndex );
        _hashStampPending = false;
    }

    SDL_LogInfo( SDL_LOG_CATEGORY_APPLICATION, "[step:%" SDL_PRIu64 "] hash:%016" SDL_PRIx64 " rng:%016" SDL_PRIx64 "%s",
                 step,
                 app->ecs->HashTransforms(),
                 crMath::HashFnv1a( &rngState, sizeof( rngState ), crMath::HASH_FNV1A_SEED ),
                 tail );
}

void Game::SpawnSquadron( const crApp* app, EFaction faction, EHull hull, int32_t count )
{
    if( count <= 0 )
        return;

    const bool   blue = ( faction == EFaction::BLUE );
    const float3 core = _cores[ static_cast<int32_t>( faction ) ].pos;
    const float3 nose = float3( blue ? 1.0f : -1.0f, 0.0f, 0.0f );

    const float3 scatter = RandomDirection( &_rng );
    const float  spread  = _rng.NextFloat32( 0.0f, 8.0f );
    const float  offset  = _rng.NextFloat32( 4.0f, 9.0f );
    const float3 anchor  = float3( ( core.x + ( nose.x * offset ) + ( scatter.x * spread ) ),
                                   ( core.y + ( scatter.y * spread ) ),
                                   ( core.z + ( scatter.z * spread ) ) );

    // the wedge plane is randomised through the launch axis, so it cannot collapse to a line whenever
    // the camera happens to line up with a fixed one
    float3 side = crMath::Cross3( nose, RandomDirection( &_rng ) );
    if( crMath::Dot3( side, side ) < 0.0001f )
        side = float3( 0.0f, 1.0f, 0.0f );
    side = crMath::Normalize3( side );

    const float spacing = ( HULL_SPECS[ static_cast<int32_t>( hull ) ].radius * 5.0f );
    const float center  = ( static_cast<float>( count - 1 ) * 0.5f );

    for( int32_t i = 0; i < count; ++i )
    {
        const float lateral = ( ( static_cast<float>( i ) - center ) * spacing );
        const float trail   = ( crMath::Abs( lateral ) * 0.7f );   // the flanks sit back — a V, not a rank

        SpawnFighter( app, faction, hull, float3( ( anchor.x + ( side.x * lateral ) - ( nose.x * trail ) ),
                                                  ( anchor.y + ( side.y * lateral ) - ( nose.y * trail ) ),
                                                  ( anchor.z + ( side.z * lateral ) - ( nose.z * trail ) ) ) );
    }
}
void Game::SpawnFighter( const crApp* app, EFaction faction, EHull hull, float3 pos )
{
    entt::registry& registry = app->ecs->registry;
    const int32_t   index    = static_cast<int32_t>( faction );
    const bool      blue     = ( faction == EFaction::BLUE );
    const HullSpec& spec     = HULL_SPECS[ static_cast<int32_t>( hull ) ];

    const float3 nose = float3( blue ? 1.0f : -1.0f, 0.0f, 0.0f );   // launched facing the enemy

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type           = b3_dynamicBody;
    bodyDef.position       = { pos.x, pos.y, pos.z };
    bodyDef.rotation       = QuatFromXTo( nose );
    bodyDef.enableSleep    = false;
    bodyDef.linearDamping  = 0.9f;
    bodyDef.angularDamping = 1.2f;

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density                  = spec.density;
    shapeDef.baseMaterial.friction    = 0.1f;
    shapeDef.baseMaterial.restitution = 0.15f;
    shapeDef.filter.categoryBits      = ( blue ? CAT_SHIP_BLUE : CAT_SHIP_RED );
    shapeDef.filter.maskBits          = ( CAT_SHIP_BLUE | CAT_SHIP_RED | CAT_CORE_BLUE | CAT_CORE_RED |
                                          ( blue ? CAT_SHOT_RED : CAT_SHOT_BLUE ) );
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, spec.radius };
    b3CreateSphereShape( body, &shapeDef, &sphere );

    const entt::entity e = CreateDynamicEntity( registry, body, bodyDef.position, bodyDef.rotation );
    b3Body_SetUserData( body, ToUserData( e ) );

    // pale / saturated / deep within the one faction hue — see HullSpec::tintWhite
    const color4 tinted = crMath::LerpColor( FactionColor( faction ), color4( 1.0f, 1.0f, 1.0f, 1.0f ), spec.tintWhite );
    const color4 base   = color4( ( tinted.r * spec.tintValue ), ( tinted.g * spec.tintValue ), ( tinted.b * spec.tintValue ), 1.0f );

    CFighter fighter;
    fighter.baseColor    = base;
    fighter.target       = entt::null;
    fighter.nextFireStep = ( app->SimulationStepIndex() + ToSteps( _rng.NextFloat32( 0.0f, spec.fireInterval ) ) );
    fighter.retargetStep = ( app->SimulationStepIndex() + ToSteps( _rng.NextFloat32( 0.0f, _tune.retargetInterval ) ) );
    fighter.orbitAxis    = RandomDirection( &_rng );
    fighter.hp           = spec.hp;
    fighter.flash        = 0.0f;
    fighter.faction      = faction;
    fighter.hull         = hull;
    registry.emplace<CFighter>( e, fighter );

    CPrimitive primitive;
    primitive.shape    = spec.shape;
    primitive.scale    = ( hull == EHull::LANCER ) ? float3( ( spec.radius * 1.5f ), ( spec.radius * 0.55f ), ( spec.radius * 0.55f ) )
                                                   : float3( spec.radius, spec.radius, spec.radius );
    primitive.color    = base;
    primitive.emissive = spec.emissive;
    primitive.fresnel  = 0.35f;
    registry.emplace<CPrimitive>( e, primitive );

    CRibbonTrail trail;
    trail.color         = base;
    trail.tailColor     = color4( 0.0f, 0.0f, 0.0f, 1.0f );   // fades out rather than to a second hue
    trail.headHalfWidth = ( spec.radius * 0.50f );   // scaled up against the 70% hull cut so trails still read
    // a hull leaves the shader at albedo * ( lit + emissive ) with lit about 1, a trail at
    // color * intensity — so matching the colour is not enough, the intensity has to carry the same
    // factor or a dark hull tows a brighter trail than itself. 0.55 then dims the whole set evenly
    trail.intensity     = ( ( spec.emissive + 1.0f ) * 0.55f );
    trail.emitDistance  = 0.35f;
    trail.fadeDuration  = 0.5f;
    trail.duration      = 0.7f;
    trail.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( e, trail );

    _fighters[ index ].Add( e );
}
void Game::FireShot( const crApp* app, const CFighter& shooter, float3 from, float3 dir )
{
    entt::registry& registry = app->ecs->registry;
    const bool      blue     = ( shooter.faction == EFaction::BLUE );
    const HullSpec& spec     = HULL_SPECS[ static_cast<int32_t>( shooter.hull ) ];

    const float muzzle = ( spec.radius + 0.35f );

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type        = b3_dynamicBody;
    bodyDef.position    = { ( from.x + ( dir.x * muzzle ) ), ( from.y + ( dir.y * muzzle ) ), ( from.z + ( dir.z * muzzle ) ) };
    bodyDef.rotation    = QuatFromXTo( dir );
    bodyDef.enableSleep = false;
    bodyDef.motionLocks.angularX = true;   // the tracer bar is aimed at birth; nothing may tumble it
    bodyDef.motionLocks.angularY = true;
    bodyDef.motionLocks.angularZ = true;

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density             = 0.05f;
    shapeDef.enableContactEvents = true;
    shapeDef.filter.categoryBits = ( blue ? CAT_SHOT_BLUE : CAT_SHOT_RED );
    shapeDef.filter.maskBits     = ( blue ? ( CAT_SHIP_RED | CAT_CORE_RED ) : ( CAT_SHIP_BLUE | CAT_CORE_BLUE ) );
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, SHOT_RADIUS };
    b3CreateSphereShape( body, &shapeDef, &sphere );

    b3Body_SetLinearVelocity( body, { ( dir.x * spec.shotSpeed ), ( dir.y * spec.shotSpeed ), ( dir.z * spec.shotSpeed ) } );

    const entt::entity e = CreateDynamicEntity( registry, body, bodyDef.position, bodyDef.rotation );
    b3Body_SetUserData( body, ToUserData( e ) );

    CShot shot;
    shot.expireStep = ( app->SimulationStepIndex() + ToSteps( spec.shotLife ) );
    shot.damage     = spec.damage;
    shot.faction    = shooter.faction;
    registry.emplace<CShot>( e, shot );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::BOX;
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.blend    = EBlendMode::ADDITIVE;
    primitive.scale    = float3( 0.42f, 0.055f, 0.055f );
    primitive.color    = EnergyColor( shooter.faction );
    primitive.emissive = 1.0f;   // low: a tracer that blooms hard smears into its neighbours, and there
                                 // are hundreds of them at once
    registry.emplace<CPrimitive>( e, primitive );

    _shots.Add( e );
}
void Game::DamageFighter( const crApp* app, entt::entity e, float damage )
{
    entt::registry& registry = app->ecs->registry;

    CFighter& fighter = registry.get<CFighter>( e );
    if( fighter.hp <= 0.0f )
        return;   // already queued for teardown — the queue insert below must happen exactly once

    fighter.hp    -= damage;
    fighter.flash  = 1.0f;

    if( fighter.hp <= 0.0f )
        _dead.Add( e );
}
void Game::DestroyFighter( const crApp* app, entt::entity e )
{
    entt::registry& registry = app->ecs->registry;

    const CFighter& fighter = registry.get<CFighter>( e );
    const HullSpec& spec    = HULL_SPECS[ static_cast<int32_t>( fighter.hull ) ];
    const float3    pos     = float3( registry.get<CTransform>( e ).current.p );
    const color4    color   = fighter.baseColor;

    // well past the hull radius: at radius alone a death is a handful of specks
    const float burst  = ( spec.radius * 2.5f );
    const float ripple = spec.rippleRadius;
    const float life   = spec.rippleLife;

    ++_commanders[ static_cast<int32_t>( fighter.faction ) ].losses;
    ++_commanders[ static_cast<int32_t>( Enemy( fighter.faction ) ) ].kills;

    b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
    registry.destroy( e );

    SpawnBurst( app, pos, color, burst, 0.12f );
    app->graphics->PostProcess()->QueueRipple( pos, ripple, life, 0.020f );
}
void Game::DamageCore( const crApp* app, EFaction faction, float damage, float3 at )
{
    Core& core = _cores[ static_cast<int32_t>( faction ) ];
    if( core.hp <= 0.0f )
        return;

    core.hp   -= damage;
    core.flash = 1.0f;

    // a floor, not an accumulation: a core under assault takes tens of hits a second, and a summed
    // impulse would peg the shake at maximum for the whole engagement
    _hitPulse = crMath::Clamp( ( _hitPulse + 0.12f ), 0.0f, 1.0f );
    if( _shakeMag < 0.22f )
        _shakeMag = 0.22f;

    SpawnBurst( app, at, FactionColor( faction ), 0.9f, 0.08f );
}

entt::entity Game::PickTarget( const crApp* app, const CFighter& fighter, float3 pos )
{
    entt::registry& registry = app->ecs->registry;

    const Commander& cmd   = _commanders[ static_cast<int32_t>( fighter.faction ) ];
    const int32_t    enemy = static_cast<int32_t>( Enemy( fighter.faction ) );
    const float      reach = ( cmd.engageRange * cmd.engageRange );

    entt::entity best      = entt::null;
    float        bestScore = 0.0f;

    for( int32_t i = 0; i < _fighters[ enemy ].Size(); ++i )
    {
        const entt::entity other = _fighters[ enemy ].At( i );
        if( registry.valid( other ) == false )
            continue;

        const float3 p  = float3( registry.get<CTransform>( other ).current.p );
        const float  dx = ( p.x - pos.x );
        const float  dy = ( p.y - pos.y );
        const float  dz = ( p.z - pos.z );
        const float  d2 = ( ( dx * dx ) + ( dy * dy ) + ( dz * dz ) );
        if( d2 > reach )
            continue;

        // nearest by default; a focus-fire doctrine trades distance for a hull that is already hurt
        const CFighter& hostile = registry.get<CFighter>( other );
        const float     hurt    = ( 1.0f - crMath::Clamp01( hostile.hp / HULL_SPECS[ static_cast<int32_t>( hostile.hull ) ].hp ) );
        const float     score   = ( ( 1.0f / ( d2 + 1.0f ) ) * ( 1.0f + ( hurt * cmd.focusFire * 3.0f ) ) );

        if( score > bestScore )
        {
            bestScore = score;
            best      = other;
        }
    }

    return best;
}
void Game::Compact( const crApp* app, crArray<entt::entity>* list )
{
    entt::registry& registry = app->ecs->registry;

    int32_t write = 0;
    for( int32_t i = 0; i < list->Size(); ++i )
    {
        const entt::entity e = list->At( i );
        if( registry.valid( e ) == false )
            continue;

        list->At( write ) = e;
        ++write;
    }

    while( list->Size() > write )
        list->Pop();
}
void Game::SpawnBurst( const crApp* app, float3 pos, color4 color, float scale, float seconds )
{
    entt::registry& registry = app->ecs->registry;

    const entt::entity e = registry.create();
    registry.emplace<CTag_NonHashTarget>( e );

    CTransform t;
    t.current  = { { pos.x, pos.y, pos.z }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    CParticleEmitter erupt;
    erupt.color        = crMath::LerpColor( color, color4( 1.0f, 1.0f, 1.0f, 1.0f ), 0.35f );
    erupt.colorEnd     = color4( ( color.r * 0.25f ), ( color.g * 0.12f ), ( color.b * 0.08f ), 1.0f );
    erupt.gravity      = float3( 0.0f, 0.0f, 0.0f );
    erupt.drag         = 1.4f;
    erupt.rate         = ( 180.0f * scale );
    erupt.spread       = ( 5.0f * scale );
    erupt.lifetimeMin  = 0.16f;
    erupt.lifetimeMax  = ( 0.42f * scale );
    erupt.sizeMin      = ( 0.05f * scale );
    erupt.sizeMax      = ( 0.17f * scale );
    erupt.sizeEndScale = 0.15f;
    erupt.intensityMin = 2.0f;
    erupt.intensityMax = 4.0f;
    erupt.spriteIndex  = _fxSpark;
    erupt.killStep     = ( app->SimulationStepIndex() + ToSteps( seconds ) );
    registry.emplace<CParticleEmitter>( e, erupt );
}

void Game::UpdateCamera( crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    // a bounding SPHERE about the centroid, because the camera orbits — an axis-aligned extent would
    // breathe as the view swings around
    float   sumX  = 0.0f;
    float   sumY  = 0.0f;
    float   sumZ  = 0.0f;
    int32_t count = 0;

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        for( int32_t i = 0; i < _fighters[ f ].Size(); ++i )
        {
            const entt::entity e = _fighters[ f ].At( i );
            if( registry.valid( e ) == false )
                continue;

            const float3 p = float3( registry.get<CTransform>( e ).current.p );
            sumX += p.x;
            sumY += p.y;
            sumZ += p.z;
            ++count;
        }
    }

    float3 wantTarget = float3( 0.0f, 0.0f, 0.0f );
    float  wantDist   = _tune.camDistMax;

    if( count > 0 )
    {
        const float inv = ( 1.0f / static_cast<float>( count ) );
        wantTarget = float3( ( sumX * inv ), ( sumY * inv ), ( sumZ * inv ) );

        // RMS spread, not the farthest hull: a max is decided by ONE outlier and steps whenever that
        // outlier dies, so the framing would jump however hard the smoothing pulled
        float sum2 = 0.0f;
        for( int32_t f = 0; f < FACTION_COUNT; ++f )
        {
            for( int32_t i = 0; i < _fighters[ f ].Size(); ++i )
            {
                const entt::entity e = _fighters[ f ].At( i );
                if( registry.valid( e ) == false )
                    continue;

                const float3 p  = float3( registry.get<CTransform>( e ).current.p );
                const float  dx = ( p.x - wantTarget.x );
                const float  dy = ( p.y - wantTarget.y );
                const float  dz = ( p.z - wantTarget.z );
                sum2 += ( ( dx * dx ) + ( dy * dy ) + ( dz * dz ) );
            }
        }

        // fitting a sphere of radius S inside a vertical fov F needs S / sin( F / 2 ) —
        // at the 60 degree default that is exactly 2S
        const float spread = crMath::Sqrt( sum2 * inv );
        const float fit    = crMath::Sin( app->camera->EffectiveFovY() * 0.5f );
        wantDist = crMath::Clamp( ( spread * _tune.camMargin ) / ( ( fit > 0.01f ) ? fit : 0.01f ),
                                  _tune.camDistMin, _tune.camDistMax );
    }

    if( _matchState == EMatchState::RESOLVE )
    {// swing onto the wreck for the beat between matches
        const float3 loser = _cores[ static_cast<int32_t>( Enemy( _winner ) ) ].pos;
        wantTarget = crMath::Lerp3( wantTarget, loser, 0.75f );
        wantDist   = crMath::Lerp( wantDist, _tune.camDistMin, 0.55f );
    }

    if( _camValid == false )
    {
        _camTarget = wantTarget;
        _camDist   = wantDist;
        _camValid  = true;
    }
    else
    {
        // clamped separately from _renderDt: a frame hitch feeds crApp's 0.25 s delta straight in and
        // k would jump to 0.45, snapping the camera on exactly the frames that can least afford it
        const float smoothDt = ( ( _renderDt < 0.04f ) ? _renderDt : 0.04f );
        const float k        = crMath::Clamp01( _tune.camFollow * smoothDt );

        _camTarget = crMath::Lerp3( _camTarget, wantTarget, k );
        _camDist   = crMath::Lerp( _camDist, wantDist, k );
    }

    _camTravel  += ( _tune.camTravelRate * _renderDt );
    _camPrecess += ( _tune.camPrecessRate * _renderDt );
    if( _camTravel > ( 2.0f * crMath::PI ) )
        _camTravel -= ( 2.0f * crMath::PI );
    if( _camPrecess > ( 2.0f * crMath::PI ) )
        _camPrecess -= ( 2.0f * crMath::PI );

    float3 at = _camTarget;
    if( ( _shakeMag > 0.0f ) && ( _tune.camShake > 0.0f ) )
    {
        _shakePhase += ( _renderDt * 41.0f );
        if( _shakePhase > ( 2.0f * crMath::PI ) )
            _shakePhase -= ( 2.0f * crMath::PI );

        const float amp = ( _shakeMag * 0.9f * _tune.camShake );
        at.x += ( crMath::Sin( _shakePhase * 1.7f ) * amp );
        at.y += ( crMath::Sin( _shakePhase * 2.3f ) * amp );
    }

    {// ride the great circle, then read the rig's yaw/pitch back off the direction it lands on.
        // crCamera builds its eye direction as ( cos(pitch)sin(yaw), sin(pitch), cos(pitch)cos(yaw) ),
        // so inverting it is one asin and one atan2
        constexpr float INCLINATION = 0.85f;   // sin( 0.85 ) = 0.75, well inside crCamera's pitch limit

        const float2 travel = crMath::CosSin( _camTravel );
        const float2 incl   = crMath::CosSin( INCLINATION );
        const float2 prec   = crMath::CosSin( _camPrecess );

        // the circle's low point sits on +Z, not +X, because the cores are ON the x axis: phased the
        // other way the eye looks straight down the core line at zero pitch twice a circuit, which is
        // the one view where the two fleets hide behind each other. here the eye's x component peaks
        // at cos( INCLINATION ) = 0.66, and only while pitched most steeply up
        const float3 tilted = float3( ( travel.y * incl.x ), ( travel.y * incl.y ), travel.x );
        const float3 dir    = float3( ( ( tilted.x * prec.x ) + ( tilted.z * prec.y ) ),
                                      tilted.y,
                                      ( ( tilted.z * prec.x ) - ( tilted.x * prec.y ) ) );

        app->camera->SetOrbit( crMath::Atan2( dir.x, dir.z ), crMath::Asin( dir.y ) );
    }

    app->camera->SetTarget( at );
    app->camera->SetDistance( _camDist );
}
void Game::UpdatePresentation( crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    _shakeMag = crMath::Clamp( ( _shakeMag - ( _renderDt * 1.6f ) ), 0.0f, 1.0f );
    _hitPulse = crMath::Clamp( ( _hitPulse - ( _renderDt * 2.2f ) ), 0.0f, 1.0f );

    _blastFlash = crMath::Clamp( ( _blastFlash - ( _renderDt * 1.7f ) ), 0.0f, 1.0f );
    _sloMo      = crMath::Clamp( ( _sloMo - ( _renderDt * 2.4f ) ), 0.0f, 1.0f );

    crPostProcess* pp = app->graphics->PostProcess();
    pp->chromatic.enabled  = ( _hitPulse > 0.01f );
    pp->chromatic.strength = ( 0.0016f + ( _hitPulse * 0.006f ) );
    pp->vignette.intensity = ( 0.42f + ( _hitPulse * 0.35f ) );

    {
        pp->radialBlur.enabled = ( _blastFlash > 0.01f );
        if( pp->radialBlur.enabled )
        {
            const crScreenPoint at = app->camera->WorldToScreen( _blastFocus );
            const int2          vp = app->camera->Viewport();

            if( at.visible && ( vp.x > 0 ) && ( vp.y > 0 ) )
            {
                // WorldToScreen is pixels from the top-left; the pass samples a GL texture, bottom-left
                pp->radialBlur.center = float2( ( at.pos.x / static_cast<float>( vp.x ) ),
                                                ( 1.0f - ( at.pos.y / static_cast<float>( vp.y ) ) ) );
            }

            pp->radialBlur.strength = ( _blastFlash * _blastFlash * 0.30f );
            pp->radialBlur.samples  = 12;
        }
    }

    for( int32_t i = 0; i < RING_COUNT; ++i )
    {
        if( registry.valid( _boundaryRings[ i ] ) == false )
            continue;

        if( ( _ringAxis[ i ].x == 0.0f ) && ( _ringAxis[ i ].y == 0.0f ) && ( _ringAxis[ i ].z == 0.0f ) )
            continue;

        CTransform& t = registry.get<CTransform>( _boundaryRings[ i ] );
        t.current.q = crMath::IntegrateRotation( t.current.q, _ringAxis[ i ], _renderDt );
        t.previous  = t.current;
    }

    if( registry.valid( _glowRing ) )
    {// the silhouette of a sphere seen from distance d is a circle of radius R*sqrt(d^2-R^2)/d, sitting
        // R^2/d from the centre toward the eye. an eye inside the sphere has no silhouette at all
        const float3 eye = app->camera->EyePosition();
        const float  d   = crMath::Sqrt( crMath::Dot3( eye, eye ) );

        CTransform& t   = registry.get<CTransform>( _glowRing );
        CPrimitive& rim = registry.get<CPrimitive>( _glowRing );

        if( d <= ( ARENA_RADIUS + 0.5f ) )
        {
            rim.scale = float3( 0.0f, 0.0f, 0.0f );
        }
        else
        {
            const float3 dir    = float3( ( eye.x / d ), ( eye.y / d ), ( eye.z / d ) );
            const float  radius = ( ( ARENA_RADIUS * crMath::Sqrt( ( d * d ) - ( ARENA_RADIUS * ARENA_RADIUS ) ) ) / d );
            const float  offset = ( ( ARENA_RADIUS * ARENA_RADIUS ) / d );

            t.current  = { { ( dir.x * offset ), ( dir.y * offset ), ( dir.z * offset ) },
                           QuatFromTo( float3( 0.0f, 0.0f, 1.0f ), dir ) };
            t.previous = t.current;

            rim.scale = float3( radius, radius, 1.0f );
        }
    }

    {// aimed at the eye, so a ring never presents as an edge-on sliver
        const float3 eye = app->camera->EyePosition();

        for( int32_t i = 0; i < BLAST_RING_POOL; ++i )
        {
            BlastRing& ring = _blastRings[ i ];
            if( ( ring.life <= 0.0f ) || ( registry.valid( ring.entity ) == false ) )
                continue;

            ring.age += _renderDt;

            CPrimitive& prim = registry.get<CPrimitive>( ring.entity );
            if( ring.age >= ring.life )
            {
                ring.life = 0.0f;
                prim.scale = float3( 0.0f, 0.0f, 0.0f );   // parked, never destroyed
                continue;
            }

            const float open = crMath::Clamp01( ring.age / ring.grow );
            const float fade = ( 1.0f - crMath::Clamp01( ring.age / ring.life ) );
            const float r    = ( ring.radius * ( 1.0f - ( ( 1.0f - open ) * ( 1.0f - open ) ) ) );   // fast out, easing

            const bool flat = ( ring.shape == EPrimitive::RING_THIN );

            CTransform& t = registry.get<CTransform>( ring.entity );
            if( flat )
            {
                const float3 toEye = float3( ( eye.x - ring.pos.x ), ( eye.y - ring.pos.y ), ( eye.z - ring.pos.z ) );
                t.current = { { ring.pos.x, ring.pos.y, ring.pos.z },
                              QuatFromTo( float3( 0.0f, 0.0f, 1.0f ), crMath::Normalize3( toEye ) ) };
            }
            else
            {
                t.current = { { ring.pos.x, ring.pos.y, ring.pos.z }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
            }
            t.previous = t.current;

            prim.shape    = ring.shape;
            prim.scale    = flat ? float3( r, r, 1.0f ) : float3( r, r, r );
            prim.color    = ring.color;
            prim.emissive = ( ring.emissive * fade * fade );
        }
    }

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        Core& core = _cores[ f ];
        core.flash = crMath::Clamp( ( core.flash - ( _renderDt * 3.0f ) ), 0.0f, 1.0f );

        const float frac = crMath::Clamp01( core.hp / core.hpMax );

        if( registry.valid( core.hull ) )
        {
            CPrimitive& prim = registry.get<CPrimitive>( core.hull );
            prim.color    = crMath::LerpColor( FactionColor( static_cast<EFaction>( f ) ), color4( 1.0f, 1.0f, 1.0f, 1.0f ), core.flash );
            prim.emissive = ( 0.5f + ( frac * 0.9f ) + ( core.flash * 2.5f ) );
        }

        {// counter-turning, so the pair reads as machinery rather than as decals
            const float3 spin[ 2 ] = { float3( 0.0f, 0.62f, 0.0f ), float3( -0.44f, 0.0f, 0.18f ) };

            for( int32_t k = 0; k < 2; ++k )
            {
                if( registry.valid( core.halo[ k ] ) == false )
                    continue;

                CTransform& t = registry.get<CTransform>( core.halo[ k ] );
                t.current.q = crMath::IntegrateRotation( t.current.q, spin[ k ], _renderDt );
                t.previous  = t.current;
            }
        }

        if( registry.valid( core.shield ) )
        {
            const float beat  = ( 1.0f + ( crMath::Sin( _camTime * ( 1.6f + ( ( 1.0f - frac ) * 5.0f ) ) ) * 0.045f ) );
            const float scale = ( CORE_RADIUS * ( 1.35f + ( frac * 0.55f ) ) * beat );

            CPrimitive& shell = registry.get<CPrimitive>( core.shield );
            shell.scale    = float3( scale, scale, scale );
            shell.emissive = ( ( 0.10f + ( frac * 0.30f ) ) + ( core.flash * 0.9f ) );
        }
    }

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        for( int32_t i = 0; i < _fighters[ f ].Size(); ++i )
        {
            const entt::entity e = _fighters[ f ].At( i );
            if( registry.valid( e ) == false )
                continue;

            CFighter& fighter = registry.get<CFighter>( e );
            if( fighter.flash <= 0.0f )
                continue;

            fighter.flash = crMath::Clamp( ( fighter.flash - ( _renderDt * 4.5f ) ), 0.0f, 1.0f );

            CPrimitive& prim = registry.get<CPrimitive>( e );
            prim.color = crMath::LerpColor( fighter.baseColor, color4( 1.0f, 1.0f, 1.0f, 1.0f ), fighter.flash );
        }
    }
}

void Game::RenderScoreboard( crApp* app )
{
    crUi*        ui     = app->ui;
    const float2 canvas = ui->CanvasSize();

    constexpr float BAR_W  = 300.0f;
    constexpr float BAR_H  = 12.0f;
    constexpr float MARGIN = 28.0f;

    char line[ 96 ];

    for( int32_t f = 0; f < FACTION_COUNT; ++f )
    {
        const EFaction   faction = static_cast<EFaction>( f );
        const Commander& cmd     = _commanders[ f ];
        const Core&      core    = _cores[ f ];
        const bool       left    = ( faction == EFaction::BLUE );
        const color4     color   = FactionColor( faction );

        const EUiAnchor anchor = left ? EUiAnchor::TOP_LEFT : EUiAnchor::TOP_RIGHT;
        const float     sign   = left ? 1.0f : -1.0f;

        ui->Label( anchor, float2( ( MARGIN * sign ), -MARGIN ), FactionName( faction ), _font16, color );

        ui->Label( anchor, float2( ( MARGIN * sign ), -( MARGIN + 22.0f ) ), DoctrineName( cmd ), _font12,
                   crMath::LerpColor( color, color4( 1.0f, 1.0f, 1.0f, 1.0f ), 0.35f ) );

        SDL_snprintf( line, sizeof( line ), "reserve %d   live %d   kills %d", cmd.pool, _fighters[ f ].Size(), cmd.kills );
        ui->Label( anchor, float2( ( MARGIN * sign ), -( MARGIN + 40.0f ) ), line, _font12, color4( 0.72f, 0.76f, 0.86f, 1.0f ) );

        // a crUi widget's pivot IS its anchor, so a TOP_RIGHT widget already extends leftward by its
        // own width. the offset is therefore only the margin from that corner — subtracting the width
        // as well pushes each element left by its own size, and frame and fill stop overlapping
        const float edgeX = ( MARGIN * sign );

        {// core integrity
            const float frac = crMath::Clamp01( core.hp / core.hpMax );

            ui->Image( anchor, float2( edgeX, -( MARGIN + 62.0f ) ), float2( BAR_W, BAR_H ), _uiFill,
                       color4( 0.10f, 0.11f, 0.16f, 1.0f ) );

            // each side keeps its fill pinned to its OWN outer edge, so both deplete toward the centre
            const float fillW = ( BAR_W * frac );
            if( fillW > 0.5f )
                ui->Image( anchor, float2( edgeX, -( MARGIN + 62.0f ) ), float2( fillW, BAR_H ), _uiFill, color );
        }

        {// reserve
            const float frac  = crMath::Clamp01( static_cast<float>( cmd.pool ) / static_cast<float>( ( _tune.poolPerSide > 0 ) ? _tune.poolPerSide : 1 ) );
            const float fillW = ( BAR_W * frac );
            if( fillW > 0.5f )
                ui->Image( anchor, float2( edgeX, -( MARGIN + 80.0f ) ), float2( fillW, 5.0f ), _uiFill,
                           color4( ( color.r * 0.55f ), ( color.g * 0.55f ), ( color.b * 0.55f ), 1.0f ) );
        }

        {// one pip per shot granted, lit while the side still holds it
            constexpr float PIP_W = 22.0f;
            constexpr float PIP_H = 6.0f;
            constexpr float PIP_GAP = 5.0f;

            // faction colour, not the energy colour the blast wears: these sit inside a row already
            // headed by the faction name, where a green pip would read as belonging to neither side
            const bool   cooling = ( app->SimulationStepIndex() < cmd.ultimateReadyStep );
            const color4 lit     = FactionColor( faction );
            const color4 spent   = color4( 0.16f, 0.17f, 0.22f, 1.0f );

            for( int32_t p = 0; p < cmd.ultimateGranted; ++p )
            {
                const bool   held = ( p < cmd.ultimateCharges );
                const color4 pip  = held ? ( cooling ? crMath::LerpColor( spent, lit, 0.35f ) : lit ) : spent;

                ui->Image( anchor, float2( ( ( MARGIN + ( static_cast<float>( p ) * ( PIP_W + PIP_GAP ) ) ) * sign ), -( MARGIN + 94.0f ) ),
                           float2( PIP_W, PIP_H ), _uiFill, pip );
            }
        }
    }

    const float elapsed = ( static_cast<float>( app->SimulationStepIndex() - _matchStartStep ) * crApp::FIXED_TIMESTEP );

    SDL_snprintf( line, sizeof( line ), "MATCH %d   %d : %d", _matchIndex, _wins[ 0 ], _wins[ 1 ] );
    ui->Label( EUiAnchor::TOP, float2( 0.0f, -MARGIN ), line, _font16, color4( 0.88f, 0.90f, 0.96f, 1.0f ) );

    if( app->SimulationStepIndex() < _ultBannerStep )
    {
        SDL_snprintf( line, sizeof( line ), "%s  %s", FactionName( _ultBannerFaction ), UltimateName( _ultBannerFaction ) );
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, ( canvas.y * 0.30f ) ), line, _font16, FactionColor( _ultBannerFaction ) );
    }

    if( _matchState == EMatchState::RESOLVE )
    {
        SDL_snprintf( line, sizeof( line ), "%s WINS", FactionName( _winner ) );
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, ( canvas.y * 0.22f ) ), line, _font16, FactionColor( _winner ) );
    }
    else if( elapsed > _tune.suddenDeath )
    {
        ui->Label( EUiAnchor::TOP, float2( 0.0f, -( MARGIN + 24.0f ) ), "SUDDEN DEATH", _font12, color4( 1.0f, 0.55f, 0.35f, 1.0f ) );
    }
    else
    {
        SDL_snprintf( line, sizeof( line ), "%.0fs", elapsed );
        ui->Label( EUiAnchor::TOP, float2( 0.0f, -( MARGIN + 24.0f ) ), line, _font12, color4( 0.55f, 0.58f, 0.68f, 1.0f ) );
    }
}

// --- input probe ------------------------------------------------------------------------------
//
// framework test scaffolding, kept apart from the battle: the skirmish runs with no player at all,
// so exercising crInputSystem here means layering over it. the cancel path, the stick-vs-UI claim
// handoff and the per-step button edge can only be judged by hand, on a device

void InputProbe::Configure( const crApp* app, int32_t font, int32_t panelSprite )
{
    _font        = font;
    _panelSprite = panelSprite;

    // halves of the canvas, minus a top strip so the scoreboard row is not a claim surface
    const float2 canvas = float2( static_cast<float>( crScreenRef::WIDTH ), static_cast<float>( crScreenRef::HEIGHT ) );

    crStickConfig stick;
    stick.regionMin = float2( 0.0f, 0.0f );
    stick.regionMax = float2( canvas.x * 0.5f, canvas.y - 120.0f );
    stick.keyUp     = SDL_SCANCODE_W;
    stick.keyDown   = SDL_SCANCODE_S;
    stick.keyLeft   = SDL_SCANCODE_A;
    stick.keyRight  = SDL_SCANCODE_D;
    app->inputSys->ConfigureStick( EStickId::LEFT, &stick );

    stick.regionMin = float2( canvas.x * 0.5f, 0.0f );
    stick.regionMax = float2( canvas.x, canvas.y - 120.0f );
    stick.keyUp     = SDL_SCANCODE_UP;
    stick.keyDown   = SDL_SCANCODE_DOWN;
    stick.keyLeft   = SDL_SCANCODE_LEFT;
    stick.keyRight  = SDL_SCANCODE_RIGHT;
    app->inputSys->ConfigureStick( EStickId::RIGHT, &stick );

    crButtonConfig button;
    button.key   = SDL_SCANCODE_Z;
    button.pad   = SDL_GAMEPAD_BUTTON_SOUTH;
    button.mouse = SDL_BUTTON_RIGHT;   // left belongs to the UI pointer
    app->inputSys->ConfigureButton( ROUND_SLOT, &button );

    button.key   = SDL_SCANCODE_X;
    button.pad   = SDL_GAMEPAD_BUTTON_EAST;
    button.mouse = SDL_BUTTON_MIDDLE;
    app->inputSys->ConfigureButton( RECT_SLOT, &button );
}
void InputProbe::FixedStep( const crApp* app )
{
    static const char* SLOT_NAMES[] = { "Z round", "X rect" };

    for( int32_t i = 0; i < 2; ++i )
    {
        const int32_t presses  = app->inputSys->ButtonPressCount( i );
        const int32_t releases = app->inputSys->ButtonReleaseCount( i );

        if( ( presses == 0 ) && ( releases == 0 ) )
            continue;

        _presses[ i ] += presses;
        SDL_LogInfo( SDL_LOG_CATEGORY_APPLICATION, "InputProbe: %s( step %" SDL_PRIu64 " ) press %d release %d, total %d",
                     SLOT_NAMES[ i ], app->SimulationStepIndex(), presses, releases, _presses[ i ] );
    }
}
void InputProbe::Render( crApp* app )
{
    // pointer space is window pixels, Y-down; the ui canvas is virtual units, Y-up
    crUi*        ui     = app->ui;
    const float2 canvas = ui->CanvasSize();
    const int2   vp     = app->camera->Viewport();
    const float  toVx   = ( canvas.x / static_cast<float>( vp.x ) );
    const float  toVy   = ( canvas.y / static_cast<float>( vp.y ) );

    const crInputSystem* input = app->inputSys;

    // one bottom-left column: the crDev ImGui panels own the top of the screen
    constexpr float ROW_X    = 12.0f;
    constexpr float ROW_BASE = 190.0f;   // clears the buttons below
    constexpr float ROW_STEP = 22.0f;

    char line[ 96 ];
    SDL_snprintf( line, sizeof( line ), "fingers %d / %d   clicks %d/%d   Z %d%s   X %d%s",
                  input->FingerCount(), crInputSystem::MAX_TRACKED_FINGERS, _clicks[ 0 ], _clicks[ 1 ],
                  _presses[ ROUND_SLOT ], input->ButtonDown( ROUND_SLOT ) ? "*" : "",
                  _presses[ RECT_SLOT ],  input->ButtonDown( RECT_SLOT )  ? "*" : "" );
    ui->Label( EUiAnchor::BOTTOM_LEFT, float2( ROW_X, ROW_BASE + ( ROW_STEP * 2.0f ) ), line, _font, color4( 0.6f, 0.9f, 1.0f, 1.0f ) );

    static const char* SOURCE_NAMES[ static_cast<int32_t>( EInputSource::_SIZE ) ] = { "KB/M", "Gamepad", "Touch" };

    SDL_snprintf( line, sizeof( line ), "source %s", SOURCE_NAMES[ static_cast<int32_t>( input->ActiveSource() ) ] );
    ui->Label( EUiAnchor::BOTTOM_LEFT, float2( ROW_X, ROW_BASE + ( ROW_STEP * 3.0f ) ), line, _font, color4( 0.6f, 0.9f, 1.0f, 1.0f ) );

    constexpr int32_t RING_STEPS = 24;
    constexpr float   RING_R     = 34.0f;

    {// same rect, two hit shapes, both over the left stick region: outside the ring the round one has
     // to miss AND let the stick through, while the rect one stays hittable to its corners
        const float2 claimSize = float2( 200.0f, 64.0f );
        const float2 roundMin  = float2( 40.0f, 40.0f );
        const float2 rectMin   = float2( 40.0f, 112.0f );
        const float  claimR    = ( claimSize.y * 0.5f );

        if( ui->Button( 1, EUiAnchor::BOTTOM_LEFT, roundMin, claimSize,
                        _panelSprite, 16.0f, "CLAIM ROUND", _font, color4( 1.0f, 1.0f, 1.0f, 1.0f ), claimR ) )
            ++_clicks[ 0 ];

        if( ui->Button( 2, EUiAnchor::BOTTOM_LEFT, rectMin, claimSize,
                        _panelSprite, 16.0f, "CLAIM RECT", _font, color4( 1.0f, 1.0f, 1.0f, 1.0f ) ) )
            ++_clicks[ 1 ];

        const float2 c = float2( roundMin.x + ( claimSize.x * 0.5f ), roundMin.y + claimR );

        float2 ring[ RING_STEPS ];
        for( int32_t k = 0; k < RING_STEPS; ++k )
        {
            const float2 cs = crMath::CosSin( ( static_cast<float>( k ) / RING_STEPS ) * ( 2.0f * crMath::PI ) );
            ring[ k ] = float2( c.x + ( cs.x * claimR ), c.y + ( cs.y * claimR ) );
        }
        ui->Polyline( ring, RING_STEPS, true, 3.0f, color4( 1.0f, 1.0f, 1.0f, 0.7f ) );
    }

    {// two faces beside UI CLAIM. either can be pressed by touch, its key, its pad button or its
     // mouse button — and the ring drawn over a held one is the actual hit shape, not the sprite
        constexpr float SIDE = 104.0f;
        constexpr float FOOT = 20.0f;

        const float2 roundMin = float2( 260.0f, FOOT );
        const float2 rectMin  = float2( 380.0f, FOOT );

        ui->ActionButton( ROUND_SLOT, EUiAnchor::BOTTOM_LEFT, roundMin, float2( SIDE, SIDE ),
                          _panelSprite, 16.0f, "Z", _font, color4( 1.0f, 0.7f, 0.4f, 1.0f ), SIDE * 0.5f );
        ui->ActionButton( RECT_SLOT, EUiAnchor::BOTTOM_LEFT, rectMin, float2( SIDE, SIDE ),
                          _panelSprite, 16.0f, "X", _font, color4( 0.5f, 0.8f, 1.0f, 1.0f ) );

        if( input->ButtonDown( ROUND_SLOT ) )
        {
            const float2 c = float2( roundMin.x + ( SIDE * 0.5f ), roundMin.y + ( SIDE * 0.5f ) );

            float2 ring[ RING_STEPS ];
            for( int32_t k = 0; k < RING_STEPS; ++k )
            {
                const float2 cs = crMath::CosSin( ( static_cast<float>( k ) / RING_STEPS ) * ( 2.0f * crMath::PI ) );
                ring[ k ] = float2( c.x + ( cs.x * SIDE * 0.5f ), c.y + ( cs.y * SIDE * 0.5f ) );
            }
            ui->Polyline( ring, RING_STEPS, true, 5.0f, color4( 1.0f, 0.85f, 0.5f, 1.0f ) );
        }

        if( input->ButtonDown( RECT_SLOT ) )
        {
            const float2 box[ 4 ] =
            {
                float2( rectMin.x,          rectMin.y ),
                float2( rectMin.x + SIDE,   rectMin.y ),
                float2( rectMin.x + SIDE,   rectMin.y + SIDE ),
                float2( rectMin.x,          rectMin.y + SIDE ),
            };
            ui->Polyline( box, 4, true, 5.0f, color4( 0.6f, 0.9f, 1.0f, 1.0f ) );
        }
    }


    for( int32_t i = 0; i < input->FingerCount(); ++i )
    {
        const crFingerInput& finger = input->Finger( i );

        const float2 at    = float2( finger.posPx.x * toVx,      canvas.y - ( finger.posPx.y * toVy ) );
        const float2 start = float2( finger.startPosPx.x * toVx, canvas.y - ( finger.startPosPx.y * toVy ) );

        const color4 color = finger.down ? color4( 0.3f, 1.0f, 0.5f, 1.0f )    // held
                                         : color4( 1.0f, 0.4f, 0.3f, 1.0f );   // ending this frame

        float2 ring[ RING_STEPS ];
        for( int32_t s = 0; s < RING_STEPS; ++s )
        {
            const float2 cs = crMath::CosSin( ( static_cast<float>( s ) / RING_STEPS ) * ( 2.0f * crMath::PI ) );
            ring[ s ] = float2( at.x + ( cs.x * RING_R ), at.y + ( cs.y * RING_R ) );
        }
        ui->Polyline( ring, RING_STEPS, true, 2.0f, color );

        const float2 drag[ 2 ] = { start, at };   // origin -> current: what a floating stick would read
        ui->Polyline( drag, 2, false, 2.0f, color );

        SDL_snprintf( line, sizeof( line ), "%d id %d%s", i, static_cast<int32_t>( finger.id ),
                      finger.pressed ? " DOWN" : ( finger.released ? " UP" : "" ) );
        ui->Label( EUiAnchor::BOTTOM_LEFT, float2( at.x, at.y + RING_R + 10.0f ), line, _font, color );
    }

    for( int32_t s = 0; s < static_cast<int32_t>( EStickId::_SIZE ); ++s )
    {
        const EStickId id    = static_cast<EStickId>( s );
        const float2   value = input->Stick( id );
        const bool     left  = ( id == EStickId::LEFT );

        SDL_snprintf( line, sizeof( line ), "%s (%+.2f, %+.2f)%s", left ? "L" : "R", value.x, value.y,
                      input->IsStickTouched( id ) ? " touch" : "" );
        ui->Label( EUiAnchor::BOTTOM_LEFT, float2( ROW_X, ROW_BASE + ( ROW_STEP * ( left ? 1.0f : 0.0f ) ) ), line, _font,
                   left ? color4( 0.4f, 0.8f, 1.0f, 1.0f ) : color4( 1.0f, 0.5f, 0.5f, 1.0f ) );

        constexpr float STICK_R = 90.0f;   // crStickConfig::radius default

        // a touch draws where the thumb landed; pad and keyboard have no origin, so they park here
        float2 origin = float2( left ? 150.0f : ( canvas.x - 150.0f ), 300.0f );
        if( input->IsStickTouched( id ) )
        {
            const float2 originPx = input->StickOrigin( id );
            origin = float2( originPx.x * toVx, canvas.y - ( originPx.y * toVy ) );
        }

        float2 reach[ RING_STEPS ];
        for( int32_t k = 0; k < RING_STEPS; ++k )
        {
            const float2 cs = crMath::CosSin( ( static_cast<float>( k ) / RING_STEPS ) * ( 2.0f * crMath::PI ) );
            reach[ k ] = float2( origin.x + ( cs.x * STICK_R ), origin.y + ( cs.y * STICK_R ) );
        }
        ui->Polyline( reach, RING_STEPS, true, 2.0f, color4( 0.5f, 0.5f, 0.6f, 1.0f ) );

        const float2 spoke[ 2 ] = { origin, float2( origin.x + ( value.x * STICK_R ), origin.y + ( value.y * STICK_R ) ) };
        ui->Polyline( spoke, 2, false, 4.0f, left ? color4( 0.4f, 0.8f, 1.0f, 1.0f ) : color4( 1.0f, 0.5f, 0.5f, 1.0f ) );
    }
}

const char* Game::FactionName( EFaction faction )
{
    return ( faction == EFaction::BLUE ) ? "BLUE" : "RED";
}
const char* Game::DoctrineName( const Commander& commander )
{
    static constexpr const char* NAMES[][ 3 ] =   // [ backbone hull ][ nerve ]
    {
        { "SWARM GUARD",  "SWARM LINE",  "SWARM ASSAULT"  },
        { "LANCE GUARD",  "LANCE LINE",  "LANCE ASSAULT"  },
        { "HAMMER GUARD", "HAMMER LINE", "HAMMER ASSAULT" },
    };

    const int32_t hull  = static_cast<int32_t>( commander.backbone );
    const int32_t nerve = ( commander.aggression > 0.66f ) ? 2 : ( ( commander.aggression > 0.36f ) ? 1 : 0 );

    return NAMES[ hull ][ nerve ];
}
const char* Game::UltimateName( EFaction faction )
{
    // fixed per side for the whole series, unlike doctrine — the asymmetry is the identity
    return ( faction == EFaction::BLUE ) ? "CASCADE" : "OVERLOAD";
}
color4 Game::EnergyColor( EFaction faction )
{
    return ( faction == EFaction::BLUE ) ? BLUE_ENERGY_COLOR : RED_ENERGY_COLOR;
}
color4 Game::FactionColor( EFaction faction )
{
    return ( faction == EFaction::BLUE ) ? BLUE_COLOR : RED_COLOR;
}
EFaction Game::Enemy( EFaction faction )
{
    return ( faction == EFaction::BLUE ) ? EFaction::RED : EFaction::BLUE;
}
