diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d41a48c..097e5e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,12 +23,12 @@ jobs: path: | game_data/generated map/static/map/icons - key: game-data-v1 + key: game-data-v2 - name: Fetch game data # sav_core embeds game_data/generated/*.json at compile time. if: steps.game-data.outputs.cache-hit != 'true' run: | - wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v1/game_data.zip + wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip python3 game_data/package_game_data.py unpack game_data.zip - name: Cache test saves uses: actions/cache@v4 @@ -59,11 +59,11 @@ jobs: path: | game_data/generated map/static/map/icons - key: game-data-v1 + key: game-data-v2 - name: Fetch game data if: steps.game-data.outputs.cache-hit != 'true' run: | - wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v1/game_data.zip + wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip python3 game_data/package_game_data.py unpack game_data.zip - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e1941d..80347a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,8 @@ map image) — it's extracted from the game's own files and too large/derivative to version. Get it one of two ways: - **Quick setup — download the pre-extracted archive**: - [game_data.zip on Google Drive](https://drive.google.com/file/d/16JshnM65xrTpwxwbYs2iHmoog2AKDGZN/view?usp=sharing), + [game_data.zip from the latest game-data release](https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip) + (the same archive the production build downloads — see `build.sh`), then unpack it — ```bash py game_data/package_game_data.py unpack path/to/game_data.zip diff --git a/build.sh b/build.sh index a99e1a8..bc4df8b 100644 --- a/build.sh +++ b/build.sh @@ -8,8 +8,8 @@ set -euo pipefail # fetching "latest" of an installer -- or an unverified game-data blob -- would # let an upstream change (or compromise) flow straight into the deployed site. WASM_PACK_VERSION="v0.15.0" -GAME_DATA_URL="https://github.com/valentinps/satisfactorymap/releases/download/game-data-v1/game_data.zip" -GAME_DATA_SHA256="75b2087fae82ff00a1d36fccebd0970550e5ccebfad6d2b159c2838718cd13d6" +GAME_DATA_URL="https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip" +GAME_DATA_SHA256="9db05102cf5ce375ddbb55d4c47cad07cc536603f490faa716e10ef693d07af6" echo "Installing Rust..." # rustup itself is fetched over pinned-TLS; the toolchain is stable (pin it diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index 4094679..e212cbc 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -1,11 +1,22 @@ # v0.1.7 -Load saves straight from a URL, sturdier sidebar filtering, consistent ore -labels — and under the hood, the map's world data now regenerates from the -game's own files. +Creature spawners on the map, a calmer first load, saves loadable straight +from a URL — and under the hood, the map's world data now regenerates from +the game's own files. ## New +- **Creature spawners on the map.** A new Spawners category pins every + creature spawn point on the planet — Hogs, Crab Hatchers, Stingers and + Spitters as toggleable families, plus Lizard Doggos, Flightless Birbs and + the Space Giraffe-Tick-Penguin-Whale Thing — about 2,800 pins, each with + the game's own creature icon. The data comes from the game's level files + (a save never records which creature a spawner spawns), so it's identical + on every save. +- **A calmer first load.** The world layers — resource nodes, resource + wells, collectables, spawners, and dropped items — now start hidden, so + loading a save shows your factory instead of thousands of pins. Switching + any of them on is remembered like every other filter choice. - **Load a save from a URL.** Append `?url=` to the map's address and the file downloads straight into your browser (with progress) and parses as usual — so a dedicated server's autosave can be shared as a @@ -40,10 +51,9 @@ game's own files. sites, dropped items, resource-node purity) now regenerate from the game's own level data via a one-command pipeline — validated 1:1 against the previous tables, and recovering two dropped-item pickups and one - resource node the old data missed. New groundwork tables extracted the - same way: every creature spawner on the map (all 159 Lizard Doggo spawns - included), crab hatcher locations, consumable plants, and the official - creature names with icons. + resource node the old data missed. Extracted the same way: the spawner + and creature-name/icon tables behind the new Spawners category (all 159 + Lizard Doggo spawns included), plus consumable plants as groundwork. ## Checksums (SHA-256) diff --git a/game_data/SCHEMA.md b/game_data/SCHEMA.md index 3db261f..5ebb7df 100644 --- a/game_data/SCHEMA.md +++ b/game_data/SCHEMA.md @@ -425,9 +425,12 @@ Hatcher variants (world actors, not spawner-spawned). path tree, so it lands in the same `` layout). - Creatures are NOT in Docs.json at all (they have no item/build descriptor) -- hence the string-table route. These are the game's literal - English strings, quirks included: - the Space Giraffe's real in-game name is "Unknown File Error #6265616e", - and the Red Forest Spitter variants share display names with the normal + English strings, quirks included -- with one deliberate exception: the + Space Giraffe's real in-game name is "Unknown File Error #6265616e" (hex + for "bean"), which on a map layer reads as the app failing to resolve a + name, so the extractor's `DISPLAY_NAME_OVERRIDES` replaces it with the + community name "Space Giraffe-Tick-Penguin-Whale Thing". Also: + the Red Forest Spitter variants share display names with the normal Forest ones (their descriptors point at the same localization keys). Two classes have no descriptor and no localization entry -- genuinely unnamed in-game -- so their names are this repo's own fallbacks: diff --git a/game_data/extractors/extract_spawners.py b/game_data/extractors/extract_spawners.py index ca97425..8aea9ef 100644 --- a/game_data/extractors/extract_spawners.py +++ b/game_data/extractors/extract_spawners.py @@ -94,6 +94,14 @@ "/FactoryGame/Character/Creature/CreatureDescriptors/UI/IconDesc_Hatcher_256"), } +# Applied over the game's own strings after extraction. The Space Giraffe's +# literal in-game name is "Unknown File Error #6265616e" (the hex spells +# "bean") -- a great gag in-game, but on a map layer it reads as this app +# actually failing to resolve a name, so use the community name instead. +DISPLAY_NAME_OVERRIDES = { + "Char_SpaceGiraffe_C": "Space Giraffe-Tick-Penguin-Whale Thing", +} + def shortClassName(objectField): """{"ObjectName": "BlueprintGeneratedClass'Char_Hog_C'"} -> "Char_Hog_C".""" @@ -253,6 +261,9 @@ def main(): if creature not in creatures and creature != "unknown": print(f"WARNING: spawned creature {creature} has no descriptor and no " f"fallback display name") + for creature, displayName in DISPLAY_NAME_OVERRIDES.items(): + if creature in creatures: + creatures[creature]["displayName"] = displayName creatures = dict(sorted(creatures.items())) with open(NAMES_OUTPUT_PATH, "w", encoding="utf-8", newline="\n") as f: json.dump(creatures, f, ensure_ascii=False, indent=1) diff --git a/map/static/map/filters.js b/map/static/map/filters.js index b10250a..908748d 100644 --- a/map/static/map/filters.js +++ b/map/static/map/filters.js @@ -11,7 +11,8 @@ // + game_data/categoryLabels.json via payload.menuOrder -- see // buildBuildingCategorySections. Resource Nodes, HUB, Entities, Collectables // (which nests Hard Drives alongside Power Slugs/Somersloops/Mercer Spheres), -// and Dropped Items (loose ground stacks) are their own separate sections. renderGroup() supports the +// Spawners (creature spawn markers, grouped by family), and Dropped Items +// (loose ground stacks) are their own separate sections. renderGroup() supports the // subcategory nesting generically (see its doc comment). var Filters = {}; @@ -244,6 +245,25 @@ var Filters = {}; return title.replace(/\s*\([\d,\/]+\)\s*$/, ""); } + // Top-level categories hidden unless explicitly enabled: the world layers + // -- every resource node/well, every collectable, every creature spawner, + // every loose ground stack -- are thousands of pins that buried the + // player's own factory. + // An explicit choice scoped to the category still wins and persists (a + // per-bucket entry, or a stored group toggle at any level inside it), but + // Check-all's blanket "*" entry deliberately does NOT reach these: it + // predates whatever category was added since (or was stored to mean "my + // factory", not "the whole planet"), and letting it through re-buried the + // map on the next load. Check all still shows everything for the current + // session -- it flips the buckets directly, this only governs restore. + var DEFAULT_HIDDEN_CATEGORIES = { + "Resource Nodes": true, + "Resource Wells": true, + "Collectables": true, + "Spawners": true, + "Dropped Items": true, + }; + function savedGroupStateForStack() { for (var i = groupPathStack.length; i > 0; i--) { var key = "group:" + groupPathStack.slice(0, i).join("/"); @@ -251,6 +271,9 @@ var Filters = {}; return savedVisibility[key]; } } + if (DEFAULT_HIDDEN_CATEGORIES[groupPathStack[0]]) { + return false; + } if (savedVisibility.hasOwnProperty("*")) { return savedVisibility["*"]; } @@ -1000,6 +1023,83 @@ var Filters = {}; }); } + // ---- Spawners (creature spawn markers -- static world data) -------------- + + // payload.spawners (see the rust core's collect_spawners) is the same for + // every save: creature spawn markers from the cooked level data, not save + // actors -- the save's own spawner actors never say which creature they + // spawn. Pins follow the collectables' design (real icon on a white pin + // circle), using the extracted creature icons keyed by class name + // (icons/creatures/.png -- see game_data/extractors/copy_icons.py). + var SPAWNER_ICON_BASE = "icons/creatures/"; + + // Creature families with several variants each get a collapsible subgroup + // (matched on the class name); everything else (Lizard Doggo, Flightless + // Birb, the Space Giraffe-Tick-Penguin-Whale Thing) is a loose row after + // them. First match wins; the patterns are mutually exclusive today. + var SPAWNER_FAMILIES = [ + { title: "Hogs", pattern: /Hog/ }, + { title: "Crab Hatchers", pattern: /Hatcher/ }, + { title: "Stingers", pattern: /Stinger/ }, + { title: "Spitters", pattern: /Spitter/ }, + ]; + + function buildSpawnersSection(navList, detailPane, payload) { + var spawnerTypes = payload.spawners || []; + var rowsByFamily = SPAWNER_FAMILIES.map(function() { return []; }); + var looseRows = []; + var total = 0; + spawnerTypes.forEach(function(spawnerType) { + var count = pointCount(spawnerType.points, 3); + if (count === 0) { + return; + } + total += count; + var url = SPAWNER_ICON_BASE + encodeURIComponent(spawnerType.typePath) + ".png"; + // The bucket label says "Spawner" so a rectangle-selection list can't + // read as live creatures; the sidebar row shows just the species + // (displayLabel), already sitting under the "Spawners" heading. + var label = spawnerType.label + " Spawner"; + var tooltipInfo = function(index) { + return { title: label, rows: [], position: worldPositionAt(spawnerType.points, index) }; + }; + var bucket = makeIconBucket("spawner:" + spawnerType.typePath, label, CREATURE_COLOR, + spawnerType.points, spawnerType.ids, "static", tooltipInfo, url, 1); + var row = { label: label, displayLabel: spawnerType.label, count: count, + color: CREATURE_COLOR, buckets: [bucket], iconUrl: url }; + for (var i = 0; i < SPAWNER_FAMILIES.length; i++) { + if (SPAWNER_FAMILIES[i].pattern.test(spawnerType.typePath)) { + rowsByFamily[i].push(row); + return; + } + } + looseRows.push(row); + }); + if (total === 0) { + return; + } + renderTopLevelCategory(navList, detailPane, "Spawners (" + total + ")", "icon", CREATURE_COLOR, function(childrenDiv) { + var checkboxes = []; + var allBuckets = []; + SPAWNER_FAMILIES.forEach(function(family, familyIndex) { + var rows = rowsByFamily[familyIndex]; + if (rows.length === 0) { + return; + } + var familyTotal = rows.reduce(function(s, r) { return s + r.count; }, 0); + var result = renderGroup(childrenDiv, family.title + " (" + familyTotal + ")", "icon", + CREATURE_COLOR, rows, { startCollapsed: true, iconUrl: rows[0].iconUrl }); + checkboxes.push(result.checkbox); + allBuckets = allBuckets.concat(result.buckets); + }); + looseRows.forEach(function(row) { + checkboxes.push(appendLeafRow(childrenDiv, row, "icon", CREATURE_COLOR)); + allBuckets = allBuckets.concat(row.buckets); + }); + return { buckets: allBuckets, checkboxes: checkboxes }; + }, { iconUrl: CREATURE_ICON_URL }); + } + // ---- Dropped / ground items ---------------------------------------------- // Fallback dot color for the rare dropped item whose ClassName has no @@ -1490,6 +1590,7 @@ var Filters = {}; buildHubSection(navList, detailPane, payload); buildEntitiesSection(navList, detailPane, payload); buildCollectablesSection(navList, detailPane, payload); + buildSpawnersSection(navList, detailPane, payload); buildDroppedItemsSection(navList, detailPane, payload); // Fit the nav panel to the category labels now that they all exist. diff --git a/rust_parser/core/src/gamedata/mod.rs b/rust_parser/core/src/gamedata/mod.rs index 07334bc..4bfcfdd 100644 --- a/rust_parser/core/src/gamedata/mod.rs +++ b/rust_parser/core/src/gamedata/mod.rs @@ -34,6 +34,18 @@ pub struct PowerSlugs { pub purple: SlugMap, } +/// creatures.json: official display name + icon per creature class (see +/// game_data/extractors/extract_spawners.py). +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatureInfo { + pub display_name: String, + pub icon: Option, +} + +/// creatureSpawners.json: {creatureShortClass|"unknown": {instancePathName: [x,y,z]}} +pub type CreatureSpawnerMap = IndexMap>; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct TypePaths { @@ -67,6 +79,10 @@ pub struct GameData { pub recipes: serde_json::Map, pub schematics: serde_json::Map, pub game_phases: serde_json::Map, + // -- game_data/generated/ (FModel level-export extracts; see + // game_data/extractors/extract_spawners.py) -- + pub creatures: IndexMap, + pub creature_spawners: CreatureSpawnerMap, } macro_rules! embed { @@ -108,6 +124,11 @@ pub fn get() -> &'static GameData { recipes: parse("recipes.json", embed!("generated/recipes.json")), schematics: parse("schematics.json", embed!("generated/schematics.json")), game_phases: parse("gamePhases.json", embed!("generated/gamePhases.json")), + creatures: parse("creatures.json", embed!("generated/creatures.json")), + creature_spawners: parse( + "creatureSpawners.json", + embed!("generated/creatureSpawners.json"), + ), }) } @@ -156,6 +177,14 @@ mod tests { assert!(d.recipes.len() > 300); assert!(d.schematics.len() > 200); assert!(!d.game_phases.is_empty()); + assert!(d.creatures.len() >= 20, "creatures {}", d.creatures.len()); + let spawner_total: usize = d.creature_spawners.values().map(|m| m.len()).sum(); + assert!(spawner_total > 2000, "creature spawners {spawner_total}"); + // Every spawner class has a name row (the extractor warns otherwise). + assert!(d + .creature_spawners + .keys() + .all(|c| c == "unknown" || d.creatures.contains_key(c))); // Purity names are enum names. let (_, purity, _, _) = d.resource_purity.values().next().unwrap(); assert!(["UNKNOWN", "IMPURE", "NORMAL", "PURE"].contains(&purity.as_str())); diff --git a/rust_parser/core/src/mapdata/collectors/mod.rs b/rust_parser/core/src/mapdata/collectors/mod.rs index b60ac60..304650d 100644 --- a/rust_parser/core/src/mapdata/collectors/mod.rs +++ b/rust_parser/core/src/mapdata/collectors/mod.rs @@ -1,5 +1,7 @@ -//! The 17 payload collectors, one module per _payloadSteps entry. Each is an -//! exact behavioral port of its Python namesake in map/sav_map_data.py. +//! The 18 payload collectors, one module per _payloadSteps entry. Each is an +//! exact behavioral port of its Python namesake in map/sav_map_data.py -- +//! except "spawners", which post-dates the Python implementation (static +//! world data, no save input; see simple::collect_spawners). pub mod buildings; pub mod lines; @@ -24,6 +26,7 @@ pub fn registry() -> Vec<(&'static str, Collector)> { ("droppedItems", world::collect_dropped_items), ("players", simple::collect_players), ("creatures", simple::collect_creatures), + ("spawners", simple::collect_spawners), ("hub", simple::collect_hub), ("gameSettings", simple::collect_game_settings), ("vehicles", simple::collect_vehicles), @@ -39,13 +42,14 @@ pub fn registry() -> Vec<(&'static str, Collector)> { /// _payloadSteps key order -- payload keys must appear in this order /// regardless of which subset is requested. -pub const STEP_ORDER: [&str; 17] = [ +pub const STEP_ORDER: [&str; 18] = [ "buildingCategories", "resourceNodes", "collectables", "hardDrives", "players", "creatures", + "spawners", "vehicles", "trains", "droppedItems", diff --git a/rust_parser/core/src/mapdata/collectors/simple.rs b/rust_parser/core/src/mapdata/collectors/simple.rs index 8b2b553..a321994 100644 --- a/rust_parser/core/src/mapdata/collectors/simple.rs +++ b/rust_parser/core/src/mapdata/collectors/simple.rs @@ -1,8 +1,9 @@ -//! The structurally simple collectors: players, creatures, hub, +//! The structurally simple collectors: players, creatures, spawners, hub, //! gameSettings, vehicles, dimensionalDepot (sav_map_data.py lines //! ~668-696, 1505-1580, 3367-3391). use crate::extract::find_prop; +use crate::gamedata; use crate::mapdata::consts::*; use crate::mapdata::geometry::{ meters_to_pixel_length, project_xy, rendered_yaw, world_z_to_meters, @@ -74,6 +75,48 @@ pub fn collect_creatures(scan: &SaveScan) -> Value { }]) } +/// Creature spawn markers -- static world data (the embedded +/// creatureSpawners.json/creatures.json tables, see +/// game_data/extractors/extract_spawners.py), not save actors: the save's +/// ~2,277 spawner actors don't record which creature they spawn, so the +/// cooked level data is the only source of truth and the result is the same +/// for every save. Beetles are dropped deliberately: no icon exists for +/// them, and they're not a creature anyone hunts on the map. One entry per +/// creature class, sorted by label for a stable sidebar order. +pub fn collect_spawners(_scan: &SaveScan) -> Value { + let gd = gamedata::get(); + let mut entries: Vec<(String, String, Value)> = Vec::new(); + for (class, spawners) in &gd.creature_spawners { + if class == "unknown" || class == "Char_Beetle_C" { + continue; + } + let Some(info) = gd.creatures.get(class) else { continue }; + let mut points: Vec = Vec::new(); + let mut ids: Vec = Vec::new(); + for (path_name, &[x, y, z]) in spawners { + let [px, py] = project_xy(x, y); + points.push(jnum(px)); + points.push(jnum(py)); + points.push(jnum(world_z_to_meters(z))); + ids.push(Value::String(path_name.clone())); + } + entries.push(( + info.display_name.clone(), + class.clone(), + json!({ + "typePath": class, + "label": info.display_name, + "points": points, + "ids": ids, + }), + )); + } + // By label, class as the tiebreak (the Red Forest Spitter variants share + // display names with the normal Forest ones). + entries.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1))); + Value::Array(entries.into_iter().map(|(_, _, v)| v).collect()) +} + /// sav_map_data._humanizeEnumValue over a raw EnumProperty value. fn humanize_enum_value(value: Option<&PropertyValue>, data: &[u8]) -> Value { // Python receives ['EnumTypeName', 'EnumTypeName::SHORT_ValueName'] only diff --git a/rust_parser/core/src/mapdata/mod.rs b/rust_parser/core/src/mapdata/mod.rs index c284be8..11c3747 100644 --- a/rust_parser/core/src/mapdata/mod.rs +++ b/rust_parser/core/src/mapdata/mod.rs @@ -24,8 +24,9 @@ use crate::store::SaveStore; use scan::SaveScan; use serde_json::{json, Value}; -/// sav_map_data._BUILD_STEP_COUNT: 17 payload steps + the save index. -pub const BUILD_STEP_COUNT: u64 = 17 + 1; +/// 18 payload steps (sav_map_data._BUILD_STEP_COUNT's 17 + the static +/// spawners step) + the save index. +pub const BUILD_STEP_COUNT: u64 = 18 + 1; /// sav_map_data.listSearchableItems. fn list_searchable_items() -> Value { diff --git a/rust_parser/wasm/src/lib.rs b/rust_parser/wasm/src/lib.rs index 05cdbf9..848379f 100644 --- a/rust_parser/wasm/src/lib.rs +++ b/rust_parser/wasm/src/lib.rs @@ -194,8 +194,9 @@ impl SaveSession { .map_err(|e| JsError::new(&e.msg))?; drop(bytes); - // 17 payload steps + the index build = BUILD_STEP_COUNT ticks, same - // as Python's buildAll; one shared SaveScan for both. + // 18 payload steps + the index build = BUILD_STEP_COUNT ticks (same + // shape as Python's buildAll, plus the static spawners step); one + // shared SaveScan for both. let mut build_progress = |current: u64, total: u64| call(2, current, total); let (payload_json, index) = mapdata::build_all_json(&store, Some(&mut build_progress))