flatland-client-lib 0.2.32

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
//! Validate + bump publish rev + upload client/sim packs to the asset backend.

use std::path::{Path, PathBuf};

use anyhow::Context;

use crate::asset_backend::{
    mime_for_path, store_from_config, AssetBackendConfig, AssetBackendKind, AssetStore,
};
use crate::assets::{
    build_client_bundle_index, resolve_bundle_file_path, AssetBundleIndex, ClientBundleSources,
};
use crate::content_pack::{build_sim_pack_index, upload_sim_pack};

#[derive(Debug, Clone)]
pub struct PublishPacksResult {
    pub rev: u64,
    pub published_at: String,
    pub summary: String,
    pub client_files: usize,
    pub sim_files: usize,
    pub uploaded: bool,
    pub backend_kind: String,
    pub bucket: String,
    pub dest: String,
}

/// Shared Publish path for content-admin and `flatland-admin content publish`.
///
/// `write_marker` bumps `assets/.content-publish.json`. When `skip_upload` is false,
/// uploads client gfx + sim content packs to [`AssetBackendConfig::from_env`].
pub async fn publish_content_packs(
    repo_root: &Path,
    summary: String,
    publish_rev: u64,
    published_at: String,
    skip_upload: bool,
) -> anyhow::Result<PublishPacksResult> {
    publish_content_packs_opts(
        repo_root,
        summary,
        publish_rev,
        published_at,
        skip_upload,
        true,
    )
    .await
}

/// Like [`publish_content_packs`], with control over sim pack upload (smoke tests skip sim).
pub async fn publish_content_packs_opts(
    repo_root: &Path,
    summary: String,
    publish_rev: u64,
    published_at: String,
    skip_upload: bool,
    upload_sim: bool,
) -> anyhow::Result<PublishPacksResult> {
    let (client_index, sources) =
        build_client_sources(repo_root, publish_rev, &published_at)?;
    let sim_index = build_sim_pack_index(repo_root, publish_rev, &published_at)?;
    let cfg = AssetBackendConfig::from_env();
    let mut uploaded = false;
    if !skip_upload {
        let store = store_from_config(&cfg)?;
        upload_client_bundle(store.as_ref(), &cfg, &sources, &client_index)
            .await
            .context("upload client asset pack")?;
        if upload_sim {
            upload_sim_pack(store.as_ref(), &cfg, repo_root, &sim_index)
                .await
                .context("upload sim content pack")?;
        }
        uploaded = true;
    }
    Ok(PublishPacksResult {
        rev: publish_rev,
        published_at,
        summary,
        client_files: client_index.files.len(),
        sim_files: sim_index.files.len(),
        uploaded,
        backend_kind: backend_label(&cfg).to_string(),
        bucket: cfg.bucket.clone(),
        dest: describe_dest(&cfg, &cfg.client_prefix, publish_rev),
    })
}

struct ClientBundleOwned {
    sprites_dir: PathBuf,
    paperdoll_dir: Option<PathBuf>,
    player_presentation: Option<PathBuf>,
    player_art_dir: Option<PathBuf>,
    client_settings: Option<PathBuf>,
    terrain_kinds: Option<PathBuf>,
}

impl ClientBundleOwned {
    fn as_refs(&self) -> ClientBundleSources<'_> {
        ClientBundleSources {
            sprites_dir: &self.sprites_dir,
            paperdoll_dir: self.paperdoll_dir.as_deref(),
            player_presentation: self.player_presentation.as_deref(),
            player_art_dir: self.player_art_dir.as_deref(),
            client_settings: self.client_settings.as_deref(),
            terrain_kinds: self.terrain_kinds.as_deref(),
        }
    }
}

fn build_client_sources(
    repo_root: &Path,
    publish_rev: u64,
    published_at: &str,
) -> anyhow::Result<(AssetBundleIndex, ClientBundleOwned)> {
    // Prefer repo sprites for pack builds — `default_sprites_dir()` may resolve to
    // `~/.flatland3/assets/current`, which nests an older paperdoll tree and breaks uploads.
    let sprites_dir = {
        let repo_sprites = repo_root.join("assets/gfx/sprites");
        if repo_sprites.join("manifest.yaml").is_file() {
            repo_sprites
        } else {
            flatland_presentation::default_sprites_dir().ok_or_else(|| {
                anyhow::anyhow!("gfx sprites dir not found under assets/gfx/sprites")
            })?
        }
    };
    let paperdoll_dir = repo_root
        .join("assets/paperdoll")
        .is_dir()
        .then(|| repo_root.join("assets/paperdoll"));
    let player_presentation = {
        let p = repo_root.join("assets/gfx/player-presentation.yaml");
        p.is_file().then_some(p)
    };
    let player_art_dir = {
        let p = repo_root.join("assets/gfx/player");
        p.is_dir().then_some(p)
    };
    let client_settings = {
        let p = repo_root.join("assets/config/client-settings.yaml");
        p.is_file().then_some(p)
    };
    let terrain_kinds = {
        let p = repo_root.join("assets/world/terrain-kinds.yaml");
        p.is_file().then_some(p)
    };
    let owned = ClientBundleOwned {
        sprites_dir,
        paperdoll_dir,
        player_presentation,
        player_art_dir,
        client_settings,
        terrain_kinds,
    };
    let index = build_client_bundle_index(&owned.as_refs(), publish_rev, published_at)?;
    Ok((index, owned))
}

async fn upload_client_bundle(
    store: &dyn AssetStore,
    cfg: &AssetBackendConfig,
    sources: &ClientBundleOwned,
    index: &AssetBundleIndex,
) -> anyhow::Result<()> {
    let refs = sources.as_refs();
    for (rel, _) in &index.files {
        let path = resolve_bundle_file_path(&refs, rel);
        let bytes = std::fs::read(&path).with_context(|| {
            format!(
                "read client pack file {rel} from {}",
                path.display()
            )
        })?;
        let key = cfg.client_object_key(index.publish_rev, rel);
        store
            .put(&key, mime_for_path(&path), &bytes)
            .await
            .with_context(|| format!("upload client {key}"))?;
    }
    let latest = serde_json::to_vec(index)?;
    store
        .put(&cfg.client_index_object(), "application/json", &latest)
        .await?;
    Ok(())
}

fn backend_label(cfg: &AssetBackendConfig) -> &'static str {
    match cfg.kind {
        AssetBackendKind::Local => "local",
        AssetBackendKind::Gcs => "gcs",
        AssetBackendKind::S3 => "s3",
    }
}

fn describe_dest(cfg: &AssetBackendConfig, prefix: &str, rev: u64) -> String {
    match cfg.kind {
        AssetBackendKind::Local => cfg
            .local_root
            .join(prefix)
            .join(format!("rev-{rev}"))
            .display()
            .to_string(),
        AssetBackendKind::Gcs => format!("gs://{}/{prefix}/rev-{rev}/", cfg.bucket),
        AssetBackendKind::S3 => format!("s3://{}/{prefix}/rev-{rev}/", cfg.bucket),
    }
}