Add Ground Zero terrain pipeline and playable assets

This commit is contained in:
2026-05-14 00:09:40 -07:00
parent 46d0e080b5
commit 6d25ff690d
77 changed files with 5770 additions and 84 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ if not exist "%BUILD_BAT%" (
echo Building AgrarianGameEditor with UnrealBuildTool...
echo Log: %LOG_FILE%
call "%BUILD_BAT%" AgrarianGameEditor Win64 Development -Project="%PROJECT_FILE%" -WaitMutex -architecture=x64 > "%LOG_FILE%" 2>&1
call "%BUILD_BAT%" AgrarianGameEditor Win64 Development -Project="%PROJECT_FILE%" -WaitMutex -architecture=x64 -NoUBA > "%LOG_FILE%" 2>&1
set "BUILD_EXIT_CODE=%ERRORLEVEL%"
type "%LOG_FILE%"
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Acquire the best available USGS 3DEP DEM source for Ground Zero.
This script queries the official USGS TNMAccess API for the selected 1 km tile,
stores the product metadata, downloads the chosen 1-meter GeoTIFF source, and
updates the tile registry source record.
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.parse
import urllib.request
from pathlib import Path
from prototype_ground_zero_terrain import TARGET_TILE_ID, utm_to_lat_lon
PROJECT_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = PROJECT_ROOT / "Data" / "Tiles" / "ground_zero_tiles.json"
SOURCE_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Sources" / TARGET_TILE_ID
TNM_PRODUCTS_URL = "https://tnmaccess.nationalmap.gov/api/v1/products"
TARGET_DATASET = "Digital Elevation Model (DEM) 1 meter"
def load_tile() -> dict:
registry = json.loads(REGISTRY_PATH.read_text())
for tile in registry["tiles"]:
if tile["tile_id"] == TARGET_TILE_ID:
return tile
raise RuntimeError(f"Could not find {TARGET_TILE_ID} in {REGISTRY_PATH}")
def tile_bbox_lon_lat(tile: dict) -> tuple[float, float, float, float]:
grid = tile["grid"]
corners = [
(grid["easting_min_m"], grid["northing_min_m"]),
(grid["easting_max_m"], grid["northing_min_m"]),
(grid["easting_max_m"], grid["northing_max_m"]),
(grid["easting_min_m"], grid["northing_max_m"]),
]
lat_lons = [utm_to_lat_lon(easting, northing, 10, True) for easting, northing in corners]
return (
min(lon for _, lon in lat_lons),
min(lat for lat, _ in lat_lons),
max(lon for _, lon in lat_lons),
max(lat for lat, _ in lat_lons),
)
def query_tnm_products(bbox: tuple[float, float, float, float]) -> dict:
params = urllib.parse.urlencode(
{
"bbox": ",".join(f"{value:.10f}" for value in bbox),
"datasets": TARGET_DATASET,
"prodFormats": "GeoTIFF",
"outputFormat": "JSON",
}
)
url = f"{TNM_PRODUCTS_URL}?{params}"
with urllib.request.urlopen(url, timeout=60) as response:
payload = json.loads(response.read().decode("utf-8"))
payload["query_url"] = url
return payload
def choose_product(products: dict) -> dict:
items = products.get("items", [])
if not items:
raise RuntimeError("TNMAccess returned no 1-meter DEM products for Ground Zero")
def product_score(item: dict) -> tuple[int, str]:
title = item.get("title", "")
size = int(item.get("sizeInBytes") or 0)
is_geotiff = 1 if item.get("format") == "GeoTIFF" else 0
has_download = 1 if item.get("downloadURL") else 0
return (has_download + is_geotiff, f"{size:020d}_{title}")
return sorted(items, key=product_score, reverse=True)[0]
def coverage_products(products: dict) -> list[dict]:
items = products.get("items", [])
if not items:
raise RuntimeError("TNMAccess returned no 1-meter DEM products for Ground Zero")
return [item for item in items if item.get("downloadURL")]
def download_file(url: str, output_path: Path) -> None:
if output_path.exists() and output_path.stat().st_size > 0:
print(f"Using existing download: {output_path}")
return
temp_path = output_path.with_suffix(output_path.suffix + ".part")
with urllib.request.urlopen(url, timeout=120) as response, temp_path.open("wb") as output_file:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
output_file.write(chunk)
temp_path.replace(output_path)
def update_registry_source(product: dict, metadata_path: Path, geotiff_path: Path) -> None:
registry = json.loads(REGISTRY_PATH.read_text())
for tile in registry["tiles"]:
if tile["tile_id"] != TARGET_TILE_ID:
continue
for source in tile["sources"]:
if source["source_kind"] == "elevation":
source["source_name"] = product.get("title", source["source_name"])
source["source_uri"] = product.get("downloadURL", source["source_uri"])
source["source_version"] = product.get("publicationDate", "downloaded")
source["coverage_status"] = "confirmed"
source["local_metadata_path"] = str(metadata_path.relative_to(PROJECT_ROOT))
source["local_source_path"] = str(geotiff_path.relative_to(PROJECT_ROOT))
source["local_source_folder"] = str(geotiff_path.parent.relative_to(PROJECT_ROOT))
tile["status"] = "source_data_found"
tile["notes"] = (
"Final MVP 1-meter USGS DEM source acquired. "
"Prototype heightmap remains generated separately until DEM extraction/import is run."
)
break
else:
raise RuntimeError(f"Could not update missing tile {TARGET_TILE_ID}")
REGISTRY_PATH.write_text(json.dumps(registry, indent=2) + "\n")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--metadata-only", action="store_true", help="Query and write metadata without downloading the GeoTIFF")
args = parser.parse_args()
tile = load_tile()
bbox = tile_bbox_lon_lat(tile)
SOURCE_ROOT.mkdir(parents=True, exist_ok=True)
products = query_tnm_products(bbox)
products_to_download = coverage_products(products)
product = choose_product(products)
metadata = {
"tile_id": TARGET_TILE_ID,
"acquired_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"tnm_query": {
"url": products["query_url"],
"bbox_lon_lat": bbox,
"dataset": TARGET_DATASET,
"product_count": products.get("total"),
},
"selected_product": product,
"coverage_products": products_to_download,
"all_products": products.get("items", []),
}
metadata_path = SOURCE_ROOT / f"{TARGET_TILE_ID}_tnm_1m_dem_product.json"
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
geotiff_paths = []
for coverage_product in products_to_download:
download_url = coverage_product.get("downloadURL")
filename = Path(urllib.parse.urlparse(download_url).path).name
geotiff_path = SOURCE_ROOT / filename
geotiff_paths.append(geotiff_path)
if not args.metadata_only:
download_file(download_url, geotiff_path)
update_registry_source(product, metadata_path, geotiff_paths[0])
print(f"Selected: {product.get('title')}")
print(f"Published: {product.get('publicationDate')}")
print(f"Size: {product.get('sizeInBytes')} bytes")
print(f"Metadata: {metadata_path}")
for geotiff_path in geotiff_paths:
if args.metadata_only:
print(f"GeoTIFF download skipped: {geotiff_path}")
else:
print(f"GeoTIFF: {geotiff_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Convert the extracted Ground Zero 1m DEM subset into Unreal heightmaps."""
from __future__ import annotations
import json
import struct
import time
from pathlib import Path
import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.warp import reproject
from prototype_ground_zero_terrain import TARGET_TILE_ID
PROJECT_ROOT = Path(__file__).resolve().parents[1]
EXTRACT_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Extracted" / TARGET_TILE_ID
UNREAL_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Unreal" / TARGET_TILE_ID
SOURCE_TIFF = EXTRACT_ROOT / f"{TARGET_TILE_ID}_1m_dem_subset.tif"
UNREAL_SIZE = 1009
TILE_SIZE_M = 1000.0
UNREAL_Z_SCALE_CM = 100.0
def encode_to_unreal_uint16(data: np.ma.MaskedArray) -> tuple[np.ndarray, float, float]:
min_elevation = float(data.min())
max_elevation = float(data.max())
# Unreal Landscape encodes zero height around 32768. At Z scale 100, each
# height unit is 100 / 128 cm and the full range is roughly +/- 256 m.
height_values = 32768.0 + (data.filled(0.0) * 100.0 * 128.0 / UNREAL_Z_SCALE_CM)
height_uint16 = np.rint(np.clip(height_values, 0.0, 65535.0)).astype("<u2")
return height_uint16, min_elevation, max_elevation
def resample_to_unreal_size(source: rasterio.io.DatasetReader) -> np.ma.MaskedArray:
source_data = source.read(1, masked=True)
destination = np.empty((UNREAL_SIZE, UNREAL_SIZE), dtype=np.float32)
dst_transform = rasterio.transform.from_bounds(
source.bounds.left,
source.bounds.bottom,
source.bounds.right,
source.bounds.top,
UNREAL_SIZE,
UNREAL_SIZE,
)
reproject(
source=source_data.filled(source.nodata),
destination=destination,
src_transform=source.transform,
src_crs=source.crs,
src_nodata=source.nodata,
dst_transform=dst_transform,
dst_crs=source.crs,
dst_nodata=source.nodata,
resampling=Resampling.bilinear,
)
return np.ma.masked_equal(destination, source.nodata)
def write_r16(height_data: np.ndarray, output_path: Path) -> None:
output_path.write_bytes(height_data.tobytes(order="C"))
def write_png16(height_data: np.ndarray, output_path: Path) -> bool:
try:
from PIL import Image
except ModuleNotFoundError:
return False
image = Image.fromarray(height_data, mode="I;16")
image.save(output_path)
return True
def write_preview_pgm(height_data: np.ndarray, output_path: Path) -> None:
preview = (height_data.astype(np.float32) / 65535.0 * 255.0).astype(np.uint8)
header = f"P5\n{UNREAL_SIZE} {UNREAL_SIZE}\n255\n".encode("ascii")
output_path.write_bytes(header + preview.tobytes(order="C"))
def write_import_metadata(
output_path: Path,
source: rasterio.io.DatasetReader,
min_elevation: float,
max_elevation: float,
r16_path: Path,
png_path: Path,
png_written: bool,
preview_path: Path,
) -> None:
vertical_range = max_elevation - min_elevation
metadata = {
"tile_id": TARGET_TILE_ID,
"generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"source_dem": str(SOURCE_TIFF.relative_to(PROJECT_ROOT)),
"source_crs": str(source.crs),
"source_bounds": list(source.bounds),
"source_size_pixels": [source.width, source.height],
"source_pixel_size_m": [abs(source.transform.a), abs(source.transform.e)],
"heightmap": {
"format": "r16_little_endian_unsigned",
"width": UNREAL_SIZE,
"height": UNREAL_SIZE,
"r16_path": str(r16_path.relative_to(PROJECT_ROOT)),
"png16_path": str(png_path.relative_to(PROJECT_ROOT)) if png_written else "",
"preview_pgm_path": str(preview_path.relative_to(PROJECT_ROOT)),
"min_elevation_m": min_elevation,
"max_elevation_m": max_elevation,
"vertical_range_m": vertical_range,
"encoding": "unreal_landscape_midpoint_32768_sea_level",
},
"unreal_landscape_import": {
"landscape_resolution": "1009 x 1009",
"tile_world_size_m": TILE_SIZE_M,
"x_scale_cm": 100.0 * TILE_SIZE_M / (UNREAL_SIZE - 1),
"y_scale_cm": 100.0 * TILE_SIZE_M / (UNREAL_SIZE - 1),
"z_scale_cm": UNREAL_Z_SCALE_CM,
"z_offset_m": 0.0,
"notes": [
"Use the R16 file for import.",
"Set X/Y scale to x_scale_cm/y_scale_cm so the 1009 samples span 1000 real meters.",
"Set Z scale to z_scale_cm.",
"Height values are encoded so Unreal landscape zero height corresponds to approximately sea level.",
"1009 x 1009 is a valid Unreal Landscape import size close to the 1000m source tile."
],
},
}
output_path.write_text(json.dumps(metadata, indent=2) + "\n")
def main() -> None:
UNREAL_ROOT.mkdir(parents=True, exist_ok=True)
r16_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_1009.r16"
png_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_1009.png"
preview_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_1009_preview.pgm"
metadata_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_heightmap_metadata.json"
with rasterio.open(SOURCE_TIFF) as source:
resampled = resample_to_unreal_size(source)
height_data, min_elevation, max_elevation = encode_to_unreal_uint16(resampled)
write_r16(height_data, r16_path)
png_written = write_png16(height_data, png_path)
write_preview_pgm(height_data, preview_path)
write_import_metadata(metadata_path, source, min_elevation, max_elevation, r16_path, png_path, png_written, preview_path)
print(f"R16: {r16_path}")
print(f"Preview: {preview_path}")
if png_written:
print(f"PNG16: {png_path}")
else:
print("PNG16 skipped: Pillow is not installed")
print(f"Metadata: {metadata_path}")
print(f"Elevation range: {min_elevation:.3f}m to {max_elevation:.3f}m")
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Extract the 1 km Ground Zero subset from the acquired USGS DEM.
This script requires either rasterio or GDAL Python bindings. The current
Ubuntu-Codex image does not include those packages by default, so this file is
checked in as the repeatable extraction step once the geospatial dependency is
available.
"""
from __future__ import annotations
import json
from pathlib import Path
from prototype_ground_zero_terrain import TARGET_TILE_ID
PROJECT_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = PROJECT_ROOT / "Data" / "Tiles" / "ground_zero_tiles.json"
SOURCE_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Sources" / TARGET_TILE_ID
EXTRACT_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Extracted" / TARGET_TILE_ID
def load_ground_zero() -> dict:
registry = json.loads(REGISTRY_PATH.read_text())
for tile in registry["tiles"]:
if tile["tile_id"] == TARGET_TILE_ID:
return tile
raise RuntimeError(f"Could not find {TARGET_TILE_ID}")
def find_source_tiffs(tile: dict) -> list[Path]:
for source in tile["sources"]:
if source["source_kind"] != "elevation":
continue
if source.get("local_source_folder"):
folder = PROJECT_ROOT / source["local_source_folder"]
candidates = sorted(folder.glob("*.tif"))
if candidates:
return candidates
if source.get("local_source_path"):
path = PROJECT_ROOT / source["local_source_path"]
if path.exists():
return [path]
candidates = sorted(SOURCE_ROOT.glob("*.tif"))
if candidates:
return candidates
raise RuntimeError("No acquired source GeoTIFF found. Run acquire_ground_zero_dem.py first.")
def extract_with_rasterio(source_tiffs: list[Path], tile: dict) -> None:
import rasterio
from rasterio.merge import merge
from rasterio.windows import from_bounds
EXTRACT_ROOT.mkdir(parents=True, exist_ok=True)
grid = tile["grid"]
output_tiff = EXTRACT_ROOT / f"{TARGET_TILE_ID}_1m_dem_subset.tif"
output_metadata = EXTRACT_ROOT / f"{TARGET_TILE_ID}_1m_dem_subset_metadata.json"
bounds = (
grid["easting_min_m"],
grid["northing_min_m"],
grid["easting_max_m"],
grid["northing_max_m"],
)
datasets = [rasterio.open(path) for path in source_tiffs]
try:
if len(datasets) == 1:
dataset = datasets[0]
source_bounds = [list(dataset.bounds)]
source_crs = str(dataset.crs)
window = from_bounds(*bounds, dataset.transform).round_offsets().round_lengths()
data = dataset.read(window=window)
transform = dataset.window_transform(window)
profile = dataset.profile
else:
source_bounds = [list(dataset.bounds) for dataset in datasets]
source_crs = str(datasets[0].crs)
data, transform = merge(datasets, bounds=bounds, res=(1.0, 1.0), nodata=-999999)
profile = datasets[0].profile
profile.update(
{
"height": data.shape[1],
"width": data.shape[2],
"transform": transform,
}
)
with rasterio.open(output_tiff, "w", **profile) as output:
output.write(data)
metadata = {
"tile_id": TARGET_TILE_ID,
"source_tiffs": [str(path.relative_to(PROJECT_ROOT)) for path in source_tiffs],
"output_tiff": str(output_tiff.relative_to(PROJECT_ROOT)),
"source_crs": source_crs,
"source_bounds": source_bounds,
"subset_bounds_utm_m": list(bounds),
"subset_width_pixels": int(data.shape[2]),
"subset_height_pixels": int(data.shape[1]),
"pixel_size_x": abs(transform.a),
"pixel_size_y": abs(transform.e),
}
output_metadata.write_text(json.dumps(metadata, indent=2) + "\n")
finally:
for dataset in datasets:
dataset.close()
print(f"Subset GeoTIFF: {output_tiff}")
print(f"Metadata: {output_metadata}")
def main() -> None:
tile = load_ground_zero()
source_tiffs = find_source_tiffs(tile)
try:
extract_with_rasterio(source_tiffs, tile)
except ModuleNotFoundError as exc:
raise SystemExit(
"Missing rasterio. Install rasterio or GDAL Python bindings, then rerun this script."
) from exc
if __name__ == "__main__":
main()
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""Generate a first real-elevation heightmap for the Ground Zero terrain tile.
This prototype intentionally uses only the Python standard library so it can run
on Ubuntu-Codex without extra GIS packages. It samples USGS EPQS elevations over
the selected 1 km UTM tile, writes CSV samples, writes a little-endian R16
heightmap, and records generation metadata.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import struct
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = PROJECT_ROOT / "Data" / "Tiles" / "ground_zero_tiles.json"
OUTPUT_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Generated"
USGS_EPQS_URL = "https://epqs.nationalmap.gov/v1/json"
TARGET_TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160"
def utm_to_lat_lon(easting: float, northing: float, zone_number: int, northern: bool = True) -> tuple[float, float]:
"""Convert UTM WGS84 coordinates to latitude/longitude in degrees."""
a = 6378137.0
ecc_squared = 0.0066943799901413165
k0 = 0.9996
x = easting - 500000.0
y = northing
if not northern:
y -= 10000000.0
lon_origin = (zone_number - 1) * 6 - 180 + 3
ecc_prime_squared = ecc_squared / (1 - ecc_squared)
m = y / k0
mu = m / (a * (1 - ecc_squared / 4 - 3 * ecc_squared**2 / 64 - 5 * ecc_squared**3 / 256))
e1 = (1 - math.sqrt(1 - ecc_squared)) / (1 + math.sqrt(1 - ecc_squared))
j1 = 3 * e1 / 2 - 27 * e1**3 / 32
j2 = 21 * e1**2 / 16 - 55 * e1**4 / 32
j3 = 151 * e1**3 / 96
j4 = 1097 * e1**4 / 512
fp = mu + j1 * math.sin(2 * mu) + j2 * math.sin(4 * mu) + j3 * math.sin(6 * mu) + j4 * math.sin(8 * mu)
sin_fp = math.sin(fp)
cos_fp = math.cos(fp)
tan_fp = math.tan(fp)
c1 = ecc_prime_squared * cos_fp**2
t1 = tan_fp**2
n1 = a / math.sqrt(1 - ecc_squared * sin_fp**2)
r1 = a * (1 - ecc_squared) / ((1 - ecc_squared * sin_fp**2) ** 1.5)
d = x / (n1 * k0)
lat = fp - (n1 * tan_fp / r1) * (
d**2 / 2
- (5 + 3 * t1 + 10 * c1 - 4 * c1**2 - 9 * ecc_prime_squared) * d**4 / 24
+ (61 + 90 * t1 + 298 * c1 + 45 * t1**2 - 252 * ecc_prime_squared - 3 * c1**2) * d**6 / 720
)
lon = math.radians(lon_origin) + (
d
- (1 + 2 * t1 + c1) * d**3 / 6
+ (5 - 2 * c1 + 28 * t1 - 3 * c1**2 + 8 * ecc_prime_squared + 24 * t1**2) * d**5 / 120
) / cos_fp
return math.degrees(lat), math.degrees(lon)
def query_elevation(latitude: float, longitude: float, retries: int = 3) -> dict:
params = urllib.parse.urlencode({"x": f"{longitude:.8f}", "y": f"{latitude:.8f}", "units": "Meters"})
url = f"{USGS_EPQS_URL}?{params}"
last_error = None
for attempt in range(retries):
try:
with urllib.request.urlopen(url, timeout=20) as response:
payload = json.loads(response.read().decode("utf-8"))
return {
"elevation_m": float(payload["value"]),
"raster_id": payload.get("rasterId"),
"resolution_m": payload.get("resolution"),
"query_url": url,
}
except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as exc:
last_error = exc
time.sleep(0.5 * (attempt + 1))
raise RuntimeError(f"USGS elevation query failed for {latitude}, {longitude}: {last_error}")
def load_ground_zero_tile() -> dict:
registry = json.loads(REGISTRY_PATH.read_text())
for tile in registry["tiles"]:
if tile["tile_id"] == TARGET_TILE_ID:
return tile
raise RuntimeError(f"Could not find {TARGET_TILE_ID} in {REGISTRY_PATH}")
def build_sample_points(tile: dict, grid_size: int) -> list[dict]:
grid = tile["grid"]
e_min = grid["easting_min_m"]
n_min = grid["northing_min_m"]
spacing = grid["tile_size_m"] / (grid_size - 1)
points = []
for row in range(grid_size):
northing = n_min + row * spacing
for col in range(grid_size):
easting = e_min + col * spacing
latitude, longitude = utm_to_lat_lon(easting, northing, 10, True)
points.append(
{
"row": row,
"col": col,
"easting_m": easting,
"northing_m": northing,
"latitude": latitude,
"longitude": longitude,
}
)
return points
def sample_elevations(points: list[dict], workers: int) -> list[dict]:
sampled = []
with ThreadPoolExecutor(max_workers=workers) as executor:
future_to_point = {
executor.submit(query_elevation, point["latitude"], point["longitude"]): point for point in points
}
for future in as_completed(future_to_point):
point = future_to_point[future]
elevation = future.result()
point.update(elevation)
sampled.append(point)
return sorted(sampled, key=lambda item: (item["row"], item["col"]))
def write_csv(samples: list[dict], output_path: Path) -> None:
fieldnames = [
"row",
"col",
"easting_m",
"northing_m",
"latitude",
"longitude",
"elevation_m",
"raster_id",
"resolution_m",
]
with output_path.open("w", newline="") as output_file:
writer = csv.DictWriter(output_file, fieldnames=fieldnames)
writer.writeheader()
for sample in samples:
writer.writerow({field: sample.get(field, "") for field in fieldnames})
def write_r16(samples: list[dict], output_path: Path) -> tuple[float, float]:
elevations = [sample["elevation_m"] for sample in samples]
min_elevation = min(elevations)
max_elevation = max(elevations)
elevation_range = max(max_elevation - min_elevation, 0.001)
with output_path.open("wb") as output_file:
for sample in samples:
normalized = (sample["elevation_m"] - min_elevation) / elevation_range
output_file.write(struct.pack("<H", round(normalized * 65535)))
return min_elevation, max_elevation
def write_metadata(tile: dict, samples: list[dict], grid_size: int, min_elevation: float, max_elevation: float, output_path: Path) -> None:
raster_ids = sorted({sample["raster_id"] for sample in samples if sample.get("raster_id") is not None})
resolutions = sorted({sample["resolution_m"] for sample in samples if sample.get("resolution_m") is not None})
metadata = {
"tile_id": tile["tile_id"],
"generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"source": {
"name": "USGS Elevation Point Query Service",
"url": USGS_EPQS_URL,
"units": "Meters",
"raster_ids": raster_ids,
"reported_resolutions_m": resolutions,
},
"grid": tile["grid"],
"sample_grid": {
"width": grid_size,
"height": grid_size,
"spacing_m": tile["grid"]["tile_size_m"] / (grid_size - 1),
"sample_count": len(samples),
},
"heightmap": {
"format": "r16_little_endian_unsigned",
"min_elevation_m": min_elevation,
"max_elevation_m": max_elevation,
"vertical_range_m": max_elevation - min_elevation,
},
"unreal_import_notes": [
"Prototype only; final landscape import should use a higher-resolution DEM raster or lidar-derived terrain.",
"R16 values are normalized from min_elevation_m to max_elevation_m.",
"Use the metadata min/max values to restore vertical scale during Unreal import.",
"Horizontal tile size is 1000 m x 1000 m."
],
}
output_path.write_text(json.dumps(metadata, indent=2) + "\n")
def update_registry(tile_id: str) -> None:
registry = json.loads(REGISTRY_PATH.read_text())
for tile in registry["tiles"]:
if tile["tile_id"] == tile_id:
tile["status"] = "generated"
tile["generation_version"] = max(tile.get("generation_version", 0), 1)
tile["package_version"] = max(tile.get("package_version", 0), 0)
tile["notes"] = (
"Prototype USGS EPQS elevation heightmap generated. "
"Use Data/Terrain/Generated/gz_us_ca_pacifica_utm10n_e544_n4160 for samples, R16 heightmap, and metadata."
)
break
else:
raise RuntimeError(f"Could not update missing tile {tile_id}")
REGISTRY_PATH.write_text(json.dumps(registry, indent=2) + "\n")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--grid-size", type=int, default=33, help="Sample grid width/height. Default: 33")
parser.add_argument("--workers", type=int, default=8, help="Concurrent USGS requests. Default: 8")
args = parser.parse_args()
if args.grid_size < 2:
raise SystemExit("--grid-size must be at least 2")
tile = load_ground_zero_tile()
points = build_sample_points(tile, args.grid_size)
output_dir = OUTPUT_ROOT / tile["tile_id"]
output_dir.mkdir(parents=True, exist_ok=True)
samples = sample_elevations(points, args.workers)
csv_path = output_dir / f"{tile['tile_id']}_elevation_samples_{args.grid_size}.csv"
r16_path = output_dir / f"{tile['tile_id']}_heightmap_{args.grid_size}.r16"
metadata_path = output_dir / f"{tile['tile_id']}_terrain_metadata.json"
write_csv(samples, csv_path)
min_elevation, max_elevation = write_r16(samples, r16_path)
write_metadata(tile, samples, args.grid_size, min_elevation, max_elevation, metadata_path)
update_registry(tile["tile_id"])
print(f"Generated {len(samples)} samples for {tile['tile_id']}")
print(f"Elevation range: {min_elevation:.3f}m to {max_elevation:.3f}m")
print(f"CSV: {csv_path}")
print(f"R16: {r16_path}")
print(f"Metadata: {metadata_path}")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
import json
from pathlib import Path
import unreal
MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test"
PROJECT_ROOT = Path(r"Z:\AgrarianGameBulid")
TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160"
UNREAL_TERRAIN_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Unreal" / TILE_ID
HEIGHTMAP_PATH = UNREAL_TERRAIN_ROOT / f"{TILE_ID}_unreal_1009.r16"
METADATA_PATH = UNREAL_TERRAIN_ROOT / f"{TILE_ID}_unreal_heightmap_metadata.json"
LANDSCAPE_LABEL = "AGR_GroundZero_Landscape"
def get_actor_label(actor):
try:
return actor.get_actor_label()
except Exception:
return actor.get_name()
def remove_existing_landscape():
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if get_actor_label(actor) == LANDSCAPE_LABEL:
unreal.EditorLevelLibrary.destroy_actor(actor)
def create_or_load_map():
unreal.EditorAssetLibrary.make_directory("/Game/Agrarian/Maps")
if unreal.EditorAssetLibrary.does_asset_exist(MAP_PATH):
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
raise RuntimeError(f"Could not load map: {MAP_PATH}")
return
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
if hasattr(level_subsystem, "new_level"):
if not level_subsystem.new_level(MAP_PATH):
raise RuntimeError(f"Could not create map: {MAP_PATH}")
return
if hasattr(unreal.EditorLevelLibrary, "new_level"):
if not unreal.EditorLevelLibrary.new_level(MAP_PATH):
raise RuntimeError(f"Could not create map: {MAP_PATH}")
return
raise RuntimeError("No supported Unreal Python API found for creating a level")
def main():
create_or_load_map()
remove_existing_landscape()
with METADATA_PATH.open("r", encoding="utf-8") as metadata_file:
metadata = json.load(metadata_file)
import_settings = metadata["unreal_landscape_import"]
heightmap = metadata["heightmap"]
result = unreal.AgrarianEditorAutomationLibrary.import_landscape_heightmap_into_editor_world(
str(HEIGHTMAP_PATH),
int(heightmap["width"]),
int(heightmap["height"]),
float(import_settings["x_scale_cm"]),
float(import_settings["y_scale_cm"]),
float(import_settings["z_scale_cm"]),
LANDSCAPE_LABEL,
)
if not str(result).startswith("PASS:"):
raise RuntimeError(str(result))
unreal.log(str(result))
unreal.EditorLevelLibrary.save_current_level()
unreal.log(f"Ground Zero terrain map saved: {MAP_PATH}")
main()
+164
View File
@@ -0,0 +1,164 @@
import unreal
ITEM_FOLDER = "/Game/Agrarian/DataAssets/Items"
ITEMS = [
{
"asset": "DA_Item_Wood",
"item_id": "wood",
"display_name": "Wood",
"description": "Usable branches, logs, and rough-cut timber for fires, tools, and early structures.",
"item_type": unreal.AgrarianItemType.RESOURCE,
"unit_weight": 1.0,
"max_stack_size": 99,
},
{
"asset": "DA_Item_Stone",
"item_id": "stone",
"display_name": "Stone",
"description": "Field stone suitable for crude tools, fire rings, and primitive construction.",
"item_type": unreal.AgrarianItemType.RESOURCE,
"unit_weight": 1.5,
"max_stack_size": 99,
},
{
"asset": "DA_Item_Fiber",
"item_id": "fiber",
"display_name": "Fiber",
"description": "Plant fiber that can be twisted, woven, or bound into survival gear and simple structures.",
"item_type": unreal.AgrarianItemType.RESOURCE,
"unit_weight": 0.1,
"max_stack_size": 99,
},
{
"asset": "DA_Item_Food",
"item_id": "food",
"display_name": "Food",
"description": "Foraged edible food that can stave off hunger in the earliest survival loop.",
"item_type": unreal.AgrarianItemType.FOOD,
"unit_weight": 0.3,
"max_stack_size": 50,
},
{
"asset": "DA_Item_Meat",
"item_id": "meat",
"display_name": "Meat",
"description": "Raw harvested meat that should be cooked or preserved before long-term use.",
"item_type": unreal.AgrarianItemType.FOOD,
"unit_weight": 0.5,
"max_stack_size": 50,
},
{
"asset": "DA_Item_Hide",
"item_id": "hide",
"display_name": "Hide",
"description": "Animal hide used for shelter, clothing, containers, and other early craft work.",
"item_type": unreal.AgrarianItemType.RESOURCE,
"unit_weight": 0.7,
"max_stack_size": 50,
},
{
"asset": "DA_Item_PrimitiveFrame",
"item_id": "primitive_frame",
"display_name": "Primitive Frame",
"description": "A lashed support frame used as the backbone for early shelters and simple buildables.",
"item_type": unreal.AgrarianItemType.STRUCTURE,
"unit_weight": 3.0,
"max_stack_size": 20,
},
{
"asset": "DA_Item_PrimitiveWallPanel",
"item_id": "primitive_wall_panel",
"display_name": "Primitive Wall Panel",
"description": "A crude wall panel built from wood and fiber for early shelter construction.",
"item_type": unreal.AgrarianItemType.STRUCTURE,
"unit_weight": 4.0,
"max_stack_size": 20,
},
{
"asset": "DA_Item_PrimitiveRoofPanel",
"item_id": "primitive_roof_panel",
"display_name": "Primitive Roof Panel",
"description": "A simple roof panel made from lashed branches, fiber, and cover material.",
"item_type": unreal.AgrarianItemType.STRUCTURE,
"unit_weight": 4.0,
"max_stack_size": 20,
},
{
"asset": "DA_Item_Campfire",
"item_id": "campfire",
"display_name": "Campfire",
"description": "A placeable fire ring for warmth, light, and early cooking.",
"item_type": unreal.AgrarianItemType.STRUCTURE,
"unit_weight": 12.0,
"max_stack_size": 5,
},
{
"asset": "DA_Item_PrimitiveShelter",
"item_id": "primitive_shelter",
"display_name": "Primitive Shelter",
"description": "A compact early shelter that provides basic weather protection.",
"item_type": unreal.AgrarianItemType.STRUCTURE,
"unit_weight": 25.0,
"max_stack_size": 1,
},
{
"asset": "DA_Item_BasicTool",
"item_id": "basic_tool",
"display_name": "Basic Tool",
"description": "A crude stone-and-wood tool for the first gather and build loops.",
"item_type": unreal.AgrarianItemType.TOOL,
"unit_weight": 1.2,
"max_stack_size": 10,
},
{
"asset": "DA_Item_Bandage",
"item_id": "bandage",
"display_name": "Bandage",
"description": "A simple treatment item made from hide and fiber.",
"item_type": unreal.AgrarianItemType.MEDICINE,
"unit_weight": 0.1,
"max_stack_size": 20,
},
]
def create_or_load_item_asset(asset_name):
path = f"{ITEM_FOLDER}/{asset_name}"
existing = unreal.EditorAssetLibrary.load_asset(path)
if existing:
return existing
factory = unreal.DataAssetFactory()
factory.set_editor_property("data_asset_class", unreal.AgrarianItemDefinitionAsset)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
asset = asset_tools.create_asset(asset_name, ITEM_FOLDER, unreal.AgrarianItemDefinitionAsset, factory)
if not asset:
raise RuntimeError(f"Could not create {path}")
return asset
def apply_definition(asset, item):
definition = unreal.AgrarianItemDefinition()
definition.set_editor_property("item_id", item["item_id"])
definition.set_editor_property("display_name", item["display_name"])
definition.set_editor_property("description", item["description"])
definition.set_editor_property("item_type", item["item_type"])
definition.set_editor_property("unit_weight", item["unit_weight"])
definition.set_editor_property("max_stack_size", item["max_stack_size"])
asset.set_editor_property("definition", definition)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
def main():
unreal.EditorAssetLibrary.make_directory(ITEM_FOLDER)
for item in ITEMS:
asset = create_or_load_item_asset(item["asset"])
apply_definition(asset, item)
unreal.log(f"Configured item definition: {item['item_id']} -> {ITEM_FOLDER}/{item['asset']}")
unreal.log("Agrarian item definition setup complete.")
main()
+166
View File
@@ -0,0 +1,166 @@
import unreal
BLUEPRINT_ROOT = "/Game/Agrarian/Blueprints"
RESOURCE_FOLDER = f"{BLUEPRINT_ROOT}/Resources"
STRUCTURE_FOLDER = f"{BLUEPRINT_ROOT}/Structures"
WILDLIFE_FOLDER = f"{BLUEPRINT_ROOT}/Wildlife"
WOOD_ITEM_PATH = "/Game/Agrarian/DataAssets/Items/DA_Item_Wood"
FIBER_ITEM_PATH = "/Game/Agrarian/DataAssets/Items/DA_Item_Fiber"
MESH_CUBE_PATH = "/Game/LevelPrototyping/Meshes/SM_Cube"
MESH_CYLINDER_PATH = "/Game/LevelPrototyping/Meshes/SM_Cylinder"
BLUEPRINTS = [
{
"asset": "BP_WoodResourceNode",
"folder": RESOURCE_FOLDER,
"parent": unreal.AgrarianResourceNode,
"defaults": {
"yield_item_definition": WOOD_ITEM_PATH,
"remaining_harvests": 16,
"quantity_per_harvest": 2,
},
"mesh": MESH_CUBE_PATH,
"scale": unreal.Vector(1.0, 1.0, 1.5),
},
{
"asset": "BP_FiberResourceNode",
"folder": RESOURCE_FOLDER,
"parent": unreal.AgrarianResourceNode,
"defaults": {
"yield_item_definition": FIBER_ITEM_PATH,
"remaining_harvests": 10,
"quantity_per_harvest": 3,
},
"mesh": MESH_CYLINDER_PATH,
"scale": unreal.Vector(0.8, 0.8, 1.0),
},
{
"asset": "BP_Campfire",
"folder": STRUCTURE_FOLDER,
"parent": unreal.AgrarianCampfire,
"defaults": {
"fuel_seconds": 180.0,
"warmth_radius": 650.0,
"warmth_per_second": 0.03,
},
"mesh": MESH_CYLINDER_PATH,
"scale": unreal.Vector(1.3, 1.3, 0.25),
},
{
"asset": "BP_PrimitiveShelter",
"folder": STRUCTURE_FOLDER,
"parent": unreal.AgrarianShelterActor,
"defaults": {
"weather_protection": 0.7,
},
"mesh": MESH_CUBE_PATH,
"scale": unreal.Vector(3.0, 2.0, 1.4),
},
{
"asset": "BP_RabbitWildlife",
"folder": WILDLIFE_FOLDER,
"parent": unreal.AgrarianWildlifeBase,
"defaults": {
"wildlife_id": "rabbit",
"display_name": "Rabbit",
"max_health": 12.0,
"health": 12.0,
"wander_radius": 900.0,
"wander_speed": 160.0,
"flee_speed": 520.0,
"aggro_radius": 0.0,
"flee_radius": 750.0,
"decision_interval_seconds": 1.5,
"harvest_yields": [
{
"item_id": "meat",
"display_name": "Meat",
"quantity": 1,
"unit_weight": 0.5,
},
{
"item_id": "hide",
"display_name": "Hide",
"quantity": 2,
"unit_weight": 0.7,
},
],
},
},
]
def load_required_asset(path):
asset = unreal.EditorAssetLibrary.load_asset(path)
if not asset:
raise RuntimeError(f"Required asset not found: {path}")
return asset
def create_or_load_blueprint(asset_name, folder, parent_class):
path = f"{folder}/{asset_name}"
existing = unreal.EditorAssetLibrary.load_asset(path)
if existing:
return existing
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", parent_class)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
blueprint = asset_tools.create_asset(asset_name, folder, None, factory)
if not blueprint:
raise RuntimeError(f"Could not create {path}")
return blueprint
def make_stack(data):
stack = unreal.AgrarianItemStack()
stack.set_editor_property("item_id", data["item_id"])
stack.set_editor_property("display_name", data["display_name"])
stack.set_editor_property("quantity", data["quantity"])
stack.set_editor_property("unit_weight", data["unit_weight"])
return stack
def apply_defaults(blueprint, config):
blueprint_path = f"{config['folder']}/{config['asset']}"
unreal.BlueprintEditorLibrary.compile_blueprint(blueprint)
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(blueprint_path)
if not generated_class:
raise RuntimeError(f"Could not load generated class for {blueprint_path}")
cdo = unreal.get_default_object(generated_class)
for property_name, value in config.get("defaults", {}).items():
if property_name == "yield_item_definition":
value = load_required_asset(value)
elif property_name == "harvest_yields":
value = [make_stack(stack_data) for stack_data in value]
cdo.set_editor_property(property_name, value)
mesh_path = config.get("mesh")
if mesh_path:
mesh_component = cdo.get_editor_property("mesh")
mesh_component.set_editor_property("static_mesh", load_required_asset(mesh_path))
mesh_component.set_editor_property("relative_scale3d", config.get("scale", unreal.Vector(1.0, 1.0, 1.0)))
unreal.BlueprintEditorLibrary.compile_blueprint(blueprint)
unreal.EditorAssetLibrary.save_loaded_asset(blueprint)
def main():
for folder in (RESOURCE_FOLDER, STRUCTURE_FOLDER, WILDLIFE_FOLDER):
unreal.EditorAssetLibrary.make_directory(folder)
for config in BLUEPRINTS:
blueprint = create_or_load_blueprint(config["asset"], config["folder"], config["parent"])
apply_defaults(blueprint, config)
unreal.log(f"Configured Blueprint: {config['folder']}/{config['asset']}")
unreal.log("Agrarian playable Blueprint setup complete.")
main()
+156
View File
@@ -0,0 +1,156 @@
import unreal
RECIPE_FOLDER = "/Game/Agrarian/DataAssets/Recipes"
ITEM_DISPLAY = {
"wood": ("Wood", 1.0),
"stone": ("Stone", 1.5),
"fiber": ("Fiber", 0.1),
"hide": ("Hide", 0.7),
"primitive_frame": ("Primitive Frame", 3.0),
"primitive_wall_panel": ("Primitive Wall Panel", 4.0),
"primitive_roof_panel": ("Primitive Roof Panel", 4.0),
"campfire": ("Campfire", 12.0),
"primitive_shelter": ("Primitive Shelter", 25.0),
"basic_tool": ("Basic Tool", 1.2),
"bandage": ("Bandage", 0.1),
}
RECIPES = [
{
"asset": "DA_Recipe_Campfire",
"recipe_id": "campfire",
"display_name": "Campfire",
"ingredients": [("wood", 5), ("stone", 8), ("fiber", 2)],
"result": ("campfire", 1),
"craft_seconds": 8.0,
"requires_campfire": False,
},
{
"asset": "DA_Recipe_PrimitiveShelter",
"recipe_id": "primitive_shelter",
"display_name": "Primitive Shelter",
"ingredients": [
("primitive_frame", 2),
("primitive_wall_panel", 4),
("primitive_roof_panel", 2),
("hide", 2),
("fiber", 6),
],
"result": ("primitive_shelter", 1),
"craft_seconds": 20.0,
"requires_campfire": False,
},
{
"asset": "DA_Recipe_PrimitiveFrame",
"recipe_id": "primitive_frame",
"display_name": "Primitive Frame",
"ingredients": [("wood", 4), ("fiber", 2)],
"result": ("primitive_frame", 1),
"craft_seconds": 8.0,
"requires_campfire": False,
},
{
"asset": "DA_Recipe_PrimitiveWallPanel",
"recipe_id": "primitive_wall_panel",
"display_name": "Primitive Wall Panel",
"ingredients": [("wood", 3), ("fiber", 2)],
"result": ("primitive_wall_panel", 1),
"craft_seconds": 6.0,
"requires_campfire": False,
},
{
"asset": "DA_Recipe_PrimitiveRoofPanel",
"recipe_id": "primitive_roof_panel",
"display_name": "Primitive Roof Panel",
"ingredients": [("wood", 3), ("fiber", 3)],
"result": ("primitive_roof_panel", 1),
"craft_seconds": 7.0,
"requires_campfire": False,
},
{
"asset": "DA_Recipe_BasicTool",
"recipe_id": "basic_tool",
"display_name": "Basic Tool",
"ingredients": [("wood", 1), ("stone", 2), ("fiber", 1)],
"result": ("basic_tool", 1),
"craft_seconds": 6.0,
"requires_campfire": False,
},
{
"asset": "DA_Recipe_Bandage",
"recipe_id": "bandage",
"display_name": "Bandage",
"ingredients": [("fiber", 3), ("hide", 1)],
"result": ("bandage", 1),
"craft_seconds": 4.0,
"requires_campfire": False,
},
]
def make_stack(item_id, quantity):
display_name, unit_weight = ITEM_DISPLAY[item_id]
stack = unreal.AgrarianItemStack()
stack.set_editor_property("item_id", item_id)
stack.set_editor_property("display_name", display_name)
stack.set_editor_property("quantity", quantity)
stack.set_editor_property("unit_weight", unit_weight)
return stack
def set_requires_campfire(recipe, value):
for property_name in ("requires_campfire", "b_requires_campfire"):
try:
recipe.set_editor_property(property_name, value)
return
except Exception:
continue
if value:
raise RuntimeError("Could not set bRequiresCampfire on recipe asset")
def create_or_load_recipe_asset(asset_name):
path = f"{RECIPE_FOLDER}/{asset_name}"
existing = unreal.EditorAssetLibrary.load_asset(path)
if existing:
return existing
factory = unreal.DataAssetFactory()
factory.set_editor_property("data_asset_class", unreal.AgrarianRecipeDataAsset)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
asset = asset_tools.create_asset(asset_name, RECIPE_FOLDER, unreal.AgrarianRecipeDataAsset, factory)
if not asset:
raise RuntimeError(f"Could not create {path}")
return asset
def apply_recipe(asset, recipe_data):
recipe = unreal.AgrarianRecipe()
recipe.set_editor_property("recipe_id", recipe_data["recipe_id"])
recipe.set_editor_property("display_name", recipe_data["display_name"])
recipe.set_editor_property(
"ingredients",
[make_stack(item_id, quantity) for item_id, quantity in recipe_data["ingredients"]],
)
result_id, result_quantity = recipe_data["result"]
recipe.set_editor_property("result", make_stack(result_id, result_quantity))
recipe.set_editor_property("craft_seconds", recipe_data["craft_seconds"])
set_requires_campfire(recipe, recipe_data["requires_campfire"])
asset.set_editor_property("recipe", recipe)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
def main():
unreal.EditorAssetLibrary.make_directory(RECIPE_FOLDER)
for recipe_data in RECIPES:
asset = create_or_load_recipe_asset(recipe_data["asset"])
apply_recipe(asset, recipe_data)
unreal.log(f"Configured recipe: {recipe_data['recipe_id']} -> {RECIPE_FOLDER}/{recipe_data['asset']}")
unreal.log("Agrarian recipe setup complete.")
main()
+6
View File
@@ -10,6 +10,12 @@ PLACEMENTS = [
"location": unreal.Vector(650.0, -150.0, 120.0),
"rotation": unreal.Rotator(0.0, 15.0, 0.0),
},
{
"label": "AGR_FiberResourceNode_01",
"class_path": "/Game/Agrarian/Blueprints/Resources/BP_FiberResourceNode",
"location": unreal.Vector(560.0, 140.0, 90.0),
"rotation": unreal.Rotator(0.0, -10.0, 0.0),
},
{
"label": "AGR_Campfire_01",
"class_path": "/Game/Agrarian/Blueprints/Structures/BP_Campfire",
+66
View File
@@ -0,0 +1,66 @@
import json
from pathlib import Path
import unreal
MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test"
PROJECT_ROOT = Path(r"Z:\AgrarianGameBulid")
TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160"
METADATA_PATH = PROJECT_ROOT / "Data" / "Terrain" / "Unreal" / TILE_ID / f"{TILE_ID}_unreal_heightmap_metadata.json"
LANDSCAPE_LABEL = "AGR_GroundZero_Landscape"
def nearly_equal(left, right, tolerance=0.01):
return abs(float(left) - float(right)) <= tolerance
def get_actor_label(actor):
try:
return actor.get_actor_label()
except Exception:
return actor.get_name()
def main():
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
raise RuntimeError(f"Could not load map: {MAP_PATH}")
with METADATA_PATH.open("r", encoding="utf-8") as metadata_file:
metadata = json.load(metadata_file)
expected = metadata["unreal_landscape_import"]
actors = unreal.EditorLevelLibrary.get_all_level_actors()
landscapes = [actor for actor in actors if get_actor_label(actor) == LANDSCAPE_LABEL]
if len(landscapes) != 1:
raise RuntimeError(f"Expected exactly one {LANDSCAPE_LABEL}, found {len(landscapes)}")
landscape = landscapes[0]
scale = landscape.get_actor_scale3d()
failures = []
if not nearly_equal(scale.x, expected["x_scale_cm"]):
failures.append(f"X scale expected {expected['x_scale_cm']}, got {scale.x}")
if not nearly_equal(scale.y, expected["y_scale_cm"]):
failures.append(f"Y scale expected {expected['y_scale_cm']}, got {scale.y}")
if not nearly_equal(scale.z, expected["z_scale_cm"]):
failures.append(f"Z scale expected {expected['z_scale_cm']}, got {scale.z}")
bounds_origin, bounds_extent = landscape.get_actor_bounds(False)
expected_extent = float(expected["tile_world_size_m"]) * 100.0 * 0.5
if not nearly_equal(bounds_extent.x, expected_extent, tolerance=150.0):
failures.append(f"X extent expected about {expected_extent}, got {bounds_extent.x}")
if not nearly_equal(bounds_extent.y, expected_extent, tolerance=150.0):
failures.append(f"Y extent expected about {expected_extent}, got {bounds_extent.y}")
if abs(bounds_origin.x) > 150.0 or abs(bounds_origin.y) > 150.0:
failures.append(f"Bounds origin expected near XY zero, got {bounds_origin}")
if failures:
raise RuntimeError("Ground Zero terrain verification failed: " + "; ".join(failures))
unreal.log(
"Ground Zero terrain verification complete: "
f"scale={scale}, bounds_origin={bounds_origin}, bounds_extent={bounds_extent}"
)
main()
+60
View File
@@ -0,0 +1,60 @@
import unreal
ITEM_FOLDER = "/Game/Agrarian/DataAssets/Items"
EXPECTED_ITEMS = {
"DA_Item_Wood": ("wood", unreal.AgrarianItemType.RESOURCE),
"DA_Item_Stone": ("stone", unreal.AgrarianItemType.RESOURCE),
"DA_Item_Fiber": ("fiber", unreal.AgrarianItemType.RESOURCE),
"DA_Item_Food": ("food", unreal.AgrarianItemType.FOOD),
"DA_Item_Meat": ("meat", unreal.AgrarianItemType.FOOD),
"DA_Item_Hide": ("hide", unreal.AgrarianItemType.RESOURCE),
"DA_Item_PrimitiveFrame": ("primitive_frame", unreal.AgrarianItemType.STRUCTURE),
"DA_Item_PrimitiveWallPanel": ("primitive_wall_panel", unreal.AgrarianItemType.STRUCTURE),
"DA_Item_PrimitiveRoofPanel": ("primitive_roof_panel", unreal.AgrarianItemType.STRUCTURE),
"DA_Item_Campfire": ("campfire", unreal.AgrarianItemType.STRUCTURE),
"DA_Item_PrimitiveShelter": ("primitive_shelter", unreal.AgrarianItemType.STRUCTURE),
"DA_Item_BasicTool": ("basic_tool", unreal.AgrarianItemType.TOOL),
"DA_Item_Bandage": ("bandage", unreal.AgrarianItemType.MEDICINE),
}
def main():
missing = []
for asset_name, (expected_item_id, expected_type) in EXPECTED_ITEMS.items():
path = f"{ITEM_FOLDER}/{asset_name}"
asset = unreal.EditorAssetLibrary.load_asset(path)
if not asset:
missing.append(f"{path} missing")
continue
definition = asset.get_editor_property("definition")
item_id = str(definition.get_editor_property("item_id"))
display_name = str(definition.get_editor_property("display_name"))
description = str(definition.get_editor_property("description"))
item_type = definition.get_editor_property("item_type")
unit_weight = definition.get_editor_property("unit_weight")
max_stack_size = definition.get_editor_property("max_stack_size")
if item_id != expected_item_id:
missing.append(f"{path} item_id expected {expected_item_id}, got {item_id}")
if item_type != expected_type:
missing.append(f"{path} type expected {expected_type}, got {item_type}")
if not display_name:
missing.append(f"{path} display_name empty")
if not description:
missing.append(f"{path} description empty")
if unit_weight <= 0.0:
missing.append(f"{path} unit_weight must be positive")
if max_stack_size < 1:
missing.append(f"{path} max_stack_size must be positive")
if missing:
raise RuntimeError("Item definition verification failed: " + "; ".join(missing))
unreal.log("Agrarian item definition verification complete.")
main()
+95
View File
@@ -0,0 +1,95 @@
import unreal
EXPECTED = {
"/Game/Agrarian/Blueprints/Resources/BP_WoodResourceNode": {
"properties": {
"remaining_harvests": 16,
"quantity_per_harvest": 2,
},
"yield_item_id": "wood",
},
"/Game/Agrarian/Blueprints/Resources/BP_FiberResourceNode": {
"properties": {
"remaining_harvests": 10,
"quantity_per_harvest": 3,
},
"yield_item_id": "fiber",
},
"/Game/Agrarian/Blueprints/Structures/BP_Campfire": {
"properties": {
"fuel_seconds": 180.0,
"warmth_radius": 650.0,
"warmth_per_second": 0.03,
},
},
"/Game/Agrarian/Blueprints/Structures/BP_PrimitiveShelter": {
"properties": {
"weather_protection": 0.7,
},
},
"/Game/Agrarian/Blueprints/Wildlife/BP_RabbitWildlife": {
"properties": {
"wildlife_id": "rabbit",
"max_health": 12.0,
"health": 12.0,
"wander_radius": 900.0,
"wander_speed": 160.0,
"flee_speed": 520.0,
"aggro_radius": 0.0,
"flee_radius": 750.0,
"decision_interval_seconds": 1.5,
},
"harvest_yield_ids": ["meat", "hide"],
},
}
def nearly_equal(left, right):
if isinstance(left, float) or isinstance(right, float):
return abs(float(left) - float(right)) < 0.001
return left == right
def main():
failures = []
for path, expected in EXPECTED.items():
blueprint = unreal.EditorAssetLibrary.load_asset(path)
if not blueprint:
failures.append(f"{path} missing")
continue
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(path)
if not generated_class:
failures.append(f"{path} parent class mismatch")
continue
cdo = unreal.get_default_object(generated_class)
for property_name, expected_value in expected.get("properties", {}).items():
actual_value = cdo.get_editor_property(property_name)
if str(actual_value) != expected_value and not nearly_equal(actual_value, expected_value):
failures.append(f"{path} {property_name} expected {expected_value}, got {actual_value}")
expected_yield_item_id = expected.get("yield_item_id")
if expected_yield_item_id:
item_asset = cdo.get_editor_property("yield_item_definition")
definition = item_asset.get_editor_property("definition") if item_asset else None
item_id = str(definition.get_editor_property("item_id")) if definition else ""
if item_id != expected_yield_item_id:
failures.append(f"{path} yield_item_definition expected {expected_yield_item_id}, got {item_id}")
expected_harvest_ids = expected.get("harvest_yield_ids")
if expected_harvest_ids:
harvest_yields = cdo.get_editor_property("harvest_yields")
actual_ids = [str(stack.get_editor_property("item_id")) for stack in harvest_yields]
if actual_ids != expected_harvest_ids:
failures.append(f"{path} harvest_yields expected {expected_harvest_ids}, got {actual_ids}")
if failures:
raise RuntimeError("Playable Blueprint verification failed: " + "; ".join(failures))
unreal.log("Agrarian playable Blueprint verification complete.")
main()
+84
View File
@@ -0,0 +1,84 @@
import unreal
MAP_PATH = "/Game/ThirdPerson/Lvl_ThirdPerson"
CHARACTER_CLASS_PATH = "/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter"
SHELTER_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveShelter"
FRAME_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveFrame"
WALL_PANEL_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveWallPanel"
ROOF_PANEL_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveRoofPanel"
SHELTER_CLASS_PATH = "/Game/Agrarian/Blueprints/Structures/BP_PrimitiveShelter"
WOOD_NODE_LABEL = "AGR_WoodResourceNode_01"
FIBER_NODE_LABEL = "AGR_FiberResourceNode_01"
RABBIT_LABEL = "AGR_RabbitWildlife_01"
def get_actor_label(actor):
try:
return actor.get_actor_label()
except Exception:
return actor.get_name()
def load_blueprint_class(path):
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(path)
if not generated_class:
raise RuntimeError(f"Could not load Blueprint class: {path}")
return generated_class
def find_actor_by_label(label):
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if get_actor_label(actor) == label:
return actor
return None
def main():
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
raise RuntimeError(f"Could not load map: {MAP_PATH}")
character_class = load_blueprint_class(CHARACTER_CLASS_PATH)
shelter_class = load_blueprint_class(SHELTER_CLASS_PATH)
shelter_recipe = unreal.EditorAssetLibrary.load_asset(SHELTER_RECIPE_PATH)
if not shelter_recipe:
raise RuntimeError(f"Could not load shelter recipe: {SHELTER_RECIPE_PATH}")
frame_recipe = unreal.EditorAssetLibrary.load_asset(FRAME_RECIPE_PATH)
if not frame_recipe:
raise RuntimeError(f"Could not load frame recipe: {FRAME_RECIPE_PATH}")
wall_panel_recipe = unreal.EditorAssetLibrary.load_asset(WALL_PANEL_RECIPE_PATH)
if not wall_panel_recipe:
raise RuntimeError(f"Could not load wall panel recipe: {WALL_PANEL_RECIPE_PATH}")
roof_panel_recipe = unreal.EditorAssetLibrary.load_asset(ROOF_PANEL_RECIPE_PATH)
if not roof_panel_recipe:
raise RuntimeError(f"Could not load roof panel recipe: {ROOF_PANEL_RECIPE_PATH}")
wood_node = find_actor_by_label(WOOD_NODE_LABEL)
if not wood_node:
raise RuntimeError(f"Could not find placed wood node: {WOOD_NODE_LABEL}")
fiber_node = find_actor_by_label(FIBER_NODE_LABEL)
if not fiber_node:
raise RuntimeError(f"Could not find placed fiber node: {FIBER_NODE_LABEL}")
rabbit = find_actor_by_label(RABBIT_LABEL)
if not rabbit:
raise RuntimeError(f"Could not find placed rabbit wildlife: {RABBIT_LABEL}")
result = unreal.AgrarianEditorAutomationLibrary.run_natural_shelter_loop_smoke_test(
character_class,
wood_node,
fiber_node,
rabbit,
frame_recipe,
wall_panel_recipe,
roof_panel_recipe,
shelter_recipe,
shelter_class,
)
unreal.log(result)
if not str(result).startswith("PASS:"):
raise RuntimeError(result)
unreal.log("Agrarian playable loop smoke verification complete.")
main()
+108
View File
@@ -0,0 +1,108 @@
import unreal
RECIPE_FOLDER = "/Game/Agrarian/DataAssets/Recipes"
EXPECTED_RECIPES = {
"DA_Recipe_Campfire": {
"recipe_id": "campfire",
"ingredients": {"wood": 5, "stone": 8, "fiber": 2},
"result": ("campfire", 1),
},
"DA_Recipe_PrimitiveShelter": {
"recipe_id": "primitive_shelter",
"ingredients": {
"primitive_frame": 2,
"primitive_wall_panel": 4,
"primitive_roof_panel": 2,
"hide": 2,
"fiber": 6,
},
"result": ("primitive_shelter", 1),
},
"DA_Recipe_PrimitiveFrame": {
"recipe_id": "primitive_frame",
"ingredients": {"wood": 4, "fiber": 2},
"result": ("primitive_frame", 1),
},
"DA_Recipe_PrimitiveWallPanel": {
"recipe_id": "primitive_wall_panel",
"ingredients": {"wood": 3, "fiber": 2},
"result": ("primitive_wall_panel", 1),
},
"DA_Recipe_PrimitiveRoofPanel": {
"recipe_id": "primitive_roof_panel",
"ingredients": {"wood": 3, "fiber": 3},
"result": ("primitive_roof_panel", 1),
},
"DA_Recipe_BasicTool": {
"recipe_id": "basic_tool",
"ingredients": {"wood": 1, "stone": 2, "fiber": 1},
"result": ("basic_tool", 1),
},
"DA_Recipe_Bandage": {
"recipe_id": "bandage",
"ingredients": {"fiber": 3, "hide": 1},
"result": ("bandage", 1),
},
}
def stack_item_id(stack):
return str(stack.get_editor_property("item_id"))
def main():
missing = []
for asset_name, expected in EXPECTED_RECIPES.items():
path = f"{RECIPE_FOLDER}/{asset_name}"
asset = unreal.EditorAssetLibrary.load_asset(path)
if not asset:
missing.append(f"{path} missing")
continue
recipe = asset.get_editor_property("recipe")
recipe_id = str(recipe.get_editor_property("recipe_id"))
display_name = str(recipe.get_editor_property("display_name"))
craft_seconds = recipe.get_editor_property("craft_seconds")
result = recipe.get_editor_property("result")
ingredients = list(recipe.get_editor_property("ingredients"))
if recipe_id != expected["recipe_id"]:
missing.append(f"{path} recipe_id expected {expected['recipe_id']}, got {recipe_id}")
if not display_name:
missing.append(f"{path} display_name empty")
if craft_seconds <= 0.0:
missing.append(f"{path} craft_seconds must be positive")
expected_result_id, expected_result_quantity = expected["result"]
if stack_item_id(result) != expected_result_id:
missing.append(f"{path} result id expected {expected_result_id}, got {stack_item_id(result)}")
if result.get_editor_property("quantity") != expected_result_quantity:
missing.append(f"{path} result quantity expected {expected_result_quantity}")
if result.get_editor_property("unit_weight") <= 0.0:
missing.append(f"{path} result unit weight must be positive")
actual_ingredients = {stack_item_id(stack): stack for stack in ingredients}
for expected_item_id, expected_quantity in expected["ingredients"].items():
stack = actual_ingredients.get(expected_item_id)
if not stack:
missing.append(f"{path} missing ingredient {expected_item_id}")
continue
if stack.get_editor_property("quantity") != expected_quantity:
missing.append(f"{path} ingredient {expected_item_id} expected quantity {expected_quantity}")
if stack.get_editor_property("unit_weight") <= 0.0:
missing.append(f"{path} ingredient {expected_item_id} unit weight must be positive")
unexpected = set(actual_ingredients.keys()) - set(expected["ingredients"].keys())
if unexpected:
missing.append(f"{path} has unexpected ingredients: {sorted(unexpected)}")
if missing:
raise RuntimeError("Recipe definition verification failed: " + "; ".join(missing))
unreal.log("Agrarian recipe definition verification complete.")
main()
+9 -1
View File
@@ -8,10 +8,18 @@ EXPECTED_PLACEMENTS = {
"class_path": "/Game/Agrarian/Blueprints/Resources/BP_WoodResourceNode",
"location": unreal.Vector(650.0, -150.0, 120.0),
"properties": {
"remaining_harvests": 6,
"remaining_harvests": 16,
"quantity_per_harvest": 2,
},
},
"AGR_FiberResourceNode_01": {
"class_path": "/Game/Agrarian/Blueprints/Resources/BP_FiberResourceNode",
"location": unreal.Vector(560.0, 140.0, 90.0),
"properties": {
"remaining_harvests": 10,
"quantity_per_harvest": 3,
},
},
"AGR_Campfire_01": {
"class_path": "/Game/Agrarian/Blueprints/Structures/BP_Campfire",
"location": unreal.Vector(900.0, 120.0, 60.0),
+110
View File
@@ -0,0 +1,110 @@
import unreal
MAP_PATH = "/Game/ThirdPerson/Lvl_ThirdPerson"
CHARACTER_CLASS_PATH = "/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter"
RABBIT_LABEL = "AGR_RabbitWildlife_01"
def get_actor_label(actor):
try:
return actor.get_actor_label()
except Exception:
return actor.get_name()
def load_blueprint_class(path):
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(path)
if not generated_class:
raise RuntimeError(f"Could not load Blueprint class: {path}")
return generated_class
def find_actor_by_label(label):
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if get_actor_label(actor) == label:
return actor
return None
def enum_name(value):
return str(value).split(".")[-1].lower()
def get_bool_property(actor, *property_names):
for property_name in property_names:
try:
return bool(actor.get_editor_property(property_name))
except Exception:
continue
raise RuntimeError(f"Could not read any bool property from {property_names}")
def main():
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
raise RuntimeError(f"Could not load map: {MAP_PATH}")
character_class = load_blueprint_class(CHARACTER_CLASS_PATH)
rabbit = find_actor_by_label(RABBIT_LABEL)
if not rabbit:
raise RuntimeError(f"Could not find placed rabbit wildlife: {RABBIT_LABEL}")
character = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world(
character_class,
unreal.Vector(500.0, 320.0, 180.0),
unreal.Rotator(0.0, 0.0, 0.0),
"AGR_AutomationWildlifeCharacter",
)
if not character:
raise RuntimeError("Could not spawn automation character")
inventory = character.get_component_by_class(unreal.AgrarianInventoryComponent)
if not inventory:
raise RuntimeError("Automation character is missing AgrarianInventoryComponent")
starting_health = float(rabbit.get_editor_property("health"))
max_health = float(rabbit.get_editor_property("max_health"))
if starting_health <= 0.0 or max_health <= 0.0:
raise RuntimeError(f"Rabbit starts invalid health={starting_health}, max_health={max_health}")
rabbit.apply_wildlife_damage(1.0, character)
damaged_health = float(rabbit.get_editor_property("health"))
damaged_state = enum_name(rabbit.get_editor_property("wildlife_state"))
if not damaged_health < starting_health:
raise RuntimeError(f"Rabbit non-lethal damage did not reduce health: {starting_health} -> {damaged_health}")
if "fleeing" not in damaged_state:
raise RuntimeError(f"Rabbit expected fleeing after non-lethal damage, got {damaged_state}")
rabbit.apply_wildlife_damage(max_health + 10.0, character)
dead_health = float(rabbit.get_editor_property("health"))
dead_state = enum_name(rabbit.get_editor_property("wildlife_state"))
if dead_health != 0.0:
raise RuntimeError(f"Rabbit lethal damage expected health 0, got {dead_health}")
if "dead" not in dead_state:
raise RuntimeError(f"Rabbit expected dead after lethal damage, got {dead_state}")
if not rabbit.can_interact(character):
raise RuntimeError("Rabbit should be harvestable after death")
meat_before = inventory.get_item_count("meat")
hide_before = inventory.get_item_count("hide")
rabbit.interact(character)
meat_after = inventory.get_item_count("meat")
hide_after = inventory.get_item_count("hide")
if meat_after <= meat_before:
raise RuntimeError(f"Rabbit harvest did not add meat: {meat_before} -> {meat_after}")
if hide_after <= hide_before:
raise RuntimeError(f"Rabbit harvest did not add hide: {hide_before} -> {hide_after}")
if not get_bool_property(rabbit, "b_harvested", "harvested"):
raise RuntimeError("Rabbit harvest did not set bHarvested")
if rabbit.can_interact(character):
raise RuntimeError("Rabbit should not be harvestable twice")
unreal.EditorLevelLibrary.destroy_actor(character)
unreal.log(
"PASS: wildlife damage/death/harvest verified "
f"health {starting_health}->{damaged_health}->0, "
f"meat {meat_before}->{meat_after}, hide {hide_before}->{hide_after}"
)
main()