diff --git a/Cargo.toml b/Cargo.toml
index 99623c0..b265199 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,7 +23,7 @@ members = [
"rc_singleplayer", "rc_singleplayer_room",
"rc_lobby", "rc_lobby_room",
"rc_core", "rc_database", "rc_factory",
- "rc_multiplayer",
+ "rc_factory_api" ,"rc_multiplayer",
"rc_plugins",
]
diff --git a/assets/robocraft/servenvmulti.config b/assets/robocraft/servenvmulti.config
index c190b1a..610927f 100644
--- a/assets/robocraft/servenvmulti.config
+++ b/assets/robocraft/servenvmulti.config
@@ -46,6 +46,7 @@
http://domain_or_ip_address:8001/
http://domain_or_ip_address:8010/live/data.json
http://domain_or_ip_address:8011/
+ http://domain_or_ip_address:8012/
domain_or_ip_address:4542
diff --git a/rc_factory/src/arc/adapter.rs b/rc_factory/src/arc/adapter.rs
index 9bd8e39..8db88b2 100644
--- a/rc_factory/src/arc/adapter.rs
+++ b/rc_factory/src/arc/adapter.rs
@@ -85,7 +85,7 @@ impl crate::VehicleFactoryAdapter for ArcAdapter {
id: meta.id as _,
name: meta.name,
description: meta.description,
- thumbnail: meta.thumbnail,
+ thumbnail: self.thumbnail_url(meta.thumbnail, meta.id),
added_by: if self.spoof_users { hex::encode(meta.added_by.as_bytes()) } else { meta.added_by },
added_by_display_name: meta.added_by_display_name,
added_date: crate::traits::parse_rc_date(&meta.added_date).unwrap_or_default(),
diff --git a/rc_factory_api/Cargo.toml b/rc_factory_api/Cargo.toml
new file mode 100644
index 0000000..e98c4db
--- /dev/null
+++ b/rc_factory_api/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "oj_factory_api"
+version.workspace = true
+edition.workspace = true
+repository.workspace = true
+license.workspace = true
+authors.workspace = true
+readme.workspace = true
+
+[dependencies]
+actix-web.workspace = true
+clap.workspace = true
+log.workspace = true
+env_logger.workspace = true
+git-version.workspace = true
+base64.workspace = true
+serde_json.workspace = true
+
+libfj.workspace = true
+oj_rc_core = { version = "*", path = "../rc_core" }
+oj_rc_factory = { version = "*", path = "../rc_factory" }
diff --git a/rc_factory_api/run_debug.sh b/rc_factory_api/run_debug.sh
new file mode 100755
index 0000000..a6e46e5
--- /dev/null
+++ b/rc_factory_api/run_debug.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+RUST_LOG=debug cargo run
diff --git a/rc_factory_api/src/cli.rs b/rc_factory_api/src/cli.rs
new file mode 100644
index 0000000..a07d819
--- /dev/null
+++ b/rc_factory_api/src/cli.rs
@@ -0,0 +1,23 @@
+use clap::Parser;
+
+#[derive(Parser, Debug, Clone)]
+#[command(version, about, long_about = None)]
+pub struct CliArgs {
+ /// TCP port on which to accept connections
+ #[arg(short, long, default_value_t = 8012)]
+ pub port: u16,
+
+ /// IP Address on which to accept connections
+ #[arg(long, default_value_t = {"127.0.0.1".to_string()})]
+ pub ip: String,
+
+ /// Assets root
+ #[arg(long, default_value_t = {"../assets/robocraft".to_string()})]
+ pub assets: String,
+}
+
+impl CliArgs {
+ pub fn get() -> Self {
+ Self::parse()
+ }
+}
diff --git a/rc_factory_api/src/main.rs b/rc_factory_api/src/main.rs
new file mode 100644
index 0000000..19ef79e
--- /dev/null
+++ b/rc_factory_api/src/main.rs
@@ -0,0 +1,43 @@
+mod cli;
+mod robocraft;
+
+use actix_web::{App, HttpServer, Responder};
+use oj_rc_core::persist::config::{ConfigImpl, ConfigProvider};
+
+#[actix_web::get("/")]
+async fn index() -> impl Responder {
+ let name = env!("CARGO_PKG_NAME");
+ let version = env!("CARGO_PKG_VERSION");
+ let git_version = git_version::git_version!(args = ["--always", "--dirty=+"]);
+ let authors = env!("CARGO_PKG_AUTHORS");
+ let license = env!("CARGO_PKG_LICENSE");
+ let repo = env!("CARGO_PKG_REPOSITORY");
+ format!("{} {}:{} by [{}]\n{}\n{}", name, version, git_version, authors, license, repo)
+}
+
+fn io_error(e: impl ToString) -> std::io::Error {
+ std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
+}
+
+#[actix_web::main]
+async fn main() -> std::io::Result<()> {
+ env_logger::init();
+ let cli_args = cli::CliArgs::get();
+
+ let conf = ConfigImpl::load(&cli_args.assets).map_err(io_error)?;
+ let factory_enum = >::factory(&conf).await.map_err(io_error)?;
+
+ let factory_data = actix_web::web::Data::new(factory_enum);
+
+ HttpServer::new(move || {
+ App::new()
+ .app_data(factory_data.clone())
+ .service(index)
+ .service(robocraft::factory::crf_api::list)
+ .service(robocraft::factory::crf_api::list_default)
+ .service(robocraft::factory::crf_api::get)
+ })
+ .bind((cli_args.ip, cli_args.port))?
+ .run()
+ .await
+}
diff --git a/rc_factory_api/src/robocraft/factory/crf_api.rs b/rc_factory_api/src/robocraft/factory/crf_api.rs
new file mode 100644
index 0000000..59e74bd
--- /dev/null
+++ b/rc_factory_api/src/robocraft/factory/crf_api.rs
@@ -0,0 +1,154 @@
+use actix_web::{get, post, web::{Data, Json, Path}, HttpResponse};
+use base64::Engine;
+
+use libfj::robocraft::{
+ FactoryInfo, RoboShopItemsInfo, FactoryRobotGetInfo, FactoryRobotListInfo,
+ ListPayload, ListQuery, FactoryOrderType, FactoryTextSearchField,
+};
+use oj_rc_factory::{VehicleFactoryAdapter, VehicleInfo, VehicleQueryInfo};
+
+fn parse_filter(filter: &str) -> Vec {
+ if filter.trim().is_empty() {
+ return Vec::new();
+ }
+ filter.split(',')
+ .filter_map(|x| x.trim().parse::().ok())
+ .collect()
+}
+
+fn payload_to_query(payload: &ListPayload) -> ListQuery {
+ ListQuery {
+ page: (payload.page.max(1)) as usize,
+ page_size: (payload.page_size.max(1).min(100)) as usize,
+ order: FactoryOrderType::try_from(payload.order.clamp(0, u8::MAX.into()) as u8).unwrap_or(FactoryOrderType::Suggested),
+ player_filter: payload.player_filter,
+ movement_filter: parse_filter(&payload.movement_filter),
+ movement_category_filter: parse_filter(&payload.movement_category_filter),
+ weapon_filter: parse_filter(&payload.weapon_filter),
+ weapon_category_filter: parse_filter(&payload.weapon_category_filter),
+ minimum_cpu: if payload.minimum_cpu <= 0 { 0 } else { payload.minimum_cpu as usize },
+ maximum_cpu: if payload.maximum_cpu <= 0 { usize::MAX } else { payload.maximum_cpu as usize },
+ text_filter: payload.text_filter.clone(),
+ text_search_field: FactoryTextSearchField::try_from(payload.text_search_field.clamp(0, u8::MAX.into()) as u8).unwrap_or(FactoryTextSearchField::All),
+ buyable: payload.buyable,
+ prepend_featured_robot: payload.prepend_featured_robot,
+ featured_only: payload.featured_only,
+ default_page: payload.default_page,
+ }
+}
+
+fn list_info(vi: VehicleQueryInfo) -> FactoryRobotListInfo {
+ FactoryRobotListInfo {
+ item_id: vi.id as usize,
+ item_name: vi.name,
+ item_description: vi.description,
+ thumbnail: vi.thumbnail,
+ added_by: vi.added_by,
+ added_by_display_name: vi.added_by_display_name,
+ added_date: vi.added_date.format("%Y-%m-%dT%H:%M:%S").to_string(),
+ expiry_date: vi.expiry_date.format("%Y-%m-%dT%H:%M:%S").to_string(),
+ cpu: vi.cpu as usize,
+ total_robot_ranking: vi.total_robot_ranking as usize,
+ rent_count: vi.rent_count as usize,
+ buy_count: vi.buy_count as usize,
+ buyable: vi.buyable,
+ removed_date: vi.removed_date.as_ref().map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string()),
+ ban_date: vi.ban_date.as_ref().map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string()),
+ featured: vi.featured,
+ banner_message: vi.banner_message,
+ combat_rating: vi.combat_rating as f32,
+ cosmetic_rating: vi.cosmetic_rating as f32,
+ cube_amounts: serde_json::to_string(&vi.cube_amounts).unwrap_or_else(|_| "{}".to_string()),
+ }
+}
+
+fn get_info(qi: VehicleQueryInfo, vi: VehicleInfo) -> FactoryRobotGetInfo {
+ FactoryRobotGetInfo {
+ item_id: qi.id as usize,
+ item_name: qi.name,
+ item_description: qi.description,
+ thumbnail: qi.thumbnail,
+ added_by: qi.added_by,
+ added_by_display_name: qi.added_by_display_name,
+ added_date: qi.added_date.format("%Y-%m-%dT%H:%M:%S").to_string(),
+ expiry_date: qi.expiry_date.format("%Y-%m-%dT%H:%M:%S").to_string(),
+ cpu: qi.cpu as usize,
+ total_robot_ranking: qi.total_robot_ranking as usize,
+ rent_count: qi.rent_count as usize,
+ buy_count: qi.buy_count as usize,
+ buyable: qi.buyable,
+ removed_date: qi.removed_date.as_ref().map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string()),
+ ban_date: qi.ban_date.as_ref().map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string()),
+ featured: qi.featured,
+ banner_message: qi.banner_message,
+ combat_rating: qi.combat_rating as f32,
+ cosmetic_rating: qi.cosmetic_rating as f32,
+ cube_data: base64::prelude::BASE64_STANDARD.encode(&vi.cube_data),
+ colour_data: base64::prelude::BASE64_STANDARD.encode(&vi.colour_data),
+ cube_amounts: serde_json::to_string(&qi.cube_amounts).unwrap_or_else(|_| "{}".to_string()),
+ }
+}
+
+#[post("/api/roboShopItems/list")]
+pub async fn list(factory: Data, body: Json) -> HttpResponse {
+ match factory.get_ref() {
+ oj_rc_core::factory::Factory::None => {
+ return HttpResponse::ServiceUnavailable().body("factory adapter disabled");
+ }
+ _ => {}
+ }
+
+ let query = payload_to_query(&body.into_inner());
+
+ match factory.list(query).await {
+ Ok(items) => {
+ let out: Vec = items.into_iter().map(list_info).collect();
+ HttpResponse::Ok().json(FactoryInfo {
+ response: RoboShopItemsInfo { roboshop_items: out },
+ status_code: 200,
+ })
+ }
+ Err(e) => HttpResponse::InternalServerError().body(format!("factory list error: {e}")),
+ }
+}
+
+#[get("/api/roboShopItems/list")]
+pub async fn list_default(factory: Data) -> HttpResponse {
+ match factory.get_ref() {
+ oj_rc_core::factory::Factory::None => {
+ return HttpResponse::ServiceUnavailable().body("factory adapter disabled");
+ }
+ _ => {}
+ }
+
+ let query = payload_to_query(&ListPayload::default());
+
+ match factory.list(query).await {
+ Ok(items) => {
+ let out: Vec = items.into_iter().map(list_info).collect();
+ HttpResponse::Ok().json(FactoryInfo {
+ response: RoboShopItemsInfo { roboshop_items: out },
+ status_code: 200,
+ })
+ }
+ Err(e) => HttpResponse::InternalServerError().body(format!("factory list error: {e}")),
+ }
+}
+
+#[get("/api/roboShopItems/get/{id}")]
+pub async fn get(factory: Data, id: Path) -> HttpResponse {
+ match factory.get_ref() {
+ oj_rc_core::factory::Factory::None => {
+ return HttpResponse::ServiceUnavailable().body("factory adapter disabled");
+ }
+ _ => {}
+ }
+ match factory.vehicle(*id).await {
+ Ok(Some((vehicle_info, query_info))) => HttpResponse::Ok().json(FactoryInfo {
+ response: get_info(query_info, vehicle_info),
+ status_code: 200,
+ }),
+ Ok(None) => HttpResponse::NotFound().body("robot not found"),
+ Err(e) => HttpResponse::InternalServerError().body(format!("factory get error: {e}")),
+ }
+}
diff --git a/rc_factory_api/src/robocraft/factory/mod.rs b/rc_factory_api/src/robocraft/factory/mod.rs
new file mode 100644
index 0000000..a9fef9c
--- /dev/null
+++ b/rc_factory_api/src/robocraft/factory/mod.rs
@@ -0,0 +1 @@
+pub mod crf_api;
diff --git a/rc_factory_api/src/robocraft/mod.rs b/rc_factory_api/src/robocraft/mod.rs
new file mode 100644
index 0000000..a106d20
--- /dev/null
+++ b/rc_factory_api/src/robocraft/mod.rs
@@ -0,0 +1 @@
+pub mod factory;