1
0
mirror of https://git.ngram.ca/OpenJam/rc-servers synced 2026-08-23 23:08:52 +00:00

add factory web frontend (#77)

### Description

This PR adds a web frontend for factory.
Please let me know if you think the directory names or API endpoints should be changed.
For example, I personally think there are better names than rc_factory_api, and there may be better API names than webui...

### Game

Robocraft

### Please confirm

- [x] I am the legal owner or represent the owner of all work submitted
- [x] I consent to my submission being added to this FOSS project
- [x] This PR used LLMs to generate some or all of the code changes

Reviewed-on: https://git.ngram.ca/OpenJam/rc-servers/pulls/77
Reviewed-by: NGnius <ngniusness@gmail.com>
Co-authored-by: MaxSignal <kastera58@gmail.com>
Co-committed-by: MaxSignal <kastera58@gmail.com>
This commit is contained in:
MaxSignal
2026-01-17 20:00:14 +00:00
committed by NGnius
parent f2896509ba
commit 5fd4acdf62
11 changed files with 589 additions and 4 deletions

24
rc_factory_web/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "oj_factory_web"
version.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
[dependencies]
actix-web.workspace = true
actix-files.workspace = true
clap.workspace = true
log.workspace = true
env_logger.workspace = true
git-version.workspace = true
base64.workspace = true
serde.workspace = true
serde_json.workspace = true
handlebars = { version = "6", features = ["dir_source"] }
libfj.workspace = true
oj_rc_core = { version = "*", path = "../rc_core" }
oj_rc_factory = { version = "*", path = "../rc_factory" }

3
rc_factory_web/run_debug.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
RUST_LOG=debug cargo run

23
rc_factory_web/src/cli.rs Normal file
View File

@@ -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()
}
}

View File

@@ -0,0 +1,63 @@
mod cli;
mod robocraft;
use actix_web::{App, HttpServer, Responder};
use oj_rc_core::persist::config::{ConfigImpl, ConfigProvider};
#[actix_web::get("/version")]
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::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 = <ConfigImpl as ConfigProvider<()>>::factory(&conf).await.map_err(io_error)?;
let factory_data = actix_web::web::Data::new(factory_enum);
let mut handlebars_conf = handlebars::Handlebars::new();
let mut dir_conf = handlebars::DirectorySourceOptions::default();
dir_conf.tpl_extension = ".html.hbs".to_owned();
dir_conf.hidden = false;
dir_conf.temporary = false;
handlebars_conf
.register_templates_directory(
std::path::PathBuf::from(&cli_args.assets).parent().expect("Bad asset path").join("templates"),
dir_conf,
)
.unwrap();
let handlebars_ref = actix_web::web::Data::new(handlebars_conf);
let assets_root = actix_web::web::Data::new(std::path::PathBuf::from(&cli_args.assets));
HttpServer::new(move || {
App::new()
.app_data(factory_data.clone())
.app_data(assets_root.clone())
.app_data(handlebars_ref.clone())
.service(index)
.service(robocraft::web_ui::index)
.service(robocraft::web_ui::app_js)
.service(robocraft::web_ui::favicon)
.service(robocraft::web_ui::favicon_standard)
.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
}

View File

@@ -0,0 +1,145 @@
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<u32> {
if filter.trim().is_empty() {
return Vec::new();
}
filter.split(',')
.filter_map(|x| x.trim().parse::<u32>().ok())
.collect()
}
fn payload_to_query(payload: &ListPayload) -> ListQuery {
ListQuery {
page: (payload.page.max(1)) as usize,
page_size: (payload.page_size.clamp(1, 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<oj_rc_core::factory::Factory>, body: Json<ListPayload>) -> HttpResponse {
if let oj_rc_core::factory::Factory::None = factory.get_ref() {
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<FactoryRobotListInfo> = 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<oj_rc_core::factory::Factory>) -> HttpResponse {
if let oj_rc_core::factory::Factory::None = factory.get_ref() {
return HttpResponse::ServiceUnavailable().body("factory adapter disabled");
}
let query = payload_to_query(&ListPayload::default());
match factory.list(query).await {
Ok(items) => {
let out: Vec<FactoryRobotListInfo> = 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<oj_rc_core::factory::Factory>, id: Path<i32>) -> HttpResponse {
if let oj_rc_core::factory::Factory::None = factory.get_ref() {
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}")),
}
}

View File

@@ -0,0 +1 @@
pub mod crf_api;

View File

@@ -0,0 +1,2 @@
pub mod factory;
pub mod web_ui;

View File

@@ -0,0 +1,59 @@
use actix_web::{get, web::Data, HttpResponse};
use handlebars::Handlebars;
const TEMPLATE_INDEX: &str = "rc_factory_web/index";
const TEMPLATE_APP_JS: &str = "rc_factory_web/app.js";
#[derive(serde::Serialize)]
struct Context {
version: String,
source_url: String,
}
fn version_string() -> String {
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
//let license = env!("CARGO_PKG_LICENSE");
//let repo = env!("CARGO_PKG_REPOSITORY");
format!("OpenJam {} {}", name, version)
}
#[get("/")]
pub async fn index(hb: Data<Handlebars<'_>>) -> HttpResponse {
let ctx = Context {
version: version_string(),
source_url: env!("CARGO_PKG_REPOSITORY").to_string(),
};
match hb.render(TEMPLATE_INDEX, &ctx) {
Ok(body) => HttpResponse::Ok()
.insert_header(("Content-Type", "text/html; charset=utf-8"))
.body(body),
Err(e) => HttpResponse::InternalServerError().body(format!("template render error: {e}")),
}
}
#[get("/app.js")]
pub async fn app_js(hb: Data<Handlebars<'_>>) -> HttpResponse {
match hb.render(TEMPLATE_APP_JS, &()) {
Ok(body) => HttpResponse::Ok()
.insert_header(("Content-Type", "application/javascript; charset=utf-8"))
.body(body),
Err(e) => HttpResponse::InternalServerError().body(format!("template render error: {e}")),
}
}
async fn favicon_impl(assets_root: Data<std::path::PathBuf>) -> impl actix_web::Responder {
let path = assets_root.join("favicon.jpg");
actix_files::NamedFile::open_async(path).await
}
#[get("/robocraft/favicon")]
pub async fn favicon(assets_root: Data<std::path::PathBuf>) -> impl actix_web::Responder {
favicon_impl(assets_root).await
}
#[get("/favicon.ico")]
pub async fn favicon_standard(assets_root: Data<std::path::PathBuf>) -> impl actix_web::Responder {
favicon_impl(assets_root).await
}