From d2626bb47fdba788e8b6b03c3a631074a6c74f3a Mon Sep 17 00:00:00 2001 From: "NG (Graham)" Date: Mon, 15 Jun 2026 01:02:52 +0000 Subject: [PATCH] feat-webgarages-121 (#129) ### Description Closes #121 ### Please confirm - [x] I am the legal owner or represent the legal owner of all work submitted (including LLM-generated code, if any) - [x] I consent to my changes being added to this FOSS project - [x] I have confirmed that this does not add new errors or warnings with `utils/clippy.sh` - [ ] This PR used LLMs to generate some or all of the code changes Reviewed-on: https://git.ngram.ca/OpenJam/rc-servers/pulls/129 --- Cargo.lock | 234 ++- Cargo.toml | 2 + assets/robocraft/rc_export.png | Bin 0 -> 78300 bytes .../templates/rc_society/dashboard.html.hbs | 133 +- .../rc_society/garage_import.html.hbs | 79 + .../templates/rc_society/garage_info.html.hbs | 91 + .../templates/rc_society/garage_list.html.hbs | 107 + assets/templates/rc_society/index.html.hbs | 127 +- .../templates/rc_society/stylesheet.html.hbs | 158 ++ rc_core/src/cubes/conversion.rs | 334 +++ rc_core/src/cubes/mod.rs | 10 + rc_core/src/persist/config/cubes_json.rs | 25 +- rc_core/src/persist/conversion.rs | 1851 +++++++++++++++++ rc_core/src/persist/cube_data.rs | 3 +- rc_core/src/persist/mod.rs | 3 + rc_core/src/persist/user/account_json.rs | 4 +- rc_core/src/persist/user/mod.rs | 2 +- rc_core/src/persist/user/traits.rs | 12 + rc_core/src/persist/user/web.rs | 107 +- rc_database/src/wrapper.rs | 25 +- rc_lobby_room/src/lobby.rs | 2 +- rc_plugins/src/lib.rs | 1 + rc_plugins/src/vehicle_import/mod.rs | 2 + rc_plugins/src/vehicle_import/plugin.rs | 35 + rc_society/Cargo.toml | 4 + rc_society/src/api/garage/mod.rs | 1 + .../src/api/garage/plugins/bobocraft.rs | 124 ++ rc_society/src/api/garage/plugins/classic.rs | 103 + rc_society/src/api/garage/plugins/factory.rs | 102 + rc_society/src/api/garage/plugins/mod.rs | 61 + rc_society/src/api/mod.rs | 1 + rc_society/src/main.rs | 18 + rc_society/src/web/dashboard.rs | 16 +- rc_society/src/web/garage/export.rs | 52 + rc_society/src/web/garage/import.rs | 221 ++ rc_society/src/web/garage/info.rs | 93 + rc_society/src/web/garage/list.rs | 76 + rc_society/src/web/garage/mod.rs | 37 + rc_society/src/web/garage/selected.rs | 29 + rc_society/src/web/mod.rs | 9 +- 40 files changed, 4012 insertions(+), 282 deletions(-) create mode 100644 assets/robocraft/rc_export.png create mode 100644 assets/templates/rc_society/garage_import.html.hbs create mode 100644 assets/templates/rc_society/garage_info.html.hbs create mode 100644 assets/templates/rc_society/garage_list.html.hbs create mode 100644 assets/templates/rc_society/stylesheet.html.hbs create mode 100644 rc_core/src/cubes/conversion.rs create mode 100644 rc_core/src/persist/conversion.rs create mode 100644 rc_plugins/src/vehicle_import/mod.rs create mode 100644 rc_plugins/src/vehicle_import/plugin.rs create mode 100644 rc_society/src/api/garage/mod.rs create mode 100644 rc_society/src/api/garage/plugins/bobocraft.rs create mode 100644 rc_society/src/api/garage/plugins/classic.rs create mode 100644 rc_society/src/api/garage/plugins/factory.rs create mode 100644 rc_society/src/api/garage/plugins/mod.rs create mode 100644 rc_society/src/web/garage/export.rs create mode 100644 rc_society/src/web/garage/import.rs create mode 100644 rc_society/src/web/garage/info.rs create mode 100644 rc_society/src/web/garage/list.rs create mode 100644 rc_society/src/web/garage/mod.rs create mode 100644 rc_society/src/web/garage/selected.rs diff --git a/Cargo.lock b/Cargo.lock index fc1ec7b..0a4f823 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,7 +31,7 @@ dependencies = [ "actix-web", "bitflags", "bytes", - "derive_more", + "derive_more 2.1.1", "futures-core", "http-range", "log", @@ -57,7 +57,7 @@ dependencies = [ "brotli", "bytes", "bytestring", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "flate2", "foldhash", @@ -90,7 +90,7 @@ dependencies = [ "actix-session", "actix-utils", "actix-web", - "derive_more", + "derive_more 2.1.1", "futures-core", "serde", "tracing", @@ -106,6 +106,44 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "actix-multipart" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more 0.99.20", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.8.6", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "actix-router" version = "0.5.4" @@ -167,7 +205,7 @@ dependencies = [ "actix-utils", "actix-web", "anyhow", - "derive_more", + "derive_more 2.1.1", "rand 0.9.4", "serde", "serde_json", @@ -203,7 +241,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie 0.16.2", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "foldhash", "futures-core", @@ -520,6 +558,15 @@ dependencies = [ "num-traits 0.2.19", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -915,6 +962,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -958,6 +1014,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.10.0" @@ -1059,6 +1121,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1256,6 +1324,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1271,7 +1352,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1395,6 +1476,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1465,6 +1558,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -1786,6 +1894,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1821,6 +1938,20 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2457,6 +2588,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2831,6 +2968,18 @@ dependencies = [ "zip", ] +[[package]] +name = "oj_convert" +version = "0.3.0" +dependencies = [ + "bytes", + "chrono", + "png", + "postcard", + "serde", + "serde_json", +] + [[package]] name = "oj_polariton_auth" version = "1.3.0" @@ -3163,8 +3312,10 @@ version = "1.3.0" dependencies = [ "actix-files", "actix-identity", + "actix-multipart", "actix-session", "actix-web", + "base64 0.22.1", "chrono", "clap", "cookie 0.18.1", @@ -3173,7 +3324,9 @@ dependencies = [ "handlebars", "libfj", "log", + "oj_convert", "oj_rc_core", + "oj_rc_plugins", "serde", "serde_json", "tokio", @@ -3299,6 +3452,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + [[package]] name = "password-hash" version = "0.5.0" @@ -3436,6 +3595,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polariton" version = "0.6.0" @@ -3484,6 +3656,19 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4097,6 +4282,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -4233,7 +4431,7 @@ dependencies = [ "async-trait", "bigdecimal", "chrono", - "derive_more", + "derive_more 2.1.1", "futures-util", "log", "mac_address", @@ -4473,6 +4671,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4977,6 +5184,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/Cargo.toml b/Cargo.toml index 311e08e..0db5ae2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,3 +49,5 @@ hex = "0.4" base64 = "0.22" #oj_serdes = { version = "0.3.0", path = "../oj_core/serdes" } oj_serdes = "0.3.0" +oj_convert = { version = "0.3.0", path = "../oj_core/convert" } +#oj_convert = "0.3.0" diff --git a/assets/robocraft/rc_export.png b/assets/robocraft/rc_export.png new file mode 100644 index 0000000000000000000000000000000000000000..92aa315e446d76a988bb166ac9835b446819621b GIT binary patch literal 78300 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 - + - + - + {{> stylesheet}}

@@ -200,7 +75,7 @@
Total
{{form.debug.user_id}}/{{form.debug.user_id}}
- +
View My Garages
diff --git a/assets/templates/rc_society/garage_import.html.hbs b/assets/templates/rc_society/garage_import.html.hbs new file mode 100644 index 0000000..a393782 --- /dev/null +++ b/assets/templates/rc_society/garage_import.html.hbs @@ -0,0 +1,79 @@ + + + + + + Garages - Openjam + + + + + + + + + + + + + + + + + + + + {{> stylesheet}} + + +
+ +

{{form.display_name}} Garage {{#if form.garage}}#{{form.garage.slot}}{{/if}} Import

+ {{#if error}} +
+

{{error}}

+
+ {{/if}} + {{#if form.success}} +
+

{{form.success}}

+
+ {{/if}} + +
+
+ {{#if form.garage}} + + {{else}} + + {{/if}} +
+ + +
+
+ +
+
+ +
+
+
+ + +
+ + diff --git a/assets/templates/rc_society/garage_info.html.hbs b/assets/templates/rc_society/garage_info.html.hbs new file mode 100644 index 0000000..499e0a6 --- /dev/null +++ b/assets/templates/rc_society/garage_info.html.hbs @@ -0,0 +1,91 @@ + + + + + + Garage {{form.garage.slot}} - Openjam + + + + + + + + + + + + + + + + + + + + {{> stylesheet}} + + +
+
+ Welcome {{form.display_name}}! | + Home +
+

{{form.display_name}} Garage #{{form.garage.slot}}

+ {{#if error}} +
+

{{error}}

+
+ {{/if}} + +
+
+
+ + {{#each form.exporters}} + + {{/each}} + +
+
+
+ +
+
Name
+
{{form.garage.name}}
+
Slot
+
{{form.garage.slot}}
+
Database ID
+
{{form.garage.garage_id}}
+
+
+
+ + +
+ + diff --git a/assets/templates/rc_society/garage_list.html.hbs b/assets/templates/rc_society/garage_list.html.hbs new file mode 100644 index 0000000..ae5c844 --- /dev/null +++ b/assets/templates/rc_society/garage_list.html.hbs @@ -0,0 +1,107 @@ + + + + + + Garages - Openjam + + + + + + + + + + + + + + + + + + + + {{> stylesheet}} + + +
+
+ Welcome {{form.display_name}}! | + Home +
+

{{form.display_name}} Garages

+ {{#if error}} +
+

{{error}}

+
+ {{/if}} + +
+ + {{#each form.garages}} +
+ +
+
Name
+
{{this.name}}
+
CPU
+
{{this.cpu}}/{{this.max_cpu}}
+
Slot
+
{{this.slot}}
+
Database ID
+
{{this.garage_id}}
+
Creation
+
+
+ +
+ View Details +
+
+
+ {{/each}} +
+ + +
+ + diff --git a/assets/templates/rc_society/index.html.hbs b/assets/templates/rc_society/index.html.hbs index 2d2a359..dc7c443 100644 --- a/assets/templates/rc_society/index.html.hbs +++ b/assets/templates/rc_society/index.html.hbs @@ -22,132 +22,7 @@ - + {{> stylesheet}}
diff --git a/assets/templates/rc_society/stylesheet.html.hbs b/assets/templates/rc_society/stylesheet.html.hbs new file mode 100644 index 0000000..88c05bb --- /dev/null +++ b/assets/templates/rc_society/stylesheet.html.hbs @@ -0,0 +1,158 @@ + + + diff --git a/rc_core/src/cubes/conversion.rs b/rc_core/src/cubes/conversion.rs new file mode 100644 index 0000000..70107a7 --- /dev/null +++ b/rc_core/src/cubes/conversion.rs @@ -0,0 +1,334 @@ +const COLOUR_COUNT: usize = 32; + +struct ClassicModernTranslation { + id_mapping: ModernMapping, + offset: (i16, i16, i16), + orientation_mapping: OrientationMapping, +} + +impl ClassicModernTranslation { + fn convert(&self, cube: &super::Cube) -> (super::Cube, super::Colour){ + let (new_id, new_colour) = self.id_mapping.convert(cube); + let new_orientation = self.orientation_mapping.convert(cube); + let new_cube = super::Cube { + id: new_id, + x: (cube.x as u16).saturating_add_signed(self.offset.0) as _, + y: (cube.y as u16).saturating_add_signed(self.offset.1) as _, + z: (cube.z as u16).saturating_add_signed(self.offset.2) as _, + orientation: new_orientation, + }; + let new_colour = super::Colour { + colour: new_colour, + x: new_cube.x, + y: new_cube.y, + z: new_cube.z, + }; + (new_cube, new_colour) + } +} + +#[allow(dead_code)] +enum ModernMapping { + Always(u32, u8), + ByOrientation(Vec<(u32, u8)>), +} + +impl ModernMapping { + fn convert(&self, cube: &super::Cube) -> (u32, u8) { + match self { + Self::Always(id, colour) => (*id, *colour), + Self::ByOrientation(v) => v[cube.orientation as usize], + } + } + + fn pretty(&self) -> String { + match self { + Self::Always(id, colour) => format!("ID:{}|colour#{}", id, colour), + Self::ByOrientation(mapping) => format!("o[0]ID:{}|colour#{}", mapping[0].0, mapping[0].1), + } + } +} + +struct ModernClassicTranslation { + id_mapping: ClassicMapping, + offset: (i16, i16, i16), + orientation_mapping: OrientationMapping, +} + +impl ModernClassicTranslation { + fn convert(&self, cube: &super::Cube, colour: &super::Colour) -> super::Cube { + let new_id = self.id_mapping.convert(cube, colour); + let new_orientation = self.orientation_mapping.convert(cube); + super::Cube { + id: new_id, + x: (cube.x as u16).saturating_add_signed(self.offset.0) as _, + y: (cube.y as u16).saturating_add_signed(self.offset.1) as _, + z: (cube.z as u16).saturating_add_signed(self.offset.2) as _, + orientation: new_orientation, + } + } +} + +#[allow(dead_code)] +enum ClassicMapping { + Always(u32), + ByColour(Vec), + ByOrientation(Vec), +} + +impl ClassicMapping { + fn convert(&self, cube: &super::Cube, colour: &super::Colour) -> u32 { + match self { + Self::Always(x) => *x, + Self::ByColour(v) => v[colour.colour as usize], + Self::ByOrientation(v) => v[cube.orientation as usize], + } + } +} + +#[allow(dead_code)] +enum OrientationMapping { + Passthrough, + ByOrientation(Vec), +} + +impl OrientationMapping { + fn convert(&self, cube: &super::Cube) -> u8 { + match self { + Self::Passthrough => cube.orientation, + Self::ByOrientation(v) => v[cube.orientation as usize], + } + } +} + +#[derive(Debug)] +pub enum ConversionError { + UnknownCube { + id: u32, + index: usize, + position: (u8, u8, u8) + }, + CubeParse(std::io::Error), + CubeDump(std::io::Error), + ColourParse(std::io::Error), + ColourDump(std::io::Error), +} + +impl core::fmt::Display for ConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownCube { id, index, position } => write!(f, "Unknown cube ID {} at {} {:?}", id, index, position), + Self::CubeParse(e) => write!(f, "Failed to parse cube data: {}", e), + Self::CubeDump(e) => write!(f, "Failed to dump cube data: {}", e), + Self::ColourParse(e) => write!(f, "Failed to parse colour data: {}", e), + Self::ColourDump(e) => write!(f, "Failed to dump colour data: {}", e), + } + } +} + +impl core::error::Error for ConversionError {} + +pub struct ModernConversionResult{ + pub cube_data: Vec, + pub colour_data: Vec, +} + +pub struct CubeConversionParser { + classic_to_modern: std::collections::HashMap, + modern_to_classic: std::collections::HashMap, + known: std::collections::HashSet, +} + +impl CubeConversionParser { + pub fn with_cubes<'a, I: std::iter::Iterator>(cubes: I) -> Self { + let mut classic_to_modern = std::collections::HashMap::::new(); + let mut modern_to_classic = std::collections::HashMap::::new(); + let mut known = std::collections::HashSet::new(); + for cube in cubes { + // populate classic_to_modern + let conversion_data = cube.conversion.clone().unwrap_or_else(crate::persist::conversion::actual_default_conversion); + for from in conversion_data.from.iter() { + let mapping = match from { + crate::persist::FromConversionData::Simple(_id) => ClassicModernTranslation { + id_mapping: ModernMapping::Always(cube.id, 0), + offset: (0, 0, 0), + orientation_mapping: OrientationMapping::Passthrough, + }, + crate::persist::FromConversionData::Complex { id: _, offset, colour } => ClassicModernTranslation { + id_mapping: ModernMapping::Always(cube.id, *colour), + offset: offset.unwrap_or_default(), + orientation_mapping: OrientationMapping::Passthrough, + }, + }; + #[cfg(debug_assertions)] + if let Some(old_mapping) = classic_to_modern.get(&from.id()) { + log::debug!("Not overriding mapping classic ID {} from modern {} to modern {}", from.id(), old_mapping.id_mapping.pretty(), mapping.id_mapping.pretty()); + } else { + classic_to_modern.insert(from.id(), mapping); + } + #[cfg(not(debug_assertions))] + if !classic_to_modern.contains_key(&from.id) { + classic_to_modern.insert(from.id(), mapping); + } + } + // populate modern_to_classic + let mapping = if let Some(to) = conversion_data.to { + match to { + crate::persist::ToConversionData::Simple(id) => ModernClassicTranslation { + id_mapping: ClassicMapping::Always(id), + offset: (0, 0, 0), + orientation_mapping: OrientationMapping::Passthrough, + }, + crate::persist::ToConversionData::Complex { id, offset } => ModernClassicTranslation { + id_mapping: ClassicMapping::Always(id), + offset: offset.unwrap_or_default(), + orientation_mapping: OrientationMapping::Passthrough, + } + } + } else if !conversion_data.from.is_empty() { + if conversion_data.from.len() == 1 { + let from_first = conversion_data.from.first().unwrap(); + match from_first { + crate::persist::FromConversionData::Simple(id) => ModernClassicTranslation { + id_mapping: ClassicMapping::Always(*id), + offset: (0, 0, 0), + orientation_mapping: OrientationMapping::Passthrough, + }, + crate::persist::FromConversionData::Complex { id, offset, colour: _ } => ModernClassicTranslation { + id_mapping: ClassicMapping::Always(*id), + offset: offset.map(|(x, y, z)| (-x, -y, -z)).unwrap_or_default(), + orientation_mapping: OrientationMapping::Passthrough, + }, + } + } else { + // more than one conversion, build map + let default_id = conversion_data.from.first().unwrap().id(); + let mut colour_map: Vec = (0..COLOUR_COUNT).map(|_| default_id).collect(); + let mut is_default_set = false; + for from in conversion_data.from.iter() { + let colour = match from { + crate::persist::FromConversionData::Simple(_) => 0, + crate::persist::FromConversionData::Complex { colour, .. } => *colour, + }; + if colour_map[colour as usize] != default_id { continue; } + if colour == 0 { + if !is_default_set { + is_default_set = true; + } else { + continue; + } + } + colour_map[colour as usize] = from.id(); + } + ModernClassicTranslation { + id_mapping: ClassicMapping::ByColour(colour_map), + offset: (0, 0, 0), + orientation_mapping: OrientationMapping::Passthrough, + } + } + } else { + ModernClassicTranslation { + id_mapping: ClassicMapping::Always(cube.id), + offset: (0, 0, 0), + orientation_mapping: OrientationMapping::Passthrough, + } + }; + modern_to_classic.insert(cube.id, mapping); + // populate known cubes + known.insert(cube.id); + } + Self { + classic_to_modern, + modern_to_classic, + known, + } + } + + /// Replace modern cubes with classic equivalents (based on cube ID) + pub fn convert_to_classic(&self, cubes: &mut dyn std::io::Read, colours: &mut dyn std::io::Read) -> Result, ConversionError> { + let mut cube_data = super::parser::Cube::parse_list(cubes).map_err(ConversionError::CubeParse)?; + let colour_data = super::parser::Colour::parse_list(colours).map_err(ConversionError::ColourParse)?; + for (i, (cube, colour)) in cube_data.iter_mut().zip(colour_data.iter()).enumerate() { + if let Some(translation) = self.modern_to_classic.get(&cube.id) { + let new_cube = translation.convert(cube, colour); + *cube = new_cube; + } else { + return Err(ConversionError::UnknownCube { + id: cube.id, + index: i, + position: (cube.x, cube.y, cube.z), + }); + } + } + super::parser::Cube::dump_list(cube_data).map_err(ConversionError::CubeDump) + } + + /// Replace classic cubes with modern equivalents (based on cube ID) + pub fn convert_to_modern(&self, cubes: &mut dyn std::io::Read) -> Result { + let mut cube_data = super::parser::Cube::parse_list(cubes).map_err(ConversionError::CubeParse)?; + let mut colour_data = Vec::with_capacity(cube_data.len()); + for (i, cube) in cube_data.iter_mut().enumerate() { + if let Some(translation) = self.classic_to_modern.get(&cube.id) { + let (new_cube, new_colour) = translation.convert(cube); + log::trace!("cube {} RC15 ID {} -> Modern ID {} colour {} ({}, {}, {})", i, cube.id, new_cube.id, new_colour.colour, cube.x, cube.y, cube.z); + *cube = new_cube; + colour_data.push(new_colour); + } else if !self.known.contains(&cube.id){ + return Err(ConversionError::UnknownCube { + id: cube.id, + index: i, + position: (cube.x, cube.y, cube.z), + }); + } else { + log::debug!("No translation found for cube {} ({}, {}, {}) id {} (a valid modern ID)", i, cube.x, cube.y, cube.z, cube.id); + colour_data.push(super::Colour { + colour: 0, + x: cube.x, + y: cube.y, + z: cube.z, + }); + } + } + #[cfg(debug_assertions)] + if cube_data.len() != colour_data.len() { + log::error!("Emitted different lengths of cube and colour data; {} cubes != {} colours", cube_data.len(), colour_data.len()); + return Err(ConversionError::UnknownCube { + id: 0, + index: cube_data.len(), + position: (0, 0, 0), + }); + } + let cube_bytes = super::parser::Cube::dump_list(cube_data).map_err(ConversionError::CubeDump)?; + let colour_bytes = super::parser::Colour::dump_list(colour_data).map_err(ConversionError::ColourDump)?; + Ok(ModernConversionResult { + cube_data: cube_bytes, + colour_data: colour_bytes, + }) + } + + /// Replace unknown cubes with modern equivalents (based on cube ID), skipping known cube IDs + pub fn upgrade_to_modern(&self, cubes: &mut dyn std::io::Read, colours: &mut dyn std::io::Read) -> Result { + let mut cube_data = super::parser::Cube::parse_list(cubes).map_err(ConversionError::CubeParse)?; + let mut colour_data = super::parser::Colour::parse_list(colours).map_err(ConversionError::ColourParse)?; + for (i, (cube, colour)) in cube_data.iter_mut().zip(colour_data.iter_mut()).enumerate() { + if self.known.contains(&cube.id) { continue; } + if let Some(translation) = self.classic_to_modern.get(&cube.id) { + let (new_cube, new_colour) = translation.convert(cube); + *cube = new_cube; + *colour = new_colour; + } else { + return Err(ConversionError::UnknownCube { + id: cube.id, + index: i, + position: (cube.x, cube.y, cube.z), + }); + } + } + let cube_bytes = super::parser::Cube::dump_list(cube_data).map_err(ConversionError::CubeDump)?; + let colour_bytes = super::parser::Colour::dump_list(colour_data).map_err(ConversionError::ColourDump)?; + Ok(ModernConversionResult { + cube_data: cube_bytes, + colour_data: colour_bytes, + }) + } +} diff --git a/rc_core/src/cubes/mod.rs b/rc_core/src/cubes/mod.rs index 4a7df69..d5d1577 100644 --- a/rc_core/src/cubes/mod.rs +++ b/rc_core/src/cubes/mod.rs @@ -1,4 +1,5 @@ mod parser; +pub use parser::{Colour, Cube}; mod weapon_list; pub use weapon_list::WeaponListParser; @@ -21,6 +22,9 @@ pub use rotations::CUBE_ROTATIONS; mod graph; pub use graph::{CubeGraph, CellPoint}; +mod conversion; +pub use conversion::CubeConversionParser; + pub const CRYSTAL_ID: u32 = 3950293873; pub const CLASP_ID: u32 = 606866102; @@ -31,6 +35,7 @@ pub struct CubeParsers { cpu_counter: std::sync::Arc, locations: std::sync::Arc, offset: std::sync::Arc, + converter: std::sync::Arc, } impl CubeParsers { @@ -41,6 +46,7 @@ impl CubeParsers { cpu_counter: std::sync::Arc::new(CpuListParser::with_cubes(cubes.values())), locations: std::sync::Arc::new(CubeLocationsParser::with_cubes(cubes.values())), offset: std::sync::Arc::new(OffsetParser::with_cubes(cubes.values())), + converter: std::sync::Arc::new(CubeConversionParser::with_cubes(cubes.values())), } } @@ -59,4 +65,8 @@ impl CubeParsers { pub fn offset(&self) -> std::sync::Arc { self.offset.clone() } + + pub fn converter(&self) -> std::sync::Arc { + self.converter.clone() + } } diff --git a/rc_core/src/persist/config/cubes_json.rs b/rc_core/src/persist/config/cubes_json.rs index ef4e423..af0e783 100644 --- a/rc_core/src/persist/config/cubes_json.rs +++ b/rc_core/src/persist/config/cubes_json.rs @@ -28,17 +28,28 @@ impl CubeConfig { pub fn load(root: impl AsRef) -> std::io::Result { let file = std::fs::File::open(root.as_ref().join(CUBE_CONFIG_FILENAME))?; let buffered = std::io::BufReader::new(file); - let result = serde_json::from_reader(buffered)?; - #[cfg(debug_assertions)] - { - let filename = root.as_ref().join(format!("{}.expanded.json", CUBE_CONFIG_FILENAME.trim_end_matches(".json"))); - let file = std::fs::File::create(filename)?; - let buffered = std::io::BufWriter::new(file); - serde_json::to_writer_pretty(buffered, &result)?; + let result = serde_json::from_reader::<_, Self>(buffered)?; + let result = result.expand_with_context(); + let expanded_filename = root.as_ref().join(format!("{}.expanded.json", CUBE_CONFIG_FILENAME.trim_end_matches(".json"))); + match std::fs::File::create(&expanded_filename) { + Ok(file) => { + let buffered = std::io::BufWriter::new(file); + serde_json::to_writer_pretty(buffered, &result)?; + }, + Err(e) => { + log::info!("Skipping creating {}: {}", expanded_filename.display(), e); + } } Ok(result) } + /// Populate more of the config for parts of it which need more context for choosing default values + fn expand_with_context(mut self) -> Self { + // TODO + self.cubes.values_mut().for_each(crate::persist::conversion::expand_conversion_data); + self + } + /// Performs configuration checks /// Returns true if validation succeeds, false if failed pub fn self_validate(&self, data_path: impl AsRef) -> bool { diff --git a/rc_core/src/persist/conversion.rs b/rc_core/src/persist/conversion.rs new file mode 100644 index 0000000..d7b4868 --- /dev/null +++ b/rc_core/src/persist/conversion.rs @@ -0,0 +1,1851 @@ +use serde::{Serialize, Deserialize}; + +pub(crate) fn actual_default_conversion() -> CubeConversionData { + CubeConversionData { + from: Vec::default(), + to: None, + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CubeConversionData { + pub from: Vec, + pub to: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum FromConversionData { + Simple(u32), + Complex { + id: u32, + offset: Option<(i16, i16, i16)>, + #[serde(default)] + colour: u8, + }, +} + +impl FromConversionData { + pub(crate) fn id(&self) -> u32 { + match self { + Self::Simple(id) => *id, + Self::Complex { id, .. } => *id, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum ToConversionData { + Simple(u32), + Complex { + id: u32, + offset: Option<(i16, i16, i16)>, + }, +} + +pub(crate) fn expand_conversion_data(cube: &mut super::Cube) { + if cube.conversion.is_some() { return; } + let conversion = match cube.id { + 0 => CubeConversionData { + from: (0..24).map(|i| FromConversionData::Complex { + id: 0, + offset: Some((i as _, (i * 4) as _, (i * -2) as _)), + colour: i as u8, + }).collect(), + to: None, + }, + // cone + 1352234106 => CubeConversionData { // MediumCone + from: vec![ + FromConversionData::Simple(914659855), // ArmoredConeT10 + FromConversionData::Complex { // ArmoredConeT10Orange + id: 903181232, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredConeT10Yellow + id: 2073756085, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredConeT10Gold + id: 420704, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1837280721), // HallowConeT10 + FromConversionData::Complex { // ArmoredConeT10Blue + id: 603738000, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredConeT10DarkBlue + id: 2126078066, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367602), // RareConeT10 + FromConversionData::Simple(311721892), // WolfConeT10 + FromConversionData::Simple(3504767087), // QuarterbackConeT10 + FromConversionData::Complex { // ArmoredConeT10C6B + id: 1558067038, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredConeT10MatteWhite + id: 914659773, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredConeT10MatteRed + id: 865911715, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredConeT10C6O + id: 2786019727, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredConeT10SType + id: 1503181726, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredConeT10MatteGreen + id: 107293578, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredConeT10MatteBlue + id: 1824768921, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredConeT10MattePurple + id: 1911941389, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 1979640496 => only_to(914659855), // CompactCone to ArmoredConeT10 + 3886136035 => only_to(914659855), // HeavytCone [sic] + 1422041599 => only_to(914659855), // LightCone + 552682446 => only_to(914659855), // NeonCone + // full cubes + 1576857358 => reciprocal(2234346032), // CompactCube + 123901970 => CubeConversionData { // heavy cube + from: vec![ + FromConversionData::Simple(1654632428), // HeavyChassisCube + FromConversionData::Simple(366000), // special april fools heavy + ], + to: Some(ToConversionData::Simple(1654632428)), + }, + 227205318 => CubeConversionData { // medium cube + from: vec![ + FromConversionData::Complex { // ArmoredCubeT10 + id: 227205318, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredCubeT10Orange + id: 903181219, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredCubeT10Yellow + id: 2073756071, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredCubeT10Gold + id: 420691, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(81509462), // HallowCubeT10 + FromConversionData::Complex { // ArmoredCubeT10Blue + id: 603737987, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredCubeT10DarkBlue + id: 2126078053, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367589), // RareArmoredCubeT3 + FromConversionData::Simple(3111721879), // WolfCubeT5 + FromConversionData::Simple(3504767074), // QuarterbackCubeT4 + FromConversionData::Complex { // ArmoredCubeT10MatteWhite + id: 2553833528, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredCubeT10MatteRed + id: 2688683104, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredCubeT10SType + id: 2688683104, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredCubeT10MatteGreen + id: 107293565, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredCubeT10MatteBlue + id: 1824768908, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredCubeT10MattePurple + id: 1911941376, + offset: NO_OFFSET, + colour: PURPLE, + }, + // TODO colour-match the tiered cubes + FromConversionData::Simple(936473914), // ArmoredCubeT9 + FromConversionData::Simple(3130466754), // ArmoredCubeT8 + FromConversionData::Simple(1602312433), // ArmoredCubeT7 + FromConversionData::Simple(3217516252), // ArmoredCubeT6 + FromConversionData::Simple(3902577141), // ArmoredCubeT5 + FromConversionData::Simple(148547544), // ArmoredCubeT4 + FromConversionData::Simple(1428281037), // ArmoredCubeT3 + FromConversionData::Simple(3523011797), // ArmoredCubeT2 + FromConversionData::Simple(3795589065), // ArmoredCubeT1 + // end tiered + FromConversionData::Simple(2256925527), // ArmoredCubeT10White_STypeSymbol + FromConversionData::Simple(1733271378), // ArmoredCubeT10SType_STypeSymbol + // no equivalent translation + // non-building blocks + FromConversionData::Simple(3951497291), // Fusion Tower + FromConversionData::Simple(606866102), // Protonium Clasp + FromConversionData::Simple(3950293873), // Protonium Crystal + + FromConversionData::Simple(2000000000), // Camera Cube + ], + to: None, + }, + 1972224393 => reciprocal(2295832800), // light cube + 3367514907 => CubeConversionData { // CubewithC6Logo + from: vec![ + FromConversionData::Complex { // ArmoredCubeT10C6B + id: 501172250, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredCubeT10C6O + id: 3461973291, + offset: NO_OFFSET, + colour: ORANGE, + }, + ], + to: None, + }, + 2392661891 => CubeConversionData { // CubewithCARBONLetters + from: vec![ + FromConversionData::Complex { // ArmoredCubeT10C6B_Six + id: 3367514907, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredCubeT10C6B_Carbon + id: 2392661891, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredCubeT10C6O_Six + id: 457113130, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredCubeT10C6O_Carbon + id: 1562104498, + offset: NO_OFFSET, + colour: ORANGE, + }, + ], + to: Some(ToConversionData::Simple(3367514907)), + }, + 3327812742 => CubeConversionData { // GlassCube + from: vec![ + FromConversionData::Simple(3969548222), // WindshieldCubeT10 + FromConversionData::Simple(3327812742), // WindshieldCube + ], + to: None, + }, + 2077855120 => only_to(227205318), // MediumCubeCardLife + 150161008 => only_to(227205318), // NeonCube + 183067052 => only_to(227205318), // RetroCube + // inners + 2378018821 => CubeConversionData { // CompactInner + from: vec![ + FromConversionData::Simple(314459661), // ArmoredInnerTX1 + FromConversionData::Complex { // GoldArmoredInnerTX1 + id: 783649019, + offset: NO_OFFSET, + colour: GOLD, + }, + ], + to: None, + }, + 2577871627 => CubeConversionData { // HeavyInner + from: vec![ + FromConversionData::Simple(4181414569), // HeavyChassisInner + FromConversionData::Simple(366003), // special april fools heavy inner + ], + to: Some(ToConversionData::Simple(1654632428)), + }, + 3559488176 => CubeConversionData { // MediumInner + from: vec![ + FromConversionData::Simple(3559488176), // ArmoredInnerT10 + FromConversionData::Complex { // ArmoredInnerT10Orange + id: 903181222, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredInnerT10Yellow + id: 2073756074, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredInnerT10Gold + id: 420694, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(2292045362), // HallowInnerT10 + FromConversionData::Complex { // ArmoredInnerT10Blue + id: 603737990, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredInnerT10DarkBlue + id: 2126078056, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1478755203), // RareArmoredInnerT3 + FromConversionData::Simple(1730552257), // WolfInnerT5 + FromConversionData::Simple(3293765065), // QuarterbackInnerT4 + FromConversionData::Complex { // ArmoredInnerT10C6B + id: 4039842231, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredInnerT10MatteWhite + id: 3217177779, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredInnerT10MatteRed + id: 865911707, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredInnerT10C6O + id: 592303238, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredInnerT10SType + id: 1503181716, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredInnerT10MatteGreen + id: 107293568, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredInnerT10MatteBlue + id: 1824768911, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredInnerT10MattePurple + id: 1911941379, + offset: NO_OFFSET, + colour: PURPLE, + }, + FromConversionData::Complex { // ArmoredInnerT10MatteGreen + id: 107293568, + offset: NO_OFFSET, + colour: GREEN, + }, + // TODO colour-match the tiered cubes + FromConversionData::Simple(2704279673), // ArmoredInnerT9 + FromConversionData::Simple(1664400308), // ArmoredInnerT8 + FromConversionData::Simple(3737084984), // ArmoredInnerT7 + FromConversionData::Simple(1717894314), // ArmoredInnerT6 + FromConversionData::Simple(1772266812), // ArmoredInnerT5 + FromConversionData::Simple(3514383790), // ArmoredInnerT4 + FromConversionData::Simple(3558861316), // ArmoredInnerT3 + FromConversionData::Simple(140476067), // ArmoredInnerT2 + FromConversionData::Simple(1661086464), // ArmoredInnerT1 + // no equivalent translation + // pyramid inner + FromConversionData::Simple(366009), // special april fools heavy pyramid inner + FromConversionData::Simple(914659851), // ArmoredPyramidInnerT10 + FromConversionData::Simple(903181228), // ArmoredRoundPyramidInnerT10Orange + FromConversionData::Simple(2073756081), // ArmoredPyramidInnerT10Yellow + FromConversionData::Simple(420700), // ArmoredPyramidInnerT10Gold + FromConversionData::Simple(1837280717), // HallowPyramidInnerT10 + FromConversionData::Simple(603737996), // ArmoredPyramidInnerT10Blue + FromConversionData::Simple(2126078062), // ArmoredPyramidInnerT10DarkBlue + FromConversionData::Simple(1727367598), // RarePyramidInnerT10 + FromConversionData::Simple(311721888), // WolfPyramidInnerT10 + FromConversionData::Simple(3504767083), // QuarterbackPyramidInnerT10 + FromConversionData::Simple(1558067034), // ArmoredPyramidInnerT10C6B + FromConversionData::Simple(914659769), // ArmoredPyramidInnerT10MatteWhite + FromConversionData::Simple(865911711), // ArmoredPyramidInnerT10MatteRed + FromConversionData::Simple(2786019731), // ArmoredPyramidInnerT10C6O + FromConversionData::Simple(1503181722), // ArmoredPyramidInnerT10SType + FromConversionData::Simple(107293574), // ArmoredPyramidInnerT10MatteGreen + FromConversionData::Simple(1824768917), // ArmoredPyramidInnerT10MatteBlue + FromConversionData::Simple(1911941385), // ArmoredPyramidInnerT10MattePurple + ], + to: None, + }, + 1941682825 => reciprocal(3920646105), // light inner + 445857183 => CubeConversionData { // GlassInner + from: vec![ + FromConversionData::Simple(2305492794), // WindshieldInnerT10 + FromConversionData::Simple(445857183), // WindshieldInner + ], + to: None, + }, + 1616217456 => only_to(3559488176), // NeonInner + 3985049715 => only_to(3559488176), // RetroInner + // edges aka prisms + 327960333 => CubeConversionData { // CompactEdge + from: vec![ + FromConversionData::Simple(3003461135), // ArmoredPrismTX1 + FromConversionData::Complex { // GoldArmoredPrismTX1 + id: 776898512, + offset: NO_OFFSET, + colour: GOLD, + }, + ], + to: None, + }, + 1225928721 => CubeConversionData { // HeavyEdge + from: vec![ + FromConversionData::Simple(2686466102), // HeavyChassisPrism + FromConversionData::Simple(366001), // special april fools heavy prism + ], + to: Some(ToConversionData::Simple(1654632428)), + }, + 227917916 => CubeConversionData { // MediumEdge + from: vec![ + FromConversionData::Simple(227917916), // ArmoredPrismT10 + FromConversionData::Complex { // ArmoredPrismT10Orange + id: 903181220, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredPrismT10Yellow + id: 2073756072, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredPrismT10Gold + id: 420692, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1309620559), // HallowPrismT10 + FromConversionData::Complex { // ArmoredPrismT10Blue + id: 603737988, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredPrismT10DarkBlue + id: 2126078054, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(2662788350), // RareArmoredPrismT3 + FromConversionData::Simple(3197803309), // WolfPrismT5 + FromConversionData::Simple(46150324), // QuarterbackPrismT4 + FromConversionData::Complex { // ArmoredPrismT10C6B + id: 2752826359, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredPrismT10MatteWhite + id: 2067295656, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredPrismT10MatteRed + id: 1097117136, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredInnerT10C6O + id: 2006058694, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredPrismT10SType + id: 1503181714, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredPrismT10MatteGreen + id: 107293566, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredPrismT10MatteBlue + id: 1824768909, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredPrismT10MattePurple + id: 1911941377, + offset: NO_OFFSET, + colour: PURPLE, + }, + FromConversionData::Complex { // ArmoredInnerT10MatteGreen + id: 107293568, + offset: NO_OFFSET, + colour: GREEN, + }, + // TODO colour-match the tiered cubes + FromConversionData::Simple(3159626035), // ArmoredPrismT9 + FromConversionData::Simple(3129475416), // ArmoredPrismT8 + FromConversionData::Simple(117694164), // ArmoredPrismT7 + FromConversionData::Simple(3218622022), // ArmoredPrismT6 + FromConversionData::Simple(2954764240), // ArmoredPrismT5 + FromConversionData::Simple(147162946), // ArmoredPrismT4 + FromConversionData::Simple(228806888), // ArmoredPrismT3 + FromConversionData::Simple(3521365071), // ArmoredPrismT2 + FromConversionData::Simple(3133051372), // ArmoredPrismT1 + // no equivalent translation + ], + to: None, + }, + 4157111053 => reciprocal(2231909735), // light edge + 4235300269 => CubeConversionData { // GlassEdge + from: vec![ + FromConversionData::Simple(2956906195), // WindshieldPrismT10 + FromConversionData::Simple(4235300269), // WindshieldPrism + ], + to: None, + }, + 348383505 => only_to(227917916), // MediumEdgeCardLife + 1183051379 => only_to(227917916), // NeonEdge + 1150929327 => only_to(227917916), // RetroEdge + // pyramids aka pyramid tetras + 2686602224 => CubeConversionData { // MediumPyramid + from: vec![ + FromConversionData::Simple(914659850), // ArmoredPyramidTetraT10 + FromConversionData::Complex { // ArmoredPyramidTetraT10Orange + id: 903181227, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10Yellow + id: 2073756080, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10Gold + id: 420699, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1837280716), // HallowPyramidTetraT10 + FromConversionData::Complex { // ArmoredPyramidTetraT10Blue + id: 603737995, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10DarkBlue + id: 2126078061, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367597), // RarePyramidTetraT10 + FromConversionData::Simple(311721887), // WolfPyramidTetraT10 + FromConversionData::Simple(3504767082), // QuarterbackPyramidTetraT10 + FromConversionData::Complex { // ArmoredPyramidTetraT10C6B + id: 1558067033, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10MatteWhite + id: 914659768, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10MatteRed + id: 865911710, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10C6O + id: 2786019732, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10SType + id: 1503181721, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10MatteGreen + id: 107293573, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10MatteBlue + id: 1824768916, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredPyramidTetraT10MattePurple + id: 1911941384, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 979123471 => only_to(914659850), // CompactPyramid to ArmoredPyramidTetraT10 + 4184274974 => only_to(914659850), // HeavyPyramid to ArmoredPyramidTetraT10 + 885415789 => only_to(914659850), // LightPyramid + 3447434946 => only_to(914659850), // NeonPyramid + // no glass pyramid exists + // wedges (just the headlamp) + 1367205243 => CubeConversionData { // Headlamp + from: vec![ + FromConversionData::Simple(1367205243), // Headlamp + FromConversionData::Simple(366007), // special april fools heavy wedge + FromConversionData::Simple(914659766), // ArmoredWedgeT10 + FromConversionData::Simple(903181226), // ArmoredWedgeT10Orange + FromConversionData::Simple(2073756079), // ArmoredWedgeT10Yellow + FromConversionData::Simple(420698), // ArmoredWedgeT10Gold + FromConversionData::Simple(1837280715), // HallowWedgeT10 + FromConversionData::Simple(603737994), // ArmoredWedgeT10Blue + FromConversionData::Simple(2126078060), // ArmoredWedgeT10DarkBlue + FromConversionData::Simple(1727367596), // RareWedgeT10 + FromConversionData::Simple(311721886), // WolfWedgeT10 + FromConversionData::Simple(3504767081), // QuarterbackWedgeT10 + FromConversionData::Simple(1558067032), // ArmoredWedgeT10C6B + FromConversionData::Simple(914659767), // ArmoredWedgeT10White + FromConversionData::Simple(865911709), // ArmoredWedgeT10MatteRed + FromConversionData::Simple(2786019733), // ArmoredWedgeT10C6O + FromConversionData::Simple(1503181720), // ArmoredWedgeT10SType + FromConversionData::Simple(107293572), // ArmoredWedgeT10MatteGreen + FromConversionData::Simple(1824768915), // ArmoredWedgeT10MatteBlue + FromConversionData::Simple(1911941383), // ArmoredWedgeT10MattePurple + ], + to: Some(ToConversionData::Simple(1367205243)), + }, + // Inner round aka round inner (lol) + 1837286858 => CubeConversionData { // MediumInnerRound + from: vec![ + FromConversionData::Simple(1837286858), // ArmoredInnerRoundT10 + FromConversionData::Complex { // ArmoredRoundInnerT10Orange + id: 903181225, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10Yellow + id: 2073756078, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10Gold + id: 420697, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1837280714), // HallowInnerRoundT10 + FromConversionData::Complex { // ArmoredRoundInnerT10Blue + id: 603737993, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10DarkBlue + id: 2126078059, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367595), // RareRoundInnerT10 + FromConversionData::Simple(311721885), // WolfRoundInnerT10 + FromConversionData::Simple(3504767080), // QuarterbackRoundInnerT10 + FromConversionData::Complex { // ArmoredRoundInnerT10C6B + id: 1558067031, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10MatteWhite + id: 3217177780, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10MatteRed + id: 865911708, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10C6O + id: 2786019734, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10SType + id: 1503181719, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10MatteGreen + id: 107293571, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10MatteBlue + id: 1824768914, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredRoundInnerT10MattePurple + id: 1911941382, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 925214026 => only_to(1837286858), // CompactInnerRound to ArmoredInnerRoundT10 + 665761850 => CubeConversionData { + from: vec![ + FromConversionData::Simple(366006), // special april fools heavy round inner + ], + to: Some(ToConversionData::Simple(1837286858)), // ArmoredInnerRoundT10 + }, + 1578378954 => only_to(1837286858), // LightInnerRound + 1774712137 => only_to(1837286858), // NeonInnerRound + 3066776961 => reciprocal(477710581), // GlassInnerRound to WindshieldRoundInnerT10 + // edge round aka round prism + 447126572 => CubeConversionData { // MediumEdgeRound + from: vec![ + FromConversionData::Simple(447126572), // ArmoredPrismRoundT10 + FromConversionData::Complex { // ArmoredRoundPrismT10Orange + id: 903181223, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10Yellow + id: 2073756075, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10Gold + id: 420695, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(447124524), // HallowPrismRoundT10 + FromConversionData::Complex { // ArmoredRoundPrismT10Blue + id: 603737991, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10DarkBlue + id: 2126078057, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367593), // RareRoundPrismT10 + FromConversionData::Simple(311721883), // WolfRoundPrismT10 + FromConversionData::Simple(3504767078), // QuarterbackRoundPrismT10 + FromConversionData::Complex { // ArmoredRoundPrismT10C6B + id: 1018457748, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10MatteWhite + id: 2067295657, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10MatteRed + id: 1097117137, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10C6O + id: 3328512085, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10SType + id: 1503181717, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10MatteGreen + id: 107293569, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10MatteBlue + id: 1824768912, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredRoundPrismT10MattePurple + id: 1911941380, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 676446887 => only_to(447126572), // CompactEdgeRound to ArmoredPrismRoundT10 + 368051733 => CubeConversionData { + from: vec![ + FromConversionData::Simple(366006), // special april fools heavy round inner + ], + to: Some(ToConversionData::Simple(447126572)), // ArmoredPrismRoundT10 + }, + 1993583577 => only_to(447126572), // LightEdgeRound + 2623263862 => only_to(447126572), // NeonEdgeRound + 3629216992 => reciprocal(2407011807), // GlassEdgeRound to WindshieldRoundPrismT10 + // corner round aka round tetra + 2589418111 => CubeConversionData { // MediumCornerRound + from: vec![ + FromConversionData::Simple(2589418111), // ArmoredTetraRoundT10 + FromConversionData::Complex { // ArmoredRoundTetraT10Orange + id: 903181224, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10Yellow + id: 2073756076, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10Gold + id: 420696, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(2089416063), // HallowTetraRoundT10 + FromConversionData::Complex { // ArmoredRoundTetraT10Blue + id: 603737992, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10DarkBlue + id: 2126078058, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367594), // RareRoundTetraT10 + FromConversionData::Simple(311721884), // WolfRoundTetraT10 + FromConversionData::Simple(3504767079), // QuarterbackRoundTetraT10 + FromConversionData::Complex { // ArmoredRoundTetraT10C6B + id: 4200979110, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10MatteWhite + id: 904092408, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10MatteRed + id: 2704843488, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10C6O + id: 11838567, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10SType + id: 1503181718, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10MatteGreen + id: 107293570, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10MatteBlue + id: 1824768913, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredRoundTetraT10MattePurple + id: 1911941381, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 1728084091 => only_to(2589418111), // CompactCornerRound to ArmoredTetraRoundT10 + 2148353926 => CubeConversionData { // HeavyCornerRound + from: vec![ + FromConversionData::Simple(366005), // special april fools heavy round tetra + ], + to: Some(ToConversionData::Simple(2589418111)), // ArmoredTetraRoundT10 + }, + 582052348 => only_to(2589418111), // LightCornerRound + 3195590950 => only_to(2589418111), // NeonCornerRound + 2380494106 => reciprocal(1907975281), // GlassCornerRound to WindshieldRoundTetraT10 + // inner slope aka sloped inner + 495400745 => CubeConversionData { // MediumInnerSlope + from: vec![ + FromConversionData::Simple(914659854), // ArmoredSlopedInnerT10 + FromConversionData::Complex { // ArmoredSlopedInnerT10Orange + id: 903181231, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10Yellow + id: 2073756084, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10Gold + id: 420703, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1837280720), // HallowInnerSlopedT10 + FromConversionData::Complex { // ArmoredSlopedInnerT10Blue + id: 603737999, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10DarkBlue + id: 2126078065, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367601), // RareSlopedInnerT10 + FromConversionData::Simple(311721891), // WolfSlopedInnerT10 + FromConversionData::Simple(3504767086), // QuarterbackSlopedInnerT10 + FromConversionData::Complex { // ArmoredSlopedInnerT10C6B + id: 1558067037, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10MatteWhite + id: 914659772, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10MatteRed + id: 865911714, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10C6O + id: 2786019728, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10SType + id: 1503181725, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10MatteGreen + id: 107293577, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10MatteBlue + id: 1824768920, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredSlopedInnerT10MattePurple + id: 1911941388, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 1834966563 => only_to(914659854), // CompactInnerSlope to ArmoredSlopedInnerT10 + 2111062867 => only_to(914659854), // HeavyInnerSlope + 2426642454 => only_to(914659854), // LightInnerSlope + 868027936 => only_to(914659854), // NeonInnerSlope + // no glass inner slope + // edge slope aka sloped prism + 1538778047 => CubeConversionData { // MediumEdgeSlope + from: vec![ + FromConversionData::Simple(914659852), // ArmoredSlopedPrismT10 + FromConversionData::Complex { // ArmoredSlopedPrismT10Orange + id: 903181229, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10Yellow + id: 2073756082, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10Gold + id: 420701, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1837280718), // HallowPrismSlopedT10 + FromConversionData::Complex { // ArmoredSlopedPrismT10Blue + id: 603737997, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10DarkBlue + id: 2126078063, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367599), // RareSlopedPrismT10 + FromConversionData::Simple(311721889), // WolfSlopedPrismT10 + FromConversionData::Simple(3504767084), // QuarterbackSlopedPrismT10 + FromConversionData::Complex { // ArmoredSlopedPrismT10C6B + id: 1558067035, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10MatteWhite + id: 914659770, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10MatteRed + id: 865911712, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredSlopedPrism10C6O + id: 2786019730, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredTetraT10SType [sic?] + id: 1503181723, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10MatteGreen + id: 107293575, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10MatteBlue + id: 1824768918, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredSlopedPrismT10MattePurple + id: 1911941386, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 1915435470 => only_to(914659852), // CompactEdgeSlope to ArmoredSlopedPrismT10 + 1334508924 => only_to(914659852), // HeavyEdgeSlope + 3093572869 => only_to(914659852), // LightEdgeSlope + 3324063519 => only_to(914659852), // NeonEdgeSlope + // corner slope aka sloped tetra + 557016830 => CubeConversionData { // MediumCornerSlope + from: vec![ + FromConversionData::Simple(914659852), // ArmoredSlopedTetraT10 + FromConversionData::Complex { // ArmoredSlopedTetraT10Orange + id: 903181230, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10Yellow + id: 2073756083, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10Gold + id: 420702, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1837280719), // HallowTetraSlopedT10 + FromConversionData::Complex { // ArmoredSlopedTetraT10Blue + id: 603737998, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10DarkBlue + id: 2126078064, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(1727367600), // RareSlopedTetraT10 + FromConversionData::Simple(311721890), // WolfSlopedTetraT10 + FromConversionData::Simple(3504767085), // QuarterbackSlopedTetraT10 + FromConversionData::Complex { // ArmoredSlopedTetraT10C6B + id: 1558067036, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10MatteWhite + id: 914659771, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10MatteRed + id: 865911713, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10C6O + id: 2786019729, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10SType + id: 1503181724, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10MatteGreen + id: 107293576, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10MatteBlue + id: 1824768919, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredSlopedTetraT10MattePurple + id: 1911941387, + offset: NO_OFFSET, + colour: PURPLE, + }, + // no equivalent translation + ], + to: None, + }, + 1881278783 => only_to(914659853), // CompactCornerSlope to ArmoredSlopedTetraT10 + 3665280239 => only_to(914659853), // HeavyCornerSlope + 3959877408 => only_to(914659853), // LightCornerSlope + 3825345103 => only_to(914659853), // NeonCornerSlope + // corner aka tetra + 126353766 => CubeConversionData { // CompactCorner + from: vec![ + FromConversionData::Simple(1362505337), // ArmoredTetraTX + FromConversionData::Complex { // GoldArmouredTetraTX + id: 734598081, + offset: NO_OFFSET, + colour: GOLD, + }, + ], + to: None, + }, + 222074329 => CubeConversionData { // HeavyCorner + from: vec![ + FromConversionData::Simple(2157492751), // HeavyChassisTetra + FromConversionData::Simple(366002), // special april fools heavy tetra + ], + to: Some(ToConversionData::Simple(2157492751)), + }, + 1931676396 => CubeConversionData { // MediumCorner + from: vec![ + FromConversionData::Simple(1931676396), // ArmoredTetraT10 + FromConversionData::Complex { // ArmoredTetraT10Orange + id: 903181221, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredTetraT10Yellow + id: 2073756073, + offset: NO_OFFSET, + colour: YELLOW, + }, + FromConversionData::Complex { // ArmoredTetraT10Gold + id: 420693, + offset: NO_OFFSET, + colour: GOLD, + }, + FromConversionData::Simple(1857534838), // HallowTetraT10 + FromConversionData::Complex { // ArmoredTetraT10Blue + id: 603737989, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredTetraT10DarkBlue + id: 2126078055, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Simple(3188616903), // RareArmoredTetraT3 + FromConversionData::Simple(3224129949), // WolfTetraT5 + FromConversionData::Simple(578334861), // QuarterbackTetraT4 + FromConversionData::Complex { // ArmoredTetraT10C6B + id: 2388981877, + offset: NO_OFFSET, + colour: BLACK, + }, + FromConversionData::Complex { // ArmoredTetraT10MatteWhite + id: 904092407, + offset: NO_OFFSET, + colour: WHITE, + }, + FromConversionData::Complex { // ArmoredTetraT10MatteRed + id: 2704843487, + offset: NO_OFFSET, + colour: RED, + }, + FromConversionData::Complex { // ArmoredTetraT10C6O + id: 1575105860, + offset: NO_OFFSET, + colour: ORANGE, + }, + FromConversionData::Complex { // ArmoredTetraT10SType + id: 1503181715, + offset: NO_OFFSET, + colour: STYPE, + }, + FromConversionData::Complex { // ArmoredTetraT10MatteGreen + id: 107293567, + offset: NO_OFFSET, + colour: GREEN, + }, + FromConversionData::Complex { // ArmoredTetraT10MatteBlue + id: 1824768910, + offset: NO_OFFSET, + colour: BLUE, + }, + FromConversionData::Complex { // ArmoredTetraT10MattePurple + id: 1911941378, + offset: NO_OFFSET, + colour: PURPLE, + }, + // TODO colour-match the tiered cubes + FromConversionData::Simple(3265763652), // ArmoredTetraT9 + FromConversionData::Simple(3292449768), // ArmoredTetraT8 + FromConversionData::Simple(2041910372), // ArmoredTetraT7 + FromConversionData::Simple(3245247734), // ArmoredTetraT6 + FromConversionData::Simple(3467171168), // ArmoredTetraT5 + FromConversionData::Simple(1987268082), // ArmoredTetraT4 + FromConversionData::Simple(1930795608), // ArmoredTetraT3 + FromConversionData::Simple(2941587199), // ArmoredTetraT2 + FromConversionData::Simple(3288882012), // ArmoredTetraT1 + // end tiered + // no equivalent translation + ], + to: None, + }, + 1347255791 => reciprocal(1754827363), // LightCorner to LightChassisTetra + 1094997616 => CubeConversionData { // GlassCorner + from: vec![ + FromConversionData::Simple(1593042212), // WindshieldTetraT10 + FromConversionData::Simple(1094997616), // WindshieldTetra + ], + to: None, + }, + 2333317284 => only_to(1931676396), // NeonCorner + 1387792676 => only_to(1931676396), // RetroCorner + // Helium (sort of a chassic cube) + 3226650954 => CubeConversionData { // Helium + from: vec![ + FromConversionData::Simple(3425997036), + FromConversionData::Simple(3305279060), + FromConversionData::Simple(1914654544), + FromConversionData::Simple(2001668174), + FromConversionData::Simple(3226650954), + ], + to: Some(ToConversionData::Simple(3226650954)), + }, + // country flags + 1385335421 => CubeConversionData { // Canada (also used as fallback country flag) + from: vec![ + FromConversionData::Simple(1385335421), // Canada + FromConversionData::Simple(1958111701), // DominicanRepublic + FromConversionData::Simple(1958111702), // Egypt + FromConversionData::Simple(1958111703), // Estonia + FromConversionData::Simple(1958111704), // Greece + FromConversionData::Simple(1958111705), // HongKong + FromConversionData::Simple(1958111706), // Hungary + FromConversionData::Simple(1958111708), // Latvia + FromConversionData::Simple(1958111709), // Liechtenstein + FromConversionData::Simple(1958111710), // Lithuania + FromConversionData::Simple(1958111711), // Mongolia + FromConversionData::Simple(1958111712), // Sardinia + FromConversionData::Simple(1958111713), // Taiwan + FromConversionData::Simple(1958111714), // Thailand + //FromConversionData::Simple(1958111715), // Turkey + // Unknown countries + FromConversionData::Simple(1395013882), // ??? + FromConversionData::Simple(2877144578), // ??? + FromConversionData::Simple(1875812779), // ??? + FromConversionData::Simple(2997211596), // ??? + FromConversionData::Simple(987493977), // ??? + FromConversionData::Simple(1122928047), // ??? + FromConversionData::Simple(1971217946), // ??? + FromConversionData::Simple(766565997), // ??? + ], + to: None, + }, + 4065608086 => only_to(68277216), // Russia to Ukraine + 68277216 => CubeConversionData { // Ukraine + from: vec![ + FromConversionData::Simple(68277216), // Ukraine + FromConversionData::Simple(4065608086), // Russia + ], + to: Some(ToConversionData::Simple(68277216)), + }, + 4175295691 => reciprocal(4175295691), // UK + 3559676835 => reciprocal(3559676835), // USA + 2020064218 => reciprocal(2020064218), // Japan + 2730691972 => reciprocal(2730691972), // Poland + 2596479965 => reciprocal(2596479965), // Germany + 3330651823 => reciprocal(3330651823), // Kazakhstan + 1768151340 => reciprocal(1768151340), // Belarus + 4166201860 => reciprocal(4166201860), // Italy + 3447406366 => reciprocal(3447406366), // Ireland + 664490215 => reciprocal(664490215), // Australia + 3114896263 => reciprocal(3114896263), // Spain + 2243089188 => reciprocal(2243089188), // Brazil + 88051662 => reciprocal(88051662), // Netherlands + 354715741 => reciprocal(354715741), // Argentina + 1786715320 => reciprocal(1786715320), // Sweden + 3910121366 => reciprocal(3910121366), // China + 2373812955 => reciprocal(2373812955), // France + 460733827 => reciprocal(460733827), // South Korea + // newer country flags (added after RC15) + 2855651676 => only_to(1385335421), // New Zealand to Canada + 932603502 => only_to(1385335421), // Denmark to Canada + 1796059677 => only_to(1385335421), // IcelandHoloflag + 1798659636 => only_to(1385335421), // BelgiumHoloFlag + 4070927930 => reciprocal(1958111715), // Turkiye/Turkey has a classic equivalent + // general flags + 3024755361 => reciprocal(3024755361), // Humble Bundle + 1039436321 => reciprocal(1039436321), // Curse + 1958111892 => CubeConversionData { // Robocraft (also used as fallback flag) + from: vec![ + FromConversionData::Simple(1958111892), // Robocraft + // These are all sequential, so likely added by client mods + //FromConversionData::Simple(1958111896), // pirate + FromConversionData::Simple(1958111895), // mars + FromConversionData::Simple(1958111897), // ??? + FromConversionData::Simple(1958111898), // ??? + FromConversionData::Simple(1958111901), // anniversary flag ??? + FromConversionData::Simple(1958111902), // anniversary flag ??? + FromConversionData::Simple(1958111903), // anniversary flag ??? + FromConversionData::Simple(1958111904), // anniversary flag ??? + FromConversionData::Simple(1754621522), // hex badge ??? + FromConversionData::Simple(1754621523), // hex badge ??? + FromConversionData::Simple(1754621523), // hex badge ??? + FromConversionData::Simple(1958111893), // ??? + FromConversionData::Simple(1958111707), // unused (for now?) + FromConversionData::Simple(1958111897), // E14 + FromConversionData::Simple(1958111898), // CF-Alliance + ], + to: Some(ToConversionData::Simple(1958111892)), + }, + 4200145444 => reciprocal(4200145444), // Dev Supporter + 3370765914 => reciprocal(3370765914), // Gold Dev Supporter + 3996493866 => reciprocal(1754621525), // 3YearsHoloflag (to badge equiv) + 507555577 => reciprocal(1754621526), // 4YearsHoloflag (to badge equiv) + 4124816651 => reciprocal(1958111896), // PirateHoloflag + // non-translatable flags + 3411783298 => only_to(1385335421), // RoboPassSeason2Holoflag + 2663263630 => only_to(1385335421), // BunnyHoloflag + 3131616996 => only_to(1385335421), // EasterEggHoloflag + 3266439135 => only_to(1385335421), // FlowersHoloflag + 216301182 => only_to(1385335421), // 2019Holoflag + 2412453676 => only_to(1385335421), // CandyCaneHoloflag + 2990880144 => only_to(1385335421), // SantaCrayHoloflag + 3058601525 => only_to(1385335421), // RoboPassSeason1Holoflag + 327459709 => only_to(1385335421), // SnowflakeHoloflag + 2537158398 => only_to(1385335421), // 6YearsHoloflag + 757806708 => only_to(1385335421), // TopHundredHoloflag + 1020482017 => only_to(1385335421), // OverwolfHoloflag + 1607707925 => only_to(1385335421), // AbleGamersCharityHoloflag + 4079824960 => only_to(1385335421), // YogscastHoloflag + 862329300 => only_to(1385335421), // ChronoGGHoloflag + 1042028710 => only_to(1385335421), // IntelHoloflag/Nyan Cray/Rainbow + 2345098755 => only_to(1385335421), // AlienwareHoloflag + 3210649465 => only_to(1385335421), // 5YearsHoloflag + // badges + 1301942811 => only_to(1754621522), // BronzeLeagueBadge to some badge (yes I know league badges aren't the same shape) + 4157599523 => only_to(1754621522), // SilverLeagueBadge + 1893098069 => only_to(1754621522), // GoldLeagueBadge + 608517870 => only_to(1754621522), // DiamondLeagueBadge + 2046961989 => only_to(1754621522), // ProtoniumLeagueBadge + 1988022506 => only_to(1754621522), // ProtoniumLeagueFiveStarsBadge + 1551110695 => only_to(1754621522), // SilverLeagueBadgeArena + 2514755079 => only_to(1754621522), // GoldLeagueBadgeArena + 988387568 => only_to(1754621522), // BronzeLeagueBadgeArena + // masks + // cockpit + 2530225659 => reciprocal(2530225659), + 3970675339 => reciprocal(3970675339), + 1354711861 => reciprocal(1354711861), + 717544517 => reciprocal(717544517), + 2711624874 => reciprocal(2711624874), + 3688596442 => reciprocal(3688596442), + // other masks + 451055140 => reciprocal(451055140), // ??? + 1620644180 => reciprocal(1620644180), // ??? + 3561288328 => reciprocal(3561288328), // ??? + 2923392504 => reciprocal(2923392504), // ??? + 2479898264 => reciprocal(2479898264), // ??? + 3920339432 => reciprocal(3920339432), // ??? + 2906446214 => reciprocal(2906446214), // ??? + 3611772662 => reciprocal(3611772662), // ??? + 1217647354 => reciprocal(1217647354), // ??? + 854085002 => reciprocal(854085002), // ??? + 604924218 => reciprocal(604924218), // ??? + 1584789066 => reciprocal(1584789066), // ??? + 4155139139 => reciprocal(4155139139), // ??? + 2379348787 => reciprocal(2379348787), // ??? + 2123945215 => reciprocal(2123945215), // ??? + 82004879 => reciprocal(82004879), // ??? + 1554012392 => reciprocal(1554012392), // ??? + 651937688 => reciprocal(651937688), // ??? + 1268201954 => reciprocal(1268201954), // ??? + 837592722 => reciprocal(837592722), // ??? + 1108501665 => reciprocal(1108501665), // ??? + 946469841 => reciprocal(946469841), // ??? + 1839067602 => reciprocal(1839067602), // ??? + 400977570 => reciprocal(400977570), // ??? + 1762483815 => reciprocal(1762483815), // ??? + 326549783 => reciprocal(326549783), // ??? + 670637166 => reciprocal(670637166), // ??? + 1568834334 => reciprocal(1568834334), // ??? + 438539744 => reciprocal(438539744), // ??? + 1616382608 => reciprocal(1616382608), // ??? + 4200383087 => reciprocal(4200383087), // ??? + 2150063391 => reciprocal(2150063391), // ??? + 3060186489 => reciprocal(3060186489), // ??? + 3424461321 => reciprocal(3424461321), // ??? + 1936618195 => reciprocal(1936618195), // ??? + 152432035 => reciprocal(152432035), // ??? + 2075163291 => reciprocal(2075163291), // ??? + 30139883 => reciprocal(30139883), // ??? + 2915102202 => reciprocal(2915102202), // ??? + 3619385994 => reciprocal(3619385994), // ??? + 665365647 => reciprocal(665365647), // ??? + 1574122495 => reciprocal(1574122495), // ??? + // misc cosmetics + 688123941 => reciprocal(688123941), // SpikeDagger + 2782198650 => reciprocal(2782198650), // RobotNameBanner + 2820370110 => reciprocal(2820370110), // Altimeter + 3531325242 => reciprocal(3531325242), // Speedometer + 3610833057 => reciprocal(3610833057), // VaporTrailSingle + 3137009174 => only_to(3610833057), // VaporTrailSingleFlower + 1810319153 => only_to(3610833057), // VaporTrailSingleFirework + 3297983510 => only_to(3610833057), // VaporTrailSingleSnowflake + 236893100 => reciprocal(236893100), // VaporTrailTwin + // electroplates + 3823106277 => reciprocal(3823106277), // ElectroshieldIL + 1889152405 => reciprocal(1889152405), // ElectroshieldIR + 3001609712 => reciprocal(3001609712), // ElectroshieldJL + 3365106304 => reciprocal(3365106304), // ElectroshieldJR + 991790322 => reciprocal(991790322), // ElectroshieldKL + 1097210754 => reciprocal(1097210754), // ElectroshieldKR + 726724318 => reciprocal(726724318), // T3ElectroShieldDLSpiked + 1361801646 => reciprocal(1361801646), // T3ElectroShieldDRSpiked + 3248328684 => reciprocal(3248328684), // T3ElectroShieldDLFootball + 3152416924 => reciprocal(3152416924), // T3ElectroShieldDRFootball + // T1 + 1264237222 => reciprocal(1264237222), + 2965095316 => reciprocal(2965095316), + // T2 + 2457663915 => reciprocal(2457663915), + 1771929753 => reciprocal(1771929753), + // T3 + 627128495 => reciprocal(627128495), + 3732947357 => reciprocal(3732947357), + // T4 + 540114865 => reciprocal(540114865), + 3687843459 => reciprocal(3687843459), + // T5 + 2536292021 => reciprocal(2536292021), + 1825425287 => reciprocal(1825425287), + // T6 + 1309343160 => reciprocal(1309343160), + 3052110986 => reciprocal(3052110986), + // T7 + 4179049660 => reciprocal(4179049660), + 49694094 => reciprocal(49694094), + // T8 + 1152021380 => reciprocal(1152021380), + 3209366198 => reciprocal(3209366198), + // T9 + 4088902272 => reciprocal(4088902272), + 139905970 => reciprocal(139905970), + // Weapons + // Nanos + 1761903423 => reciprocal(1761903423), // T2Nano + 1671695619 => variant_dual(1671695619, 3726197307), // T3Nano to T7/T8 + 2364175165 => variant_dual(2364175165, 3568946183), // T4Nano to T9/T10 + // Plasma + 2655409492 => fallback_conversion(), // T0Plasma + 1293532710 => variant_dual(1412971690, 1293532710), // T1Plasma to T3/T4 + 2446895092 => variant_dual(3810917806, 2446895092), // T2Plasma to T5/T6 + 3953551555 => variant_dual(3866475184, 3953551555), // T3Plasma to T7/T8 + 929526033 => variant_dual(1365801908, 929526033), // T4Plasma to T9/T10 + 1593059007 => only_to(929526033), // T4PlasmaCarbon6 + 171448327 => reciprocal(171448327), // T5Plasma (mega) + 2943284935 => reciprocal(2943284935), // T5PlasmaCarbon6 (mega) + 3594602569 => only_to(171448327), // T5PlasmaGolden + // Rail + 1640043587 => reciprocal(1640043587), // T1Rail to T4 + 2870850840 => variant_dual(360854742, 2870850840), // T2Rail to T5/T6 + 1779831198 => variant_dual(282227656, 1779831198), // T3Rail to T7/T8 + 2697638085 => variant_dual(2815406796, 2697638085), // T4Rail to T9/T10 + 2083340214 => only_to(2697638085), // T5Rail to T10; mega does not exist in classic game + 447003593 => only_to(2697638085), // T5RailGolden + // SMG front mounts + 3402350870 => variant_dual(1337963121, 3402350870), // T0FrontLaser to T1/T2 + 2494440442 => variant_dual(776342052, 2494440442), // T1FrontLaser to T3/T4 + 1584562849 => variant_dual(1497447090, 1584562849), // T2FrontLaser to T5/T6 + 2675516967 => variant_dual(3341135633, 2675516967), // T3FrontLaser to T7/T8 + 1436911484 => variant_dual(2955059079, 1436911484), // T4FrontLaser to T9/T10 + // SMG top mounts + 3809895395 => variant_dual(2929054885, 3809895395), // T0Laser to T1/T2 + 3178463503 => variant_dual(2985370719, 3178463503), // T1Laser to T3/T4 + 2007965780 => variant_dual(3338015945, 2007965780), // T2Laser to T5/T6 + 3064235218 => variant_dual(1485996394, 3064235218), // T3Laser to T7/T8 + //2088248713 => variant_dual(798339580, 2088248713), // T4Laser to T9/T10 + 2088248713 => CubeConversionData { // T4Laser + from: vec![ + FromConversionData::Simple(798339580), // T9 + FromConversionData::Simple(2088248713), // T10 + FromConversionData::Simple(201410), // T10 retro + FromConversionData::Simple(1201410), // T10 retro ??? + FromConversionData::Simple(8201410), // T10 retro gold + ], + to: Some(ToConversionData::Simple(2088248713)), + }, + 3632954889 => only_to(2088248713), // T4LaserCarbon6 + 3209000021 => reciprocal(3209000021), // T5Laser (mega) + 1199458351 => reciprocal(1199458351), // T5LaserCarbon6 + 3851710054 => only_to(3209000021), // T5LaserGolden + // Tesla + 2017654588 => reciprocal(2017654588), // T2Tesla + 3479123512 => reciprocal(3479123512), // T3Tesla + 2703353899 => reciprocal(2703353899), // T4Tesla + // Modern-only weapons + 3947892032 => fallback_conversion(), // T4Aeroflak + 73607413 => fallback_conversion(), // T5Aeroflak + 48150119 => fallback_conversion(), // T3Seeker + 350778877 => fallback_conversion(), // T4Seeker + 2419806013 => fallback_conversion(), // T5Seeker + 3437168371 => fallback_conversion(), // T5SeekerFirework + 3541341953 => fallback_conversion(), // T4Ion + 1184153980 => fallback_conversion(), // T5Ion + 2295212085 => fallback_conversion(), // T5IonCardLife + 105639928 => fallback_conversion(), // T5IonGolden + 1318129004 => fallback_conversion(), // T4Chaingun + 1795842454 => fallback_conversion(), // T5Chaingun + 1885850433 => fallback_conversion(), // T5Mortar + 194724715 => fallback_conversion(), // T5ChaingunGolden + 3832533619 => fallback_conversion(), // T5SeekerGolden + 2256680330 => fallback_conversion(), // T5MortarGolden + 3087866254 => fallback_conversion(), // MortarEggLauncher + // TODO + // Movement + // Rudders + 2369778644 => reciprocal(2369778644), // T2Rudder to T4 + 3808719325 => variant_dual(975319760, 3808719325), // T3Rudder to T5/T6 + 3919904737 => variant_dual(1410969817, 3919904737), // T4Rudder to T7/T8 + 2404311559 => reciprocal(2404311559), // T4RudderBat + 2608683011 => variant_dual(1589198565, 2608683011), // T5Rudder to T9/T10 + 2928920064 => only_to(2608683011), // T5RudderBat + // Wings/Aerofoils + 3980928804 => reciprocal(3980928804), // T2Wing to T4 + 2205394221 => variant_dual(1515578912, 2205394221), // T3Wing to T5/T6 + 2312317713 => variant_dual(879649833, 2312317713), // T4Wing to T7/T8 + 2865954846 => reciprocal(2865954846), // T4WingBat + 2464192350 => variant_dual(1053747733, 2464192350), // T5Wing to T9/T10 + 2863842421 => only_to(2464192350), // T5WingBat + // Hover + 1851713730 => reciprocal(1851713730), // T0Hover to T2 + 3170960304 => variant_dual(947628072, 3170960304), // T1Hover to T3/T4 + 1639979618 => variant_dual(1333565630, 1639979618), // T2Hover to T5/T6 + 465491285 => variant_dual(3508014365, 465491285), // T3Hover to T7/T8 + 3347041415 => variant_dual(2787065227, 3347041415), // T4Hover to T10 Low/T10 Regular + 3835205776 => reciprocal(3835205776), // T5Hover (mega) + 2217471839 => only_to(3835205776), // T5HoverGolden + // Ski + 347391203 => reciprocal(347391203), // T2Ski (why is this in hovers_common.xml!?) + 3080624795 => reciprocal(3080624795), // T2SteeringSki + // Insect Legs + 2671394050 => reciprocal(2671394050), // T3InsectLegBladed + 1668760037 => variant_dual(1889322041, 1668760037), // T4InsectLeg to T9/T10 (Jumper) + 875393229 => only_to(1668760037), // T4InsectLegSpider + 127661231 => variant_dual(2535778622, 127661231), // T3InsectLeg to T7/T8 (Leaper) + // Mech Legs + 3066502430 => fallback_conversion(), // T0MechLeg + 76192516 => fallback_conversion(), // T1MechLeg + 1789991181 => fallback_conversion(), // T2MechLeg + 1611765553 => reciprocal(706930465), // T3MechLeg to T10 Compact + 2452579137 => reciprocal(706930466), // T4MechLeg to T10 + 4140335767 => reciprocal(706930467), // T5MechLeg to TX-1 + 3660410821 => only_to(706930467), // T5MechLegGolden + // Sprinter Legs + 3345472872 => fallback_conversion(), // T3SprinterLeg + 1169080311 => fallback_conversion(), // T4SprinterLeg + 2474131397 => fallback_conversion(), // T4SprinterLegCarbon6 + // Rotors + 3947193935 => variant_dual(845358402, 3947193935), // T2Rotor to T5/T6 + 3789940851 => variant_dual(1549313867, 3789940851), // T3Rotor to T7/T8 + 1802864184 => variant_dual(1459104119, 1802864184), // T4Rotor to T9/T10 + // Thrusters + 844778245 => variant_dual(2766875446, 844778245), // T1Thruster to T1/T2 + 2498076128 => variant_dual(4276453881, 2498076128), // T2Thruster to T3/T4 + 3764614578 => variant_dual(1572019677, 3764614578), // T3Thruster to T5/T6 + 3938712462 => variant_dual(3285682302, 3938712462), // T4Thruster to T7/T8 + 2969949587 => variant_dual(3033577704, 2969949587), // T5Thruster to T9/T10 + 944347375 => reciprocal(944347375), // T5ThrusterCarbon6 + // Tracks + 4050988656 => fallback_conversion(), // T1TankTrack + 1691117057 => variant_dual(1637622047, 1691117057), // T2TankTrack to T3/T4 + 184017928 => variant_dual(3553732357, 184017928), // T3TankTrack to T5/T6 + 5728820 => variant_dual(3186238732, 5728820), // T4TankTrack to T7/T8 + 431289744 => variant_dual(3075124016, 431289744), // T5TankTrack to T9/T10 + // NOTE: wheels are not the same IDs, presumably because of the wheels rework between classic and modern versions + // Wheels steering + 932552373 => variant_dual(3769194911, 3158825472), // T0SteeringWheel to T5/T6 + 169839823 => variant_dual(1526222854, 3539316064), // T1SteeringWheel to T5/T6 + 2958657062 => variant_dual(3435681649, 1701822652), // T2SteeringWheel to T5/T6 + 1177366035 => variant_dual(1868379119, 244400800), // T3SteeringWheel to T7/T8 + //345048522 => variant_dual(4184683672, 3104518012), // T4SteeringWheel to T9/T10 + 345048522 => CubeConversionData { // T4SteeringWheel + from: vec![ + FromConversionData::Simple(4184683672), // T9 + FromConversionData::Simple(3104518012), // T10 + FromConversionData::Simple(2682515905), // T10 Large + ], + to: Some(ToConversionData::Simple(3104518012)), // T10 + }, + 32187483 => only_to(3104518012), // T4SteeringWheelCardLife + 48208435 => reciprocal(48208435), // T5SteeringWheel (mega) + 3546777424 => only_to(48208435), // T5SteeringWheelGolden + // Wheels non-steering + 1810876016 => variant_dual(2866040116, 299251682), // T0Wheel + 4172855773 => variant_dual(182430853, 2137276546), // T1Wheel + 2924196438 => variant_dual(2632952818, 3372100958), // T2Wheel + 3028949761 => variant_dual(1065013100, 2734731074), // T3Wheel + //1223384335 => variant_dual(, ), // T4Wheel + 1223384335 => CubeConversionData { // T4Wheel + from: vec![ + FromConversionData::Simple(2840250395), // T9 + FromConversionData::Simple(345686686), // T10 + FromConversionData::Simple(3625806184), // T10 Large + ], + to: Some(ToConversionData::Simple(345686686)), // T10 Large + }, + 2105254905 => only_to(345686686), // T4WheelCardLife + 3301162279 => reciprocal(3301162279), // T5Wheel (mega) + 2305706992 => only_to(3301162279), // T5WheelGolden + // Modern-only movement + 3286209719 => fallback_conversion(), // T3Propeller + 2784549582 => fallback_conversion(), // T4Propeller + // TODO + // Jammers + 198393630 => reciprocal(3805965640), // JammerSmall + 3106939802 => reciprocal(1439149132), // JammerMedium + //575087658 => reciprocal(1352133458), // JammerLarge + 575087658 => CubeConversionData { // JammerLarge + from: vec![ + FromConversionData::Simple(1352133458), // T7 + FromConversionData::Simple(3884591702), // T9 has no modern equivalent + ], + to: Some(ToConversionData::Simple(1352133458)), + }, + // Radars + 3782348815 => reciprocal(3123991804), // RadarSmall to T1 + 2115785469 => reciprocal(591201606), // RadarMedium to T5 + 3370519355 => reciprocal(1413101008), // RadarLarge to T9 + // Radar Receivers + 2330463949 => reciprocal(2460830034), // ReceiverSmall to T5 + 2312945847 => reciprocal(2549943884), // ReceiverMedium to T7 + 2742818297 => reciprocal(551604040), // ReceiverLarge to T9 + // Flippers aka alignment rectifiers (why does classic only have 1?) + 2297667466 => reciprocal(2014070946), // FlipperLarge + 2707915966 => only_to(2014070946), // FlipperSmall + 3083647184 => only_to(2014070946), // FlipperMedium + // Seats + 2325944783 => reciprocal(1692614192), // PilotSeatMega to Mega seat + 2843561040 => reciprocal(3186524732), // PilotSeatCray to actual pilot seat + 1427441647 => only_to(3186524732), // PilotSeatGene to actual pilot seat + 2743059293 => only_to(3186524732), // PilotSeatRetro to actual pilot seat + 1061758673 => only_to(3186524732), // PilotSeatBunny + // Modules + 1692614192 => only_to(1692614192), // T5EnergyModule to Mega Seat + 3263542661 => fallback_conversion(), // T5ShieldModule + 3987245394 => fallback_conversion(), // T5BlinkModule + 4230376397 => fallback_conversion(), // T5GhostModule + 658488560 => fallback_conversion(), // T5EmpModule + 344080878 => only_to(1413101008), // T5WindowmakerModule to Radar T9 + // Rods + 1818686721 => fallback_conversion(), // Rod + 1312806203 => fallback_conversion(), // RodShort + 3489647384 => fallback_conversion(), // RodLong + 2454932271 => fallback_conversion(), // RodArc + 3568811832 => fallback_conversion(), // RodArcShort + 394503911 => fallback_conversion(), // RodDiagonal2D + 1316425218 => fallback_conversion(), // RodDiagonal2DShort + 3705632066 => fallback_conversion(), // RodDiagonal3D + 1750720388 => fallback_conversion(), // RodPlus + 651695911 => fallback_conversion(), // RodSkewedPlus + 4265819694 => fallback_conversion(), // RodCross + 3950939011 => fallback_conversion(), // RodSpring (cosmetic rod) + 2828600518 => fallback_conversion(), // RodShortSpring (cosmetic rod) + 1051732041 => fallback_conversion(), // RodLongSpring (cosmetic rod) + 2183972675 => fallback_conversion(), // FairyLight (cosmetic rod) + // Struts + 4262490755 => fallback_conversion(), // Strut + 1648206355 => fallback_conversion(), // StrutShort + 3084940372 => fallback_conversion(), // StrutLong + 743433251 => fallback_conversion(), // StrutArc + 2126360617 => fallback_conversion(), // StrutSlice + 2608938070 => fallback_conversion(), // StrutSliceShort + 3999885169 => fallback_conversion(), // StrutRamp + 14694903 => fallback_conversion(), // StrutDiagonal2D + 3562021587 => fallback_conversion(), // StrutDiagonal2DShort + 2028802540 => fallback_conversion(), // StrutDiagonal3DL + 2002864041 => fallback_conversion(), // StrutDiagonal3DR + 511164535 => fallback_conversion(), // StrutPlus + 1521447199 => fallback_conversion(), // StrutSkewedPlus + 787409845 => fallback_conversion(), // StrutCross + 4206005137 => fallback_conversion(), // StrutArcShort + 3903776284 => fallback_conversion(), // StrutRampShort + 1319987665 => fallback_conversion(), // StrutDiagonal3DLShort + 3923036903 => fallback_conversion(), // StrutDiagonal3DRShort + // misc masks and mask-likes + 1704793982 => fallback_conversion(), // VigilantEyeL + 3558508089 => fallback_conversion(), // VigilantEyeR + 767825476 => fallback_conversion(), // CatEyeL + 683945560 => fallback_conversion(), // CatEyeR + 1609561971 => fallback_conversion(), // CyborgEye + 108680691 => fallback_conversion(), // SpikePin + 955529870 => fallback_conversion(), // SpikeNeedle + 1991524976 => fallback_conversion(), // SpikeClaw + 38268230 => fallback_conversion(), // EagleNeckL + 2017227318 => fallback_conversion(), // EagleNeckR + 2829693731 => fallback_conversion(), // EagleFeathersL + 3536989267 => fallback_conversion(), // EagleFeathersR + 1797279669 => fallback_conversion(), // EagleFaceL + 291229893 => fallback_conversion(), // EagleFaceR + 699401910 => fallback_conversion(), // AlienwareMaskEarL + 3136672710 => fallback_conversion(), // AlienwareMaskEarR + 3969591264 => fallback_conversion(), // AlienwareMaskHeadL + 2145317520 => fallback_conversion(), // AlienwareMaskHeadR + 1314825875 => fallback_conversion(), // AlienwareMaskJawL + 3709563875 => fallback_conversion(), // AlienwareMaskJawR + 1390502124 => fallback_conversion(), // NinjaMaskBandanaL + 3248797084 => fallback_conversion(), // NinjaMaskBandanaR + 989441293 => fallback_conversion(), // NinjaMaskHeadL + 2847701117 => fallback_conversion(), // NinjaMaskHeadR + 2503490917 => fallback_conversion(), // NinjaMaskL + 108914709 => fallback_conversion(), // NinjaMaskR + 799302218 => fallback_conversion(), // TrexHeadL + 981970925 => fallback_conversion(), // TrexHeadR + 4069461414 => fallback_conversion(), // TrexNoseL + 669458802 => fallback_conversion(), // TrexNoseR + 3618891370 => fallback_conversion(), // TrexJawL + 3670645745 => fallback_conversion(), // TrexJawR + // silly blocks + 149580437 => fallback_conversion(), // ExhaustBlower + 3489925411 => fallback_conversion(), // ExhaustStackL + 1679554409 => fallback_conversion(), // ExhaustStackR + 138993629 => fallback_conversion(), // PresentSmall + 568116457 => fallback_conversion(), // PresentLarge + 2484686408 => fallback_conversion(), // BubbleBlower + 346272734 => fallback_conversion(), // Balloon + // dev blocks + 3950293873 => fallback_conversion(), // ProtoniumCrystal + 2771514423 => fallback_conversion(), // Equalizer + 606866102 => fallback_conversion(), // ProtoniumClasp + 3951497291 => fallback_conversion(), // FusionTower + unhandled_id => { + log::warn!("Unimplemented modern ID {}", unhandled_id); + fallback_conversion() + } + }; + cube.conversion = Some(conversion); +} + +fn reciprocal(classic_id: u32) -> CubeConversionData { + CubeConversionData { + from: vec![ + FromConversionData::Simple(classic_id), + ], + to: Some(ToConversionData::Simple(classic_id)), + } +} + +fn only_to(classic_id: u32) -> CubeConversionData { + CubeConversionData { + from: Vec::default(), + to: Some(ToConversionData::Simple(classic_id)), + } +} + +fn variant_dual(lower_tier: u32, higher_tier: u32) -> CubeConversionData { + CubeConversionData { + from: vec![ + FromConversionData::Simple(lower_tier), + FromConversionData::Simple(higher_tier), + ], + to: Some(ToConversionData::Simple(higher_tier)), + } +} + +fn fallback_conversion() -> CubeConversionData { + CubeConversionData { + from: Vec::default(), + to: Some(ToConversionData::Simple(2000000000)), // camera cube + } +} + +// these are positions in the array of colours, not the UI position... thanks FJ +const NO_OFFSET: Option<(i16, i16, i16)> = Some((0, 0, 0)); +const ORANGE: u8 = 3; +//const ORANGE: u8 = 11; +const YELLOW: u8 = 6; +const GOLD: u8 = 6; +//const LIGHT_BLUE: u8 = 3; +const BLUE: u8 = 9; +//const DARK_BLUE: u8 = 24; +const WHITE: u8 = 0; +const BLACK: u8 = 4; +const RED: u8 = 5; +const STYPE: u8 = 6; // yellow-ish? +const GREEN: u8 = 7; +const PURPLE: u8 = 10; diff --git a/rc_core/src/persist/cube_data.rs b/rc_core/src/persist/cube_data.rs index 39cd741..6d1b0b2 100644 --- a/rc_core/src/persist/cube_data.rs +++ b/rc_core/src/persist/cube_data.rs @@ -4,7 +4,7 @@ use serde::{Serialize, Deserialize}; use polariton::operation::Typed; -use super::{WeaponData, WeaponUpgradeInfo, TechTreeData, MovementData}; +use super::{WeaponData, WeaponUpgradeInfo, TechTreeData, MovementData, CubeConversionData}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Cube { @@ -14,6 +14,7 @@ pub struct Cube { pub weapon_upgrade: Option, pub movement: Option, pub tree: Option, + pub conversion: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/rc_core/src/persist/mod.rs b/rc_core/src/persist/mod.rs index 04cd70b..7150cd9 100644 --- a/rc_core/src/persist/mod.rs +++ b/rc_core/src/persist/mod.rs @@ -50,6 +50,9 @@ pub use team_chooser::TeamChooser; mod vehicle_validator; pub use vehicle_validator::VehicleValidator; +pub(crate) mod conversion; +pub use conversion::{CubeConversionData, FromConversionData, ToConversionData}; + const VALID_ROBOT: &[u8] = &[64, 0, 0, diff --git a/rc_core/src/persist/user/account_json.rs b/rc_core/src/persist/user/account_json.rs index 6584556..5841f5e 100644 --- a/rc_core/src/persist/user/account_json.rs +++ b/rc_core/src/persist/user/account_json.rs @@ -396,7 +396,7 @@ impl UserData { Ok(()) } - async fn all_vehicles(&self) -> Result, oj_rc_database::sea_orm::DbErr> { + pub(super) async fn all_vehicles(&self) -> Result, oj_rc_database::sea_orm::DbErr> { self.db.garages_by_user_id(self.account.id).await } @@ -1123,7 +1123,7 @@ impl super::User for UserData { let total_cost: u32 = self.garage_upgrades.increments.iter() .map(|x| if x.cpu > minimum_upgrade_to_cpu { 0 } else { x.cost }) .sum(); - log::info!("Bay CPU upgrade costs {}", total_cost); + log::debug!("Bay CPU upgrade costs {}", total_cost); self.currency_sub_checked(super::CurrencyType::Free, total_cost as u64).await.map_err(|e| { log::error!("Failed to debit user for cpu upgrade during fresh clone to slot {} for user_id {}: {}", slot, self.account.id, e); DATABASE_ERR diff --git a/rc_core/src/persist/user/mod.rs b/rc_core/src/persist/user/mod.rs index e16c21b..9a5a43c 100644 --- a/rc_core/src/persist/user/mod.rs +++ b/rc_core/src/persist/user/mod.rs @@ -11,7 +11,7 @@ mod inventory; pub use inventory::{UnlockedParts, UnlockOverride}; mod traits; -pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, UserLoginInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser}; +pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, UserLoginInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser, GarageWebInfo}; pub mod intercom; pub use intercom::generate_token as generate_intercom_token; diff --git a/rc_core/src/persist/user/traits.rs b/rc_core/src/persist/user/traits.rs index 977f6a4..3d033a7 100644 --- a/rc_core/src/persist/user/traits.rs +++ b/rc_core/src/persist/user/traits.rs @@ -676,7 +676,19 @@ pub trait FactoryUser { #[async_trait::async_trait] pub trait WebUser: CommonUser { + async fn garages(&self) -> Result, Box>; + async fn garage_by_id(&self, id: i32) -> Result, Box>; + async fn save_garage(&self, vehicle: crate::persist::user::VehicleData, garage_id: Option, cpu_counter: &crate::cubes::CpuListParser, weapon_orderer: &crate::cubes::WeaponListParser) -> Result<(), Box>; + async fn garage_id_selected(&self) -> Result, Box>; +} +pub struct GarageWebInfo { + pub id: i32, + pub slot: i32, + pub total_robot_cpu: i32, + pub bay_cpu: i32, + pub name: String, + pub creation_time: i64, } #[async_trait::async_trait] diff --git a/rc_core/src/persist/user/web.rs b/rc_core/src/persist/user/web.rs index aa2d0e9..bdcd593 100644 --- a/rc_core/src/persist/user/web.rs +++ b/rc_core/src/persist/user/web.rs @@ -1,4 +1,109 @@ #[async_trait::async_trait] impl super::WebUser for super::account_json::UserData { - // TODO + async fn garages(&self) -> Result, Box> { + let vehicles = self.all_vehicles().await?; + Ok(vehicles.into_iter() + .map(|v| super::GarageWebInfo { + id: v.id, + slot: v.slot, + total_robot_cpu: v.total_robot_cpu, + bay_cpu: v.bay_cpu, + name: v.name.clone(), + creation_time: v.creation_time, + }) + .collect() + ) + } + + async fn garage_by_id(&self, id: i32) -> Result, Box> { + let garage = self.db.garage_by_id(id).await?; + Ok(garage.and_then(|g| + if g.user_id == self.account.id { + Some(super::VehicleData { + name: Some(g.name), + slot: g.slot, + robot_data: g.robot_data, + colour_data: g.colour_data, + weapon_order: Vec::default(), + crf_id: g.crf_id, + was_rated: Some(g.was_rated), + }) + } else { + None + } + )) + } + + async fn save_garage( + &self, + vehicle: super::VehicleData, + garage_id: Option, + cpu_counter: &crate::cubes::CpuListParser, + weapon_orderer: &crate::cubes::WeaponListParser, + ) -> Result<(), Box> { + let cpu_counts = cpu_counter.calculate_cpu(&mut std::io::Cursor::new(&vehicle.robot_data)); + let weapon_order = weapon_orderer.guess_weapons(&mut std::io::Cursor::new(&vehicle.robot_data)); + let mut entity = oj_rc_database::schema::garage::ActiveModel { + id: garage_id.map(oj_rc_database::sea_orm::ActiveValue::Set).unwrap_or(oj_rc_database::sea_orm::ActiveValue::NotSet), + user_id: oj_rc_database::sea_orm::ActiveValue::Set(self.account.id), + weapon_order: oj_rc_database::sea_orm::ActiveValue::Set(oj_rc_database::schema::dump_csv(&weapon_order)), + robot_data: oj_rc_database::sea_orm::ActiveValue::Set(vehicle.robot_data), + colour_data: oj_rc_database::sea_orm::ActiveValue::Set(vehicle.colour_data), + crf_id: oj_rc_database::sea_orm::ActiveValue::NotSet, + name: if let Some(new_name) = vehicle.name { + oj_rc_database::sea_orm::ActiveValue::Set(format!("{} (imported)", new_name)) + } else { + oj_rc_database::sea_orm::ActiveValue::Set(format!( + "Import {}", + garage_id.map(|x| x.to_string()).unwrap_or_else(|| "(new)".to_owned()) + )) + }, + total_robot_cpu: oj_rc_database::sea_orm::ActiveValue::Set(cpu_counts.total as _), + total_cosmetic_cpu: oj_rc_database::sea_orm::ActiveValue::Set(cpu_counts.cosmetic as _), + was_rated: oj_rc_database::sea_orm::ActiveValue::Set(false), + movement_categories: oj_rc_database::sea_orm::ActiveValue::Set("".to_owned()), // FIXME + total_robot_ranking: oj_rc_database::sea_orm::ActiveValue::Set(0), // FIXME + bay_cpu: oj_rc_database::sea_orm::ActiveValue::Set(10_000), + //tutorial_robot: oj_rc_database::sea_orm::ActiveValue::Set(false), + mastery_level: oj_rc_database::sea_orm::ActiveValue::Set(0), + ..super::initial_data::default_reset_slot() + }; + // TODO charge currency for new or higher CPU bay + /*let total_cost: u32 = self.garage_upgrades.increments.iter() + .map(|x| if x.cpu > minimum_upgrade_to_cpu { 0 } else { x.cost }) + .sum(); + log::debug!("Bay CPU upgrade costs {}", total_cost); + self.currency_sub_checked(super::CurrencyType::Free, total_cost as u64).await.map_err(|e| { + log::error!("Failed to debit user for cpu upgrade during create of slot {} for user_id {}: {}", slot, self.account.id, e); + DATABASE_ERR + })?;*/ + if let Some(garage_id) = garage_id { + // technically we don't need an account id to get the slot + // but this way ensures the user also owns the slot we retrieve + // (and no slot will be retrieved if the user doesn't own the slot, even if the garage id exists) + if let Some(slot) = self.db.slot_of_garage_by_id_and_user_id(garage_id, self.account.id).await? { + entity.slot = oj_rc_database::sea_orm::ActiveValue::Set(slot); + self.db.update_garage_by_user_id_and_slot(entity, self.account.id, slot).await?; + Ok(()) + } else { + Err(format!("Invalid vehicle ID {} for user {}", garage_id, self.account.id).into()) + } + } else { + let now = chrono::Utc::now().timestamp(); + let new_slot = self.db.garage_max_slot_by_user_id(self.account.id).await? + 1; + let uuid = super::uuid_sanitize(now); + entity.slot = oj_rc_database::sea_orm::ActiveValue::Set(new_slot); + entity.creation_time = oj_rc_database::sea_orm::ActiveValue::Set(now); + entity.uuid = oj_rc_database::sea_orm::ActiveValue::Set(uuid); + entity.thumbnail_version = oj_rc_database::sea_orm::ActiveValue::Set(0); + entity.selected = oj_rc_database::sea_orm::ActiveValue::Set(false); + self.db.insert_garage(entity).await?; + Ok(()) + } + } + + async fn garage_id_selected(&self) -> Result, Box> { + Ok(self.db.garage_selected(self.account.id).await? + .map(|x| x.id)) + } } diff --git a/rc_database/src/wrapper.rs b/rc_database/src/wrapper.rs index a80dce5..0ac472c 100644 --- a/rc_database/src/wrapper.rs +++ b/rc_database/src/wrapper.rs @@ -129,7 +129,7 @@ impl Database { } pub async fn insert_user_aux(&self, entities: Vec) -> Result<(), sea_orm::DbErr> { - crate::schema::user_aux::Entity::insert_many(entities.into_iter()).exec(self.orm.as_ref()).await?; + crate::schema::user_aux::Entity::insert_many(entities).exec(self.orm.as_ref()).await?; Ok(()) } @@ -217,6 +217,13 @@ impl Database { .await } + pub async fn garage_count_by_user_id(&self, user_id: i32) -> Result { + crate::schema::garage::Entity::find() + .filter(crate::schema::garage::Column::UserId.eq(user_id)) + .count(self.orm.as_ref()) + .await + } + pub async fn garage_max_slot_by_user_id(&self, user_id: i32) -> Result { let result = crate::schema::garage::Entity::find() .select_only() @@ -265,8 +272,20 @@ impl Database { .await } + pub async fn slot_of_garage_by_id_and_user_id(&self, garage_id: i32, user_id: i32) -> Result, sea_orm::DbErr> { + let opt = crate::schema::garage::Entity::find() + .select_only() + .column_as(crate::schema::garage::Column::Slot, "column") + .filter(crate::schema::garage::Column::Id.eq(garage_id)) + .filter(crate::schema::garage::Column::UserId.eq(user_id)) + .into_model::>() + .one(self.orm.as_ref()) + .await?; + Ok(opt.map(|x| *x)) + } + pub async fn insert_garages(&self, entities: Vec) -> Result<(), sea_orm::DbErr> { - crate::schema::garage::Entity::insert_many(entities.into_iter()).exec(self.orm.as_ref()).await?; + crate::schema::garage::Entity::insert_many(entities).exec(self.orm.as_ref()).await?; Ok(()) } @@ -515,7 +534,7 @@ impl Database { } pub async fn insert_players(&self, entities: Vec) -> Result<(), sea_orm::DbErr> { - crate::schema::multiplayer_game_player::Entity::insert_many(entities.into_iter()).exec(self.orm.as_ref()).await?; + crate::schema::multiplayer_game_player::Entity::insert_many(entities).exec(self.orm.as_ref()).await?; Ok(()) } diff --git a/rc_lobby_room/src/lobby.rs b/rc_lobby_room/src/lobby.rs index 2be7bdc..0504909 100644 --- a/rc_lobby_room/src/lobby.rs +++ b/rc_lobby_room/src/lobby.rs @@ -658,7 +658,7 @@ impl QueueHandler { count += q_entry.users.len(); if let Some(values) = new_queue_map.get_mut(&key) { values.users.append(&mut q_entry.users); - values.platoons.extend(q_entry.platoons.into_iter()); + values.platoons.extend(q_entry.platoons); } else { new_queue_map.insert(key.clone(), q_entry); } diff --git a/rc_plugins/src/lib.rs b/rc_plugins/src/lib.rs index ffcaa56..b4c658e 100644 --- a/rc_plugins/src/lib.rs +++ b/rc_plugins/src/lib.rs @@ -1,6 +1,7 @@ pub mod chat; pub mod vehicle_validation; pub mod team_selection; +pub mod vehicle_import; pub trait Plugin: Send + Sync { fn self_check(&self) -> bool { diff --git a/rc_plugins/src/vehicle_import/mod.rs b/rc_plugins/src/vehicle_import/mod.rs new file mode 100644 index 0000000..5fcc142 --- /dev/null +++ b/rc_plugins/src/vehicle_import/mod.rs @@ -0,0 +1,2 @@ +mod plugin; +pub use plugin::{VehicleImportPlugin, VehicleImportErrorCode, VehicleImportData}; diff --git a/rc_plugins/src/vehicle_import/plugin.rs b/rc_plugins/src/vehicle_import/plugin.rs new file mode 100644 index 0000000..74ce4c9 --- /dev/null +++ b/rc_plugins/src/vehicle_import/plugin.rs @@ -0,0 +1,35 @@ +#[repr(u8)] +#[derive(Debug)] +pub enum VehicleImportErrorCode { + Invalid, + Unsupported, + UnknownCube, +} + +impl core::fmt::Display for VehicleImportErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Invalid => write!(f, "Invalid/corrupt data"), + Self::Unsupported => write!(f, "Unsupported/unrecognized data"), + Self::UnknownCube => write!(f, "Unknown cube in data"), + } + } +} + +impl core::error::Error for VehicleImportErrorCode {} + +#[derive(Debug)] +pub struct VehicleImportData { + pub cube_data: Vec, + pub colour_data: Vec, + pub vehicle_name: Option, + pub vehicle_author: Option, +} + +pub trait VehicleImportPlugin: crate::Plugin { + //fn supports(&self) -> Vec; + /// Preferred file extension for the raw data + fn file_ext(&self) -> &'static str; + fn import(&self, upload: &[u8]) -> Result; + fn export(&self, data: &VehicleImportData) -> Result, VehicleImportErrorCode>; +} diff --git a/rc_society/Cargo.toml b/rc_society/Cargo.toml index 8df84f4..a346b10 100644 --- a/rc_society/Cargo.toml +++ b/rc_society/Cargo.toml @@ -11,6 +11,7 @@ readme.workspace = true actix-web = { workspace = true, features = [ "secure-cookies" ] } actix-session = { version = "0.11", features = [ "cookie-session" ], default-features = false } actix-identity = "0.9" +actix-multipart = "0.7" cookie = "0.18" actix-files.workspace = true #actix-ws = "0.3" @@ -21,8 +22,11 @@ tokio = { version = "1.43", features = [ "rt-multi-thread" ] } clap.workspace = true handlebars = { version = "6", features = ["dir_source"] } oj_rc_core = { version = "*", path = "../rc_core" } +oj_rc_plugins = { version = "*", path = "../rc_plugins" } +oj_convert.workspace = true libfj.workspace = true git-version.workspace = true serde.workspace = true serde_json.workspace = true +base64.workspace = true chrono.workspace = true diff --git a/rc_society/src/api/garage/mod.rs b/rc_society/src/api/garage/mod.rs new file mode 100644 index 0000000..25a0616 --- /dev/null +++ b/rc_society/src/api/garage/mod.rs @@ -0,0 +1 @@ +pub mod plugins; diff --git a/rc_society/src/api/garage/plugins/bobocraft.rs b/rc_society/src/api/garage/plugins/bobocraft.rs new file mode 100644 index 0000000..5b43732 --- /dev/null +++ b/rc_society/src/api/garage/plugins/bobocraft.rs @@ -0,0 +1,124 @@ +use oj_convert::{Bobocraft, BobocraftBlock}; + +pub struct RexLike { + convert: std::sync::Arc, +} + +impl RexLike { + pub fn new(convert: std::sync::Arc) -> Self { + Self { + convert, + } + } +} + +impl oj_rc_plugins::vehicle_import::VehicleImportPlugin for RexLike { + fn file_ext(&self) -> &'static str { + "bobo" + } + + fn import(&self, upload: &[u8]) -> Result { + let data = Bobocraft::parse(upload).map_err(|e| { + log::error!("Failed to parse bobocraft import: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let cubes = data.cubes.iter() + .map(|b| oj_rc_core::cubes::Cube { + id: b.cube_id, + x: b.x, + y: b.y, + z: b.z, + orientation: b.orientation, + }).collect(); + let cube_data = oj_rc_core::cubes::Cube::dump_list(cubes) + .map_err(|e| { + log::error!("Failed to write cube data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let colours = data.cubes.iter() + .map(|b| oj_rc_core::cubes::Colour { + colour: b.color.unwrap_or(0), + x: b.x, + y: b.y, + z: b.z, + }).collect(); + let colour_data = oj_rc_core::cubes::Colour::dump_list(colours) + .map_err(|e| { + log::error!("Failed to write colour data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let upgraded = self.convert.upgrade_to_modern( + &mut std::io::Cursor::new(&cube_data), + &mut std::io::Cursor::new(&colour_data), + ).map_err(|e| { + log::warn!("Failed to upgrade bobo import data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported + })?; + Ok(oj_rc_plugins::vehicle_import::VehicleImportData { + cube_data: upgraded.cube_data, + colour_data: upgraded.colour_data, + vehicle_name: Some(data.item_name), + vehicle_author: Some(data.added_by_display_name), + }) + } + + fn export(&self, data: &oj_rc_plugins::vehicle_import::VehicleImportData) -> Result, oj_rc_plugins::vehicle_import::VehicleImportErrorCode> { + let cubes = oj_rc_core::cubes::Cube::parse_list(&mut std::io::Cursor::new(&data.cube_data)) + .map_err(|e| { + log::error!("Failed to read cube data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let colours = oj_rc_core::cubes::Colour::parse_list(&mut std::io::Cursor::new(&data.colour_data)) + .map_err(|e| { + log::error!("Failed to read colour data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + if colours.len() != cubes.len() { + log::error!("Failed to parse the same amount of cubes and colours (aborting)"); + return Err(oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid); + } + let mut placements = Vec::with_capacity(colours.len()); + for i in 0..cubes.len() { + let colour = &colours[i]; + let cube = &cubes[i]; + // assumption: cube and colours are in the same order + let placement = BobocraftBlock { + cube_id: cube.id, + x: cube.x, + y: cube.y, + z: cube.z, + orientation: cube.orientation, + color: if (cube.x, cube.y, cube.z) == (colour.x, colour.y, colour.z) { Some(colour.colour) } else { None }, + }; + placements.push(placement); + } + let bobo = Bobocraft { + item_id: 0, + item_name: data.vehicle_name.clone().unwrap_or_else(|| "Exported vehicle".to_owned()), + item_description: format!("Exported by OpenJam {} {}", env!("CARGO_CRATE_NAME"), env!("CARGO_PKG_VERSION")), + thumbnail: String::default(), + added_by: data.vehicle_author.clone().unwrap_or_default(), + added_by_display_name: data.vehicle_author.clone().unwrap_or_default(), + added_date: chrono::Utc::now().naive_utc(), + expiry_date: chrono::Utc::now().naive_utc(), + cpu: 0, // TODO? + total_robot_ranking: None, // TODO? + buyable: true, + buy_count: 0, + unknown_count: 0, + combat_rating: 5.0, + cosmetic_rating: 5.0, + featured: false, + banner_message: None, + offset: (0, 0, 0), + cubes: placements, + }; + bobo.dump() + .map_err(|e| { + log::error!("Failed to write bobocraft format: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + }) + } +} + +impl oj_rc_plugins::Plugin for RexLike {} diff --git a/rc_society/src/api/garage/plugins/classic.rs b/rc_society/src/api/garage/plugins/classic.rs new file mode 100644 index 0000000..ff3dc60 --- /dev/null +++ b/rc_society/src/api/garage/plugins/classic.rs @@ -0,0 +1,103 @@ +use oj_convert::{Classic, ClassicBlock}; + +pub struct FifteenLike { + convert: std::sync::Arc, + base_image: std::sync::Arc>, + cpu_counter: std::sync::Arc, +} + +impl FifteenLike { + pub fn new( + convert: std::sync::Arc, + base_image: Vec, + cpu_counter: std::sync::Arc, + ) -> Self { + Self { + convert, + base_image: std::sync::Arc::new(base_image), + cpu_counter, + } + } +} + +impl oj_rc_plugins::vehicle_import::VehicleImportPlugin for FifteenLike { + fn file_ext(&self) -> &'static str { + "png" + } + + fn import(&self, upload: &[u8]) -> Result { + let data = Classic::parse(upload).map_err(|e| { + log::error!("Failed to parse RC15 import: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let cubes = data.cubes.iter() + .map(|b| oj_rc_core::cubes::Cube { + id: b.cube_id, + x: b.x, + y: b.y, + z: b.z, + orientation: b.orientation, + }).collect(); + let cube_data = oj_rc_core::cubes::Cube::dump_list(cubes) + .map_err(|e| { + log::error!("Failed to write cube data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let converted = self.convert.convert_to_modern( + &mut std::io::Cursor::new(&cube_data), + ).map_err(|e| { + log::warn!("Failed to modernize RC15 import data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported + })?; + Ok(oj_rc_plugins::vehicle_import::VehicleImportData { + cube_data: converted.cube_data, + colour_data: converted.colour_data, + vehicle_name: Some(data.item_name), + vehicle_author: data.item_author, + }) + } + + fn export(&self, data: &oj_rc_plugins::vehicle_import::VehicleImportData) -> Result, oj_rc_plugins::vehicle_import::VehicleImportErrorCode> { + let cpu_count = self.cpu_counter.calculate_cpu(&mut std::io::Cursor::new(&data.cube_data)).total; + let classified = self.convert.convert_to_classic( + &mut std::io::Cursor::new(&data.cube_data), + &mut std::io::Cursor::new(&data.colour_data), + ).map_err(|e| { + log::warn!("Failed to classic-ify RC15 export data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported + })?; + let cubes = oj_rc_core::cubes::Cube::parse_list(&mut std::io::Cursor::new(&classified)) + .map_err(|e| { + log::error!("Failed to read cube data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let mut placements = Vec::with_capacity(cubes.len()); + for cube in cubes.iter() { + let placement = ClassicBlock { + cube_id: cube.id, + x: cube.x, + y: cube.y, + z: cube.z, + orientation: cube.orientation, + }; + placements.push(placement); + } + let classic = Classic { + item_author: data.vehicle_author.clone(), + game_version: Some(1), // once told me + export_time: Some(chrono::Utc::now().to_rfc3339()), + item_name: data.vehicle_name.clone().unwrap_or_else(|| "Exported vehicle".to_owned()), + item_description: Some(format!("Exported by OpenJam {} {}", env!("CARGO_CRATE_NAME"), env!("CARGO_PKG_VERSION"))), + item_tier: 1, // TODO + item_cpu: cpu_count, + cubes: placements, + }; + classic.dump(&self.base_image) + .map_err(|e| { + log::error!("Failed to dump the RC15 PNG data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported + }) + } +} + +impl oj_rc_plugins::Plugin for FifteenLike {} diff --git a/rc_society/src/api/garage/plugins/factory.rs b/rc_society/src/api/garage/plugins/factory.rs new file mode 100644 index 0000000..0c3d9f2 --- /dev/null +++ b/rc_society/src/api/garage/plugins/factory.rs @@ -0,0 +1,102 @@ +use std::io::Cursor; + +use libfj::robocraft::{FactoryRobotGetInfo, FactoryInfo}; + +pub struct FactoryLike { + convert: std::sync::Arc, +} + +impl FactoryLike { + pub fn new(convert: std::sync::Arc,) -> Self { + Self { + convert, + } + } + + fn try_guess_json_format(upload: &[u8]) -> Option { + let reader = Cursor::new(upload); + match serde_json::from_reader::<_, FactoryRobotGetInfo>(reader) { + Ok(data) => Some(data), + Err(e) => { + let reader = Cursor::new(upload); + match serde_json::from_reader::<_, FactoryInfo>(reader) { + Ok(data) => Some(data.response), + Err(e2) => { + log::error!("Failed to parse json for factory-like vehicle import: (1){} (2){}", e, e2); + None + } + } + } + } + } +} + +impl oj_rc_plugins::vehicle_import::VehicleImportPlugin for FactoryLike { + fn file_ext(&self) -> &'static str { + "rcbup" + } + + fn import(&self, upload: &[u8]) -> Result { + if let Some(upload_data) = Self::try_guess_json_format(upload) { + use base64::Engine; + let cube_data = base64::engine::general_purpose::STANDARD.decode(&upload_data.cube_data).map_err(|e| { + log::error!("Bad base64 encoding of cube data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let colour_data = base64::engine::general_purpose::STANDARD.decode(&upload_data.cube_data).map_err(|e| { + log::error!("Bad base64 encoding of colour data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + let upgraded = self.convert.upgrade_to_modern( + &mut std::io::Cursor::new(&cube_data), + &mut std::io::Cursor::new(&colour_data), + ).map_err(|e| { + log::warn!("Failed to upgrade rcbup import data: {}", e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported + })?; + Ok(oj_rc_plugins::vehicle_import::VehicleImportData { + cube_data: upgraded.cube_data, + colour_data: upgraded.colour_data, + vehicle_name: Some(upload_data.item_name), + vehicle_author: Some(upload_data.added_by_display_name), + }) + } else { + Err(oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid) + } + } + + fn export(&self, data: &oj_rc_plugins::vehicle_import::VehicleImportData) -> Result, oj_rc_plugins::vehicle_import::VehicleImportErrorCode> { + use base64::Engine; + let json_data = FactoryRobotGetInfo { + item_id: 0, + item_name: data.vehicle_name.clone().unwrap_or_default(), + item_description: String::default(), + thumbnail: String::default(), + added_by: data.vehicle_author.clone().unwrap_or_default(), + added_by_display_name: data.vehicle_author.clone().unwrap_or_default(), + added_date: "1970-01-01T00:00:01".to_owned(), + expiry_date: "2100-01-01T00:00:01".to_owned(), + rent_count: 0, + buy_count: 0, + cpu: 0, // TODO + total_robot_ranking: 0, // TODO + buyable: true, + removed_date: None, + ban_date: None, + featured: false, + banner_message: None, + combat_rating: 5.0, + cosmetic_rating: 5.0, + cube_data: base64::engine::general_purpose::STANDARD.encode(&data.cube_data), + colour_data: base64::engine::general_purpose::STANDARD.encode(&data.colour_data), + cube_amounts: String::default(), + }; + let output = serde_json::to_vec_pretty(&json_data).map_err(|e| { + log::error!("Failed to convert vehicle `{:?}` to JSON: {}", json_data.item_name, e); + oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid + })?; + Ok(output) + } +} + +impl oj_rc_plugins::Plugin for FactoryLike {} diff --git a/rc_society/src/api/garage/plugins/mod.rs b/rc_society/src/api/garage/plugins/mod.rs new file mode 100644 index 0000000..7f21f61 --- /dev/null +++ b/rc_society/src/api/garage/plugins/mod.rs @@ -0,0 +1,61 @@ +mod factory; +pub use factory::FactoryLike; + +mod bobocraft; +pub use bobocraft::RexLike; + +mod classic; +pub use classic::FifteenLike; + +#[derive(Default)] +pub struct ImportPlugins { + map: std::collections::HashMap>, +} + +impl ImportPlugins { + pub fn standard( + assets_path: impl AsRef, + parsers: &oj_rc_core::cubes::CubeParsers, + ) -> Self { + let mut map = std::collections::HashMap::with_capacity(3); + map.insert("rcbup".to_owned(), Box::new(FactoryLike::new(parsers.converter())) as _); + map.insert("bobo".to_owned(), Box::new(RexLike::new(parsers.converter())) as _); + let image_path = assets_path.as_ref().join("rc_export.png"); + log::debug!("Loading PNG for RC15 export steganography from {}", image_path.display()); + let image_data = std::fs::read(image_path).expect("Failed to read rc_export.png file required for exporting RC15 vehicles"); + map.insert("rc15".to_owned(), Box::new(FifteenLike::new(parsers.converter(), image_data, parsers.cpu_counter())) as _); + Self { + map, + } + } + + pub fn plugin_names(&self) -> impl std::iter::Iterator { + let mut to_sort = self.map.keys().collect::>(); + to_sort.sort_by_key(|x| x.to_lowercase()); + to_sort.into_iter() + } + + pub fn file_ext(&self, name: &str) -> Result<&'static str, oj_rc_plugins::vehicle_import::VehicleImportErrorCode> { + if let Some(plugin) = self.map.get(name) { + Ok(plugin.file_ext()) + } else { + Err(oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported) + } + } + + pub fn import_by_name(&self, name: &str, upload: &[u8]) -> Result { + if let Some(plugin) = self.map.get(name) { + plugin.import(upload) + } else { + Err(oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported) + } + } + + pub fn export_by_name(&self, name: &str, data: &oj_rc_plugins::vehicle_import::VehicleImportData) -> Result, oj_rc_plugins::vehicle_import::VehicleImportErrorCode> { + if let Some(plugin) = self.map.get(name) { + plugin.export(data) + } else { + Err(oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Unsupported) + } + } +} diff --git a/rc_society/src/api/mod.rs b/rc_society/src/api/mod.rs index e69de29..46b5f68 100644 --- a/rc_society/src/api/mod.rs +++ b/rc_society/src/api/mod.rs @@ -0,0 +1 @@ +pub mod garage; diff --git a/rc_society/src/main.rs b/rc_society/src/main.rs index 1473258..230e0f4 100644 --- a/rc_society/src/main.rs +++ b/rc_society/src/main.rs @@ -27,6 +27,14 @@ async fn main() -> std::io::Result<()> { let server_settings = actix_web::web::Data::new(>::server_config(&config)); + let parsers = oj_rc_core::cubes::CubeParsers::new(&config); + + let vehicle_importers = crate::api::garage::plugins::ImportPlugins::standard(&cli_args.assets_robocraft, &parsers); + log::info!("Loaded {} vehicle import/export plugins", vehicle_importers.plugin_names().count()); + let importers_ref = actix_web::web::Data::new(vehicle_importers); + + let parsers_ref = actix_web::web::Data::new(parsers); + let users = oj_rc_core::UserImpl::load(&cli_args.data_robocraft, &config).await.expect("Bad user data"); let auth_ref = actix_web::web::Data::new(Box::new(users)); @@ -65,12 +73,22 @@ async fn main() -> std::io::Result<()> { .app_data(handlebars_ref.clone()) .app_data(server_settings.clone()) .app_data(auth_ref.clone()) + .app_data(importers_ref.clone()) + .app_data(parsers_ref.clone()) .service(version_info) .service(web::login::form_submit) .service(web::login::form_load) .service(web::favicon::favicon_standard) .service(web::dashboard::get) + .service(web::dashboard::post) // for login redirect .service(web::index::get) + .service(web::garage::list::get) + .service(web::garage::info::get) + .service(web::garage::export::get) + .service(web::garage::import::get_existing) + .service(web::garage::import::get_new) + .service(web::garage::import::post) + .service(web::garage::selected::get) }) .bind((cli_args.ip, cli_args.port))? .run() diff --git a/rc_society/src/web/dashboard.rs b/rc_society/src/web/dashboard.rs index ea760e7..eced6c8 100644 --- a/rc_society/src/web/dashboard.rs +++ b/rc_society/src/web/dashboard.rs @@ -1,4 +1,4 @@ -use actix_web::{get, web::Data, Responder, HttpRequest}; +use actix_web::{get, post, web::Data, Responder, HttpRequest}; use actix_identity::Identity; use serde::{Serialize, Deserialize}; @@ -29,12 +29,12 @@ struct PermissionData { banned: bool, } -#[get("/dashboard")] -pub async fn get(handlebars_ref: Data>, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { +pub async fn dashboard_impl(handlebars_ref: Data>, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { match super::try_auth_user(user_opt, auth.as_ref(), &req).await? { super::LoginReturn::AuthFail(resp) => Ok(resp), super::LoginReturn::Success(user) => { // TODO + log::debug!("Rendering user's dashboard"); let creation_time = user.creation(); let creation_time_chrono = chrono::DateTime::::from_timestamp_secs(creation_time).unwrap_or_default(); Ok(super::render_ok( @@ -63,3 +63,13 @@ pub async fn get(handlebars_ref: Data>, auth: Data>, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + dashboard_impl(handlebars_ref, auth, user_opt, req).await +} + +#[post("/dashboard")] +pub async fn post(handlebars_ref: Data>, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + dashboard_impl(handlebars_ref, auth, user_opt, req).await +} diff --git a/rc_society/src/web/garage/export.rs b/rc_society/src/web/garage/export.rs new file mode 100644 index 0000000..87f97ad --- /dev/null +++ b/rc_society/src/web/garage/export.rs @@ -0,0 +1,52 @@ +use actix_web::{HttpRequest, HttpResponse, Responder, get, http::StatusCode, web::{Data, Path, Query}}; +use actix_identity::Identity; + +use oj_rc_plugins::vehicle_import::VehicleImportData; + +use crate::web::{LoginReturn, try_auth_user}; +use crate::api::garage::plugins::ImportPlugins; + +#[get("/garages/{id}/export")] +pub async fn get(id: Path, query: Query, exporter: Data, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + match try_auth_user(user_opt, auth.as_ref(), &req).await? { + LoginReturn::AuthFail(resp) => Ok(resp), + LoginReturn::Success(user) => { + match user.garage_by_id(*id).await { + Ok(Some(garage)) => { + let data = VehicleImportData { + cube_data: garage.robot_data, + colour_data: garage.colour_data, + vehicle_name: garage.name, + vehicle_author: Some(user.display_name().to_owned()), + }; + match exporter.export_by_name(&query.plugin, &data) { + Ok(export) => { + //export.push(b'\n'); + use actix_web::http::header::{ContentDisposition, TryIntoHeaderPair}; + let ext = exporter.file_ext(&query.plugin).unwrap(); + // TODO sanitise vehicle name and include it in the filename + let dispo = ContentDisposition::attachment(format!("export-{}-{}.{}", &query.plugin, *id, ext)); + let (key, val) = dispo.try_into_pair().unwrap(); + let mut resp = HttpResponse::with_body(StatusCode::OK, export); + resp.headers_mut().append(key, val); + Ok(resp.map_into_boxed_body()) + }, + Err(e) => { + log::error!("Failed export of vehicle {} for user {}: {}", id, user.public_id(), e); + Err(super::PluginPortError::from(e).into()) + } + } + }, + Ok(None) => { + Ok(HttpResponse::NotFound() + .finish()) + }, + Err(e) => { + log::error!("Failed to load garage ID for user {}: {}", user.public_id(), e); + Ok(HttpResponse::InternalServerError() + .finish()) + } + } + } + } +} diff --git a/rc_society/src/web/garage/import.rs b/rc_society/src/web/garage/import.rs new file mode 100644 index 0000000..ccd40f2 --- /dev/null +++ b/rc_society/src/web/garage/import.rs @@ -0,0 +1,221 @@ +use actix_web::{get, post, web::{Data, Path}, Responder, HttpRequest}; +use actix_identity::Identity; +use serde::{Serialize, Deserialize}; + +use crate::web::{LoginReturn, try_auth_user, render_ok, render_err}; +use crate::api::garage::plugins::ImportPlugins; + +const FORM_NAME: &str = "garage_import"; + +#[derive(Serialize, Deserialize)] +struct RenderData { + display_name: String, + public_id: String, + garage: Option, + importers: Vec, + selected_importer: Option, + success: Option, +} + +#[derive(Serialize, Deserialize)] +struct GarageData { + garage_id: i32, + slot: i32, +} + +async fn import_impl(id: Option, handlebars_ref: Data>, importer: Data, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + match try_auth_user(user_opt, auth.as_ref(), &req).await? { + LoginReturn::AuthFail(resp) => Ok(resp), + LoginReturn::Success(user) => { + let html = if let Some(id) = id { + match user.garage_by_id(id).await { + Ok(Some(garage)) => { + render_ok( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: Some(GarageData { + garage_id: id, + slot: garage.slot, + }), + importers: importer.plugin_names() + .map(|x| x.to_owned()) + .collect(), + selected_importer: None, + success: None, + }, + handlebars_ref.as_ref(), + FORM_NAME, + ) + }, + Ok(None) => { + log::error!("Failed to find vehicle {} for user {}", id, user.public_id()); + render_err( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: Some(GarageData { + garage_id: -1, + slot: -1, + }), + importers: Vec::new(), + selected_importer: None, + success: None, + }, + "Vehicle not found".to_owned(), + handlebars_ref.as_ref(), + FORM_NAME, + ) + }, + Err(e) => { + log::error!("Failed to retrieve vehicle {} for user {}: {}", id, user.public_id(), e); + render_err( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: Some(GarageData { + garage_id: -1, + slot: -1, + }), + importers: Vec::new(), + selected_importer: None, + success: None, + }, + e.to_string(), + handlebars_ref.as_ref(), + FORM_NAME, + ) + } + } + } else { + render_ok( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: None, + importers: importer.plugin_names() + .map(|x| x.to_owned()) + .collect(), + selected_importer: None, + success: None, + }, + handlebars_ref.as_ref(), + FORM_NAME, + ) + }; + Ok( + html + .respond_to(&req) + .map_into_boxed_body() + ) + } + } +} + +#[get("/garages/{id}/import")] +pub async fn get_existing(id: Path, handlebars_ref: Data>, importer: Data, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + import_impl(Some(*id), handlebars_ref, importer, auth, user_opt, req).await +} + +#[get("/garages/import")] +pub async fn get_new(handlebars_ref: Data>, importer: Data, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + import_impl(None, handlebars_ref, importer, auth, user_opt, req).await +} + +#[derive(Debug, actix_multipart::form::MultipartForm)] +struct ImportForm { + //#[multipart] + files: Vec, + garage_id: actix_multipart::form::text::Text, + plugin: actix_multipart::form::text::Text, + +} + +#[post("/garages/import")] +pub async fn post(form: actix_multipart::form::MultipartForm, handlebars_ref: Data>, importer: Data, parsers: Data, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + match try_auth_user(user_opt, auth.as_ref(), &req).await? { + LoginReturn::AuthFail(resp) => Ok(resp), + LoginReturn::Success(user) => { + let total_size: usize = form.files.iter().map(|f| f.data.len()).sum(); + if form.files.is_empty() { + return Err(super::PluginPortError { + code: oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid, + }.into()); + } + if *form.garage_id != -1 && form.files.len() != 1 { + return Err(super::PluginPortError { + code: oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid, + }.into()); + } + for (i, file) in form.files.iter().enumerate() { + #[cfg(debug_assertions)] + log::trace!("import {} file {} data: {:?}", &*form.plugin, i, &file.data[..]); + let import_data = match importer.import_by_name(&form.plugin, &file.data) + .map_err(|e| super::PluginPortError { code: e }) { + Ok(x) => x, + Err(e) => { + let html = render_err( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: None, + importers: importer.plugin_names() + .map(|x| x.to_owned()) + .collect(), + selected_importer: Some(form.plugin.0.clone()), + success: None, + }, + format!("Failed to import file {}: {}", i, e), + handlebars_ref.as_ref(), + FORM_NAME, + ); + return Ok( + html + .respond_to(&req) + .map_into_boxed_body() + ); + }, + }; + let vehicle_data = oj_rc_core::persist::user::VehicleData { + name: import_data.vehicle_name, + slot: -1, // will be overriden in oj_rc_core + robot_data: import_data.cube_data, + colour_data: import_data.colour_data, + weapon_order: Vec::default(), // will be overriden in oj_rc_core + crf_id: None, // irrelevant + was_rated: None, // irrelevant + }; + user.save_garage( + vehicle_data, + if *form.garage_id < 0 { None } else { Some(*form.garage_id) }, // multipart crate silliness + parsers.cpu_counter().as_ref(), + parsers.weapon_order().as_ref(), + ).await?; + } + let html = render_ok( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: None, + importers: importer.plugin_names() + .map(|x| x.to_owned()) + .collect(), + selected_importer: Some(form.plugin.0.clone()), + success: Some(format!( + "Imported {} vehicle{} ({:.2}KiB)", + form.files.len(), + if form.files.len() > 1 { "s" } else { "" }, + total_size as f64 / 1024.0, + )), + }, + handlebars_ref.as_ref(), + FORM_NAME, + ); + Ok( + html + .respond_to(&req) + .map_into_boxed_body() + ) + } + } +} diff --git a/rc_society/src/web/garage/info.rs b/rc_society/src/web/garage/info.rs new file mode 100644 index 0000000..998057c --- /dev/null +++ b/rc_society/src/web/garage/info.rs @@ -0,0 +1,93 @@ +use actix_web::{get, web::{Data, Path}, Responder, HttpRequest}; +use actix_identity::Identity; +use serde::{Serialize, Deserialize}; + +use crate::web::{LoginReturn, try_auth_user, render_ok, render_err}; +use crate::api::garage::plugins::ImportPlugins; + +const FORM_NAME: &str = "garage_info"; + +#[derive(Serialize, Deserialize)] +struct RenderData { + display_name: String, + public_id: String, + garage: GarageData, + exporters: Vec, +} + +#[derive(Serialize, Deserialize)] +struct GarageData { + garage_id: i32, + slot: i32, + name: String, +} + +#[get("/garages/{id}/info")] +pub async fn get(id: Path, handlebars_ref: Data>, exporter: Data, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + match try_auth_user(user_opt, auth.as_ref(), &req).await? { + LoginReturn::AuthFail(resp) => Ok(resp), + LoginReturn::Success(user) => { + let html = match user.garage_by_id(*id).await { + Ok(Some(garage)) => { + render_ok( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: GarageData { + garage_id: *id, + slot: garage.slot, + name: garage.name.unwrap_or_default(), + }, + exporters: exporter.plugin_names() + .map(|x| x.to_owned()) + .collect(), + }, + handlebars_ref.as_ref(), + FORM_NAME, + ) + }, + Ok(None) => { + log::error!("Failed to find vehicle {} for user {}", id, user.public_id()); + render_err( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: GarageData { + garage_id: -1, + slot: -1, + name: String::default(), + }, + exporters: Vec::new(), + }, + "Vehicle not found".to_owned(), + handlebars_ref.as_ref(), + FORM_NAME, + ) + }, + Err(e) => { + log::error!("Failed to retrieve vehicle {} for user {}: {}", id, user.public_id(), e); + render_err( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garage: GarageData { + garage_id: -1, + slot: -1, + name: String::default(), + }, + exporters: Vec::new(), + }, + e.to_string(), + handlebars_ref.as_ref(), + FORM_NAME, + ) + } + }; + Ok( + html + .respond_to(&req) + .map_into_boxed_body() + ) + } + } +} diff --git a/rc_society/src/web/garage/list.rs b/rc_society/src/web/garage/list.rs new file mode 100644 index 0000000..5fea689 --- /dev/null +++ b/rc_society/src/web/garage/list.rs @@ -0,0 +1,76 @@ +use actix_web::{get, web::Data, Responder, HttpRequest}; +use actix_identity::Identity; +use serde::{Serialize, Deserialize}; + +use crate::web::{LoginReturn, try_auth_user, render_ok, render_err}; + +const FORM_NAME: &str = "garage_list"; + +#[derive(Serialize, Deserialize)] +struct RenderData { + display_name: String, + public_id: String, + garages: Vec, +} + +#[derive(Serialize, Deserialize)] +struct GarageData { + garage_id: i32, + slot: i32, + name: String, + cpu: i32, + max_cpu: i32, + creation_time_unix: i64, + creation_time_iso: String, +} + +#[get("/garages/list")] +pub async fn get(handlebars_ref: Data>, auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + match try_auth_user(user_opt, auth.as_ref(), &req).await? { + LoginReturn::AuthFail(resp) => Ok(resp), + LoginReturn::Success(user) => { + let html = match user.garages().await { + Ok(mut garage_infos) => { + garage_infos.sort_by_key(|g| g.slot); + render_ok( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garages: garage_infos.into_iter() + .map(|g| GarageData { + garage_id: g.id, + slot: g.slot, + name: g.name, + cpu: g.total_robot_cpu, + max_cpu: g.bay_cpu, + creation_time_unix: g.creation_time, + creation_time_iso: chrono::DateTime::::from_timestamp_secs(g.creation_time).unwrap_or_default().to_rfc3339(), + }) + .collect(), + }, + handlebars_ref.as_ref(), + FORM_NAME, + ) + }, + Err(e) => { + log::error!("Failed to retrieve garages for user {}: {}", user.public_id(), e); + render_err( + RenderData { + display_name: user.display_name().to_owned(), + public_id: user.public_id().to_owned(), + garages: Vec::new(), + }, + e.to_string(), + handlebars_ref.as_ref(), + FORM_NAME, + ) + } + }; + Ok( + html + .respond_to(&req) + .map_into_boxed_body() + ) + } + } +} diff --git a/rc_society/src/web/garage/mod.rs b/rc_society/src/web/garage/mod.rs new file mode 100644 index 0000000..1976bfe --- /dev/null +++ b/rc_society/src/web/garage/mod.rs @@ -0,0 +1,37 @@ +pub mod list; +pub mod export; +pub mod import; +pub mod info; +pub mod selected; + +#[derive(serde::Deserialize)] +struct PortQuery { + pub plugin: String, +} + +#[derive(Debug)] +struct PluginPortError { + code: oj_rc_plugins::vehicle_import::VehicleImportErrorCode, +} + +impl std::convert::From for PluginPortError { + fn from(value: oj_rc_plugins::vehicle_import::VehicleImportErrorCode) -> Self { + Self { + code: value, + } + } +} + +impl core::fmt::Display for PluginPortError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.code) + } +} + +impl core::error::Error for PluginPortError {} + +impl actix_web::error::ResponseError for PluginPortError { + fn status_code(&self) -> actix_web::http::StatusCode { + actix_web::http::StatusCode::UNPROCESSABLE_ENTITY + } +} diff --git a/rc_society/src/web/garage/selected.rs b/rc_society/src/web/garage/selected.rs new file mode 100644 index 0000000..661c232 --- /dev/null +++ b/rc_society/src/web/garage/selected.rs @@ -0,0 +1,29 @@ +use actix_web::{get, web::{Data, Redirect}, Responder, HttpRequest}; +use actix_identity::Identity; + +use crate::web::{LoginReturn, try_auth_user}; + +#[get("/garages/selected")] +pub async fn get(auth: Data>, user_opt: Option, req: HttpRequest) -> Result { + match try_auth_user(user_opt, auth.as_ref(), &req).await? { + LoginReturn::AuthFail(resp) => Ok(resp), + LoginReturn::Success(user) => { + let resp = if let Some(selected_garage) = user.garage_id_selected().await? { + Redirect::to(format!("/garages/{}/info", selected_garage)) + .respond_to(&req) + .map_into_boxed_body() + + } else { + log::error!("User {} has no garage selected (bad database state?)", user.display_name()); + Redirect::to("/garages/list".to_owned()) + .respond_to(&req) + .map_into_boxed_body() + }; + Ok( + resp + .respond_to(&req) + .map_into_boxed_body() + ) + } + } +} diff --git a/rc_society/src/web/mod.rs b/rc_society/src/web/mod.rs index 2422763..cee20ec 100644 --- a/rc_society/src/web/mod.rs +++ b/rc_society/src/web/mod.rs @@ -2,6 +2,7 @@ pub mod dashboard; pub mod login; pub mod favicon; pub mod index; +pub mod garage; use serde::Serialize; use actix_web::{web::{Html, Redirect}, Responder}; @@ -51,19 +52,25 @@ async fn try_auth_user(user_opt: Option, auth: &oj_rc_ if let Some(user) = user_opt { let user_id = user.id()?; match >::web_authenticate(auth, user_id.clone()).await { - Ok(user) => Ok(LoginReturn::Success(user)), + Ok(user) => { + //log::debug!("Web auth success"); + Ok(LoginReturn::Success(user)) + }, Err(e) => { log::warn!("Failed to login with token {}: {} ({:?})", user_id, e.message, e.code); Ok(LoginReturn::AuthFail( Redirect::to("/login") + .temporary() .respond_to(req) .map_into_boxed_body() )) } } } else { + log::debug!("No user identity, redirecting to login page"); Ok(LoginReturn::AuthFail( Redirect::to("/login") + .temporary() .respond_to(req) .map_into_boxed_body() ))