Make RustRaptor the repository root and track its playable bundle
This commit is contained in:
1 parent
4b1edefeec
commit
8f459796b1
9860 files changed
+21084
-61019
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# Saltbreaker
|
||||
|
||||
Standalone API 1 mod, version 1.0.0, with no dependencies. Saltworks Convoy requires this package and references `iron_coast_boss_3:unit` and the death objective `iron_coast_boss_3:defeated`.
|
||||
|
||||
The armored twin-fan escort enters vertically, then patrols between x=80 and x=144 at y=24. Its logical canvas remains 96×64 in SD and HD. It has 160 health and pays 1,800 credits when destroyed. Every 54 simulation steps after arrival, one nose cannon fires two three-damage shells. The cannons alternate and each warning sounds nine steps before firing. Shells move four pixels vertically, with a second shell angled one pixel toward the center. The boss never completes a mission or pays a separate scripted victory reward.
|
||||
|
||||
Every entity retains its own direction, clock and cannon side in serializable script state. Dead or absent entities cannot move or fire. A duplicate death callback cannot count twice. A new spawn resets its lifecycle even when a lethal collision precedes its queued callback, so restored state and reused entity IDs cannot suppress replay objectives. Mission-start objective reset and victory gating belong to the mission mod.
|
||||
|
||||
Generated master images, aligned SD/HD PNGs and synthesized mono 16-bit 22,050 Hz WAVs are included. `prompts/` contains the exact built-in image generation prompts, `provenance.json` records the sources, and `build_assets.py` reproduces derived artwork and audio. Use `--audio-only` to regenerate sounds without Pillow. `art-qa.json` records logical dimensions and clipping checks; `audio-recipe.json` records non-silent PCM peaks and hashes.
|
||||
|
||||
Validate content with `raptor-rust --data data --mods RustRaptor/mods --validate-mods iron_coast_boss_3` from the repository root. `validate.rs` is a package-local acceptance probe compiled against the RustRaptor library and serde_json. It checks cannon alternation, warning timing, independent entities, restoration, patrol bounds and death objectives. A real World regression kills the boss through collision before queued callbacks, restores script checkpoints and repeats three lifecycles with the same entity ID; each must count and pay exactly once without firing after death.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"unit": {
|
||||
"logical_size": [
|
||||
96,
|
||||
64
|
||||
],
|
||||
"hd_size": [
|
||||
768,
|
||||
512
|
||||
],
|
||||
"hd_scale": 8,
|
||||
"sd_opaque_bounds": [
|
||||
6,
|
||||
4,
|
||||
90,
|
||||
58
|
||||
],
|
||||
"hd_opaque_bounds": [
|
||||
49,
|
||||
36,
|
||||
719,
|
||||
466
|
||||
],
|
||||
"source": "art/unit-generated-v1.png",
|
||||
"source_size": [
|
||||
1536,
|
||||
1024
|
||||
],
|
||||
"source_sha256": "6359953cbc845441f8e075488aaf0b9c0281f02938b895d43affdade668d8bef",
|
||||
"frames": 1,
|
||||
"origin": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"alpha": "Generated RGBA preserved; indexed threshold alpha>=128"
|
||||
},
|
||||
"projectile": {
|
||||
"logical_size": [
|
||||
8,
|
||||
8
|
||||
],
|
||||
"hd_size": [
|
||||
64,
|
||||
64
|
||||
],
|
||||
"hd_scale": 8,
|
||||
"sd_opaque_bounds": [
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"hd_opaque_bounds": [
|
||||
23,
|
||||
11,
|
||||
41,
|
||||
53
|
||||
],
|
||||
"source": "art/projectile-generated-v1.png",
|
||||
"source_size": [
|
||||
1254,
|
||||
1254
|
||||
],
|
||||
"source_sha256": "6b8e12409793991fc374386acf8d5cb05fcdaad86257c1a24cbad1119f6e6df2",
|
||||
"frames": 1,
|
||||
"origin": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"alpha": "Generated RGBA preserved; indexed threshold alpha>=128"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 178 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"generator": "build_assets.py seeded chirp/noise synthesis; no recordings",
|
||||
"files": {
|
||||
"audio/shot.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 4851,
|
||||
"seed": 303,
|
||||
"duration_seconds": 0.22,
|
||||
"peak": 25558,
|
||||
"sha256": "f3b0e9f304a6fa5beb25d6b2cf3378e35711af2825756aafd12d02fc4eace3c6"
|
||||
},
|
||||
"audio/warning.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 3749,
|
||||
"seed": 304,
|
||||
"duration_seconds": 0.17002267573696145,
|
||||
"peak": 17694,
|
||||
"sha256": "906abb992bee7ea65e171d9d3e4e59bf6d75fdb7aa0df512347a3ac3025b645b"
|
||||
},
|
||||
"audio/death.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 20948,
|
||||
"seed": 305,
|
||||
"duration_seconds": 0.9500226757369614,
|
||||
"peak": 25558,
|
||||
"sha256": "9762116be5e60f038a4f30c1f3e5d96e29088783d416473577c85981dc263a4e"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resize saved imagegen masters and reproduce seeded PCM effects.
|
||||
|
||||
Generated alpha is preserved. No image is painted or reconstructed here.
|
||||
Run with --audio-only to regenerate WAVs without Pillow.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import random
|
||||
import struct
|
||||
import wave
|
||||
|
||||
|
||||
def synthesize(root):
|
||||
rate = 22050
|
||||
reports = {}
|
||||
for name, duration, seed in [("shot", 0.22, 303), ("warning", 0.17, 304), ("death", 0.95, 305)]:
|
||||
rng = random.Random(seed)
|
||||
count = round(duration * rate)
|
||||
phase = smooth_noise = 0.0
|
||||
samples = []
|
||||
for index in range(count):
|
||||
t = index / rate
|
||||
u = index / max(1, count - 1)
|
||||
noise = rng.uniform(-1, 1)
|
||||
smooth_noise = smooth_noise * 0.88 + noise * 0.12
|
||||
if name == "shot":
|
||||
phase += 2 * math.pi * (95 + 520 * (1-u)**2) / rate
|
||||
value = 0.6 * math.sin(phase) + 0.24 * noise + 0.14 * math.sin(2.21 * phase)
|
||||
envelope = math.exp(-6*u)
|
||||
elif name == "warning":
|
||||
phase += 2 * math.pi * (640 + 740*u) / rate
|
||||
value = 0.6 * math.sin(phase) + 0.12 * math.sin(2*phase)
|
||||
envelope = math.sin(math.pi*u)**1.5 * (0.3 + 0.7 * (0.5 + 0.5*math.sin(2*math.pi*18*t)))
|
||||
else:
|
||||
phase += 2 * math.pi * (90 - 65*u) / rate
|
||||
value = 0.65 * smooth_noise + 0.17 * noise + 0.31 * math.sin(phase)
|
||||
envelope = math.exp(-4.9*u)
|
||||
envelope *= min(1, t/0.003) * min(1, (count-1-index)/(rate*0.005))
|
||||
samples.append(value * max(0, envelope))
|
||||
peak = max(abs(v) for v in samples)
|
||||
pcm = [round(v / peak * (0.54 if name == "warning" else 0.78) * 32767) for v in samples]
|
||||
path = root / "audio" / f"{name}.wav"
|
||||
with wave.open(str(path), "wb") as output:
|
||||
output.setnchannels(1)
|
||||
output.setsampwidth(2)
|
||||
output.setframerate(rate)
|
||||
output.writeframes(struct.pack("<" + "h" * count, *pcm))
|
||||
reports[str(path.relative_to(root))] = {
|
||||
"sample_rate": rate, "channels": 1, "bits": 16, "samples": count,
|
||||
"seed": seed, "duration_seconds": count/rate,
|
||||
"peak": max(abs(v) for v in pcm),
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
}
|
||||
(root / "audio-recipe.json").write_text(json.dumps({"generator": "build_assets.py seeded chirp/noise synthesis; no recordings", "files": reports}, indent=2) + "\n")
|
||||
|
||||
|
||||
def artwork(root):
|
||||
from PIL import Image, ImageOps
|
||||
reports = {}
|
||||
for name, logical, scale in [("unit", (96,64), 8), ("projectile", (8,8), 8)]:
|
||||
path = root / "art" / f"{name}-generated-v1.png"
|
||||
source = Image.open(path).convert("RGBA")
|
||||
if source.getchannel("A").getextrema()[0] != 0:
|
||||
raise ValueError(f"{path}: missing true transparent pixels")
|
||||
size = tuple(v*scale for v in logical)
|
||||
if name == "unit":
|
||||
hd = Image.new("RGBA", size, (0,0,0,0))
|
||||
fitted = ImageOps.contain(source, (size[0]-64, size[1]-42), Image.Resampling.LANCZOS)
|
||||
hd.alpha_composite(fitted, ((size[0]-fitted.width)//2, (size[1]-fitted.height)//2))
|
||||
else:
|
||||
hd = source.resize(size, Image.Resampling.LANCZOS)
|
||||
sd = hd.resize(logical, Image.Resampling.BOX)
|
||||
bounds = []
|
||||
for image in (sd,hd):
|
||||
bbox = image.getchannel("A").point(lambda v:255 if v>=128 else 0).getbbox()
|
||||
if bbox is None or bbox[0] <= 0 or bbox[1] <= 0 or bbox[2] >= image.width or bbox[3] >= image.height:
|
||||
raise ValueError(f"{name}: silhouette clips or is missing: {bbox}")
|
||||
bounds.append(list(bbox))
|
||||
sd.save(root / "art" / f"{name}-sd.png")
|
||||
hd.save(root / "art" / f"{name}-hd.png")
|
||||
reports[name] = {"logical_size": list(logical), "hd_size": list(size), "hd_scale": scale,
|
||||
"sd_opaque_bounds": bounds[0], "hd_opaque_bounds": bounds[1],
|
||||
"source": str(path.relative_to(root)), "source_size": list(source.size),
|
||||
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"frames": 1, "origin": [0,0], "alpha": "Generated RGBA preserved; indexed threshold alpha>=128"}
|
||||
(root / "art-qa.json").write_text(json.dumps(reports, indent=2) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--audio-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parent
|
||||
synthesize(root)
|
||||
if not args.audio_only:
|
||||
artwork(root)
|
||||
print("Saltbreaker assets validated and generated")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"weapons": [{
|
||||
"id": "iron_coast_boss_3:projectile", "name": "Saltbreaker incendiary shell",
|
||||
"damage": 3, "speed": 4, "cooldown": 54, "behavior": "straight", "target": "all",
|
||||
"art": { "png": "art/projectile-sd.png", "hd_png": "art/projectile-hd.png", "logical_width": 8, "logical_height": 8 }
|
||||
}],
|
||||
"enemies": [{
|
||||
"id": "iron_coast_boss_3:unit", "name": "Saltbreaker armored escort VTOL",
|
||||
"health": 160, "speed": 0, "reward": 1800, "ground": false, "boss": true, "cooldown": 54,
|
||||
"art": { "png": "art/unit-sd.png", "hd_png": "art/unit-hd.png", "logical_width": 96, "logical_height": 64, "frames": 1 },
|
||||
"script": "scripts/ai.rhai"
|
||||
}],
|
||||
"sounds": [
|
||||
{ "id": "iron_coast_boss_3:shot", "name": "Saltbreaker cannon report", "file": "audio/shot.wav", "volume": 95, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_3:warning", "name": "Saltbreaker cannon charging warning", "file": "audio/warning.wav", "volume": 83, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_3:death", "name": "Saltbreaker turbine destruction", "file": "audio/death.wav", "volume": 120, "pan": 127, "pitch": 128, "priority": 4 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
id = "iron_coast_boss_3"
|
||||
name = "Iron Coast Saltbreaker"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "An armored convoy-escort VTOL boss with alternating, audible side-cannon warnings, SD and HD artwork, and deterministic serializable AI."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/ai.rhai"]
|
||||
@@ -0,0 +1 @@
|
||||
Use case: stylized-concept. Asset type: a single transparent production game projectile sprite for an industrial top-down vertical shoot-em-up. Exactly one small Saltbreaker boss cannon shell, straight top-down overhead orthographic view. Compact oval gunmetal projectile with a narrow amber incendiary slit and tiny red bands, nose pointing DOWN; no fins. Isolated centered on a square transparent canvas, occupies the central 60% height and 40% width. Crisp outlined high-definition painted sprite compatible with downsampling to 8x8 pixels; thick visible larger detail, restrained charcoal silver amber military palette. True transparent background and generous clear margins. No text, no watermark, no glow aura, no cast shadow, no explosion, no trail, no smoke, no surrounding scene. Exactly one projectile.
|
||||
@@ -0,0 +1 @@
|
||||
Use case: stylized-concept. Asset type: production transparent HD game sprite for a top-down 1990s Raptor-inspired vertical shoot-em-up. Use the reference only for the industrial gray metal, olive plates, restrained rust-red accents and crisp painted/pixel-compatible detail; do not copy its silhouette or multiple frames. Generate ONE distinct enemy boss called Saltbreaker: a heavy armored convoy-escort VTOL craft with a broad low-profile fuselage, two large circular ducted lift fans set into the short side wings, blunt armored nose pointing straight DOWN, and a cannon pod on each side of the nose. Canvas landscape 3:2, aircraft occupies 86% width and 80% height, completely visible and centered. Exact overhead orthographic view, bilateral symmetric static unbanked sprite, no perspective. Gunmetal and olive military industrial armor, small red warning strips, warm amber small windows, dark fan rotors, metallic rim highlights; no blue neon. Crisp clean edges and purposeful larger details remain readable when downsampled to 96x64 logical pixels. True transparent background, transparent margin all sides, no cast ground shadow, no grid, no surface, no smoke, no words, no labels, no border, no glowing aura, no additional objects. Exactly one craft, no sheet.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"asset_authoring": "Built-in image_gen tool, transparent_background=true; no CLI fallback",
|
||||
"original_game_art": "No original game assets copied into this package",
|
||||
"artworks": [
|
||||
{"master": "art/unit-generated-v1.png", "prompt": "prompts/unit-hd-v1.txt", "reference": "../iron_coast_boss/art/unit-hd.png", "reference_role": "Industrial materials and palette only; independently generated twin-fan silhouette", "output": ["art/unit-hd.png", "art/unit-sd.png"]},
|
||||
{"master": "art/projectile-generated-v1.png", "prompt": "prompts/projectile-hd-v1.txt", "reference": null, "output": ["art/projectile-hd.png", "art/projectile-sd.png"]}
|
||||
],
|
||||
"derivation": "build_assets.py applies only aspect-preserving fit, transparent padding and SD/HD resampling; original generated alpha is retained",
|
||||
"sounds": "Original deterministic procedural chirps and seeded noise; build_assets.py --audio-only; no recordings or external audio assets",
|
||||
"validation": ["art-qa.json logical and HD bounds", "audio-recipe.json PCM peaks and hashes", "validate.rs callback acceptance probe"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Each boss has its own direction, cannon clock and alternating barrel.
|
||||
// No wall-clock time or global tick modulo affects restored behavior.
|
||||
fn is_alive(event) {
|
||||
for unit in event.entities {
|
||||
if unit.entity == event.entity && unit.health > 0 { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn on_spawn(event, state, random) {
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
if !state.contains("dead") { state.dead = #{}; }
|
||||
let key = event.entity.to_string();
|
||||
state.units[key] = #{direction: 1, clock: 0, side: 0};
|
||||
state.dead.remove(key);
|
||||
// IDs restart on replay. Even a spawn killed during advance must clear
|
||||
// its previous life's death guard before the queued death callback runs.
|
||||
if !is_alive(event) { return #{commands: [], state: state}; }
|
||||
#{commands: [#{op: "velocity", entity: event.entity, vx: 0, vy: 2}], state: state}
|
||||
}
|
||||
|
||||
fn on_tick(event, state, random) {
|
||||
if event.health <= 0 || !is_alive(event) { return #{commands: [], state: state}; }
|
||||
if event.y < 24 {
|
||||
return #{commands: [#{op: "velocity", entity: event.entity, vx: 0, vy: 2}], state: state};
|
||||
}
|
||||
let key = event.entity.to_string();
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
let unit = if state.units.contains(key) { state.units[key] } else {#{direction: 1, clock: 0, side: 0}};
|
||||
if event.x >= 144 { unit.direction = -1; }
|
||||
if event.x <= 80 { unit.direction = 1; }
|
||||
unit.clock = (unit.clock + 1) % 54;
|
||||
let commands = [
|
||||
#{op: "move", entity: event.entity, x: event.x, y: 24},
|
||||
#{op: "velocity", entity: event.entity, vx: unit.direction, vy: 0}
|
||||
];
|
||||
// Audible warning lasts nine simulation steps before each side fires.
|
||||
if unit.clock == 45 {
|
||||
commands.push(#{op: "play_sound", sound: "iron_coast_boss_3:warning"});
|
||||
}
|
||||
if unit.clock == 0 {
|
||||
let muzzle = if unit.side == 0 {35} else {61};
|
||||
let inward = if unit.side == 0 {1} else {-1};
|
||||
commands.push(#{op: "projectile", weapon: "iron_coast_boss_3:projectile", x: event.x + muzzle, y: event.y + 54, vx: 0, vy: 4, hostile: true});
|
||||
commands.push(#{op: "projectile", weapon: "iron_coast_boss_3:projectile", x: event.x + muzzle, y: event.y + 54, vx: inward, vy: 4, hostile: true});
|
||||
commands.push(#{op: "play_sound", sound: "iron_coast_boss_3:shot"});
|
||||
unit.side = 1 - unit.side;
|
||||
state.shots = if state.contains("shots") {state.shots + 1} else {1};
|
||||
}
|
||||
state.units[key] = unit;
|
||||
#{commands: commands, state: state}
|
||||
}
|
||||
|
||||
fn on_hit(event, state, random) {
|
||||
state.hits = if state.contains("hits") {state.hits + 1} else {1};
|
||||
#{commands: [], state: state}
|
||||
}
|
||||
|
||||
fn on_death(event, state, random) {
|
||||
let key = event.entity.to_string();
|
||||
if !state.contains("dead") { state.dead = #{}; }
|
||||
if state.dead.contains(key) { return #{commands: [], state: state}; }
|
||||
state.dead[key] = true;
|
||||
if state.contains("units") { state.units.remove(key); }
|
||||
state.destroyed = if state.contains("destroyed") {state.destroyed + 1} else {1};
|
||||
let defeated = if event.objectives.contains("iron_coast_boss_3:defeated") {event.objectives["iron_coast_boss_3:defeated"] + 1} else {1};
|
||||
#{commands: [
|
||||
#{op: "objective", key: "iron_coast_boss_3:defeated", value: defeated},
|
||||
#{op: "effect", x: event.x + 35, y: event.y + 48},
|
||||
#{op: "effect", x: event.x + 61, y: event.y + 48},
|
||||
#{op: "play_sound", sound: "iron_coast_boss_3:death"}
|
||||
], state: state}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Package-local callback acceptance probe; compile against raptor_replica and serde_json.
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
const OWNER: &str = "iron_coast_boss_3";
|
||||
const SCRIPT: &str = "scripts/ai.rhai";
|
||||
|
||||
fn runtime(path: &Path) -> ScriptRuntime {
|
||||
let mut result = ScriptRuntime::new();
|
||||
result
|
||||
.add(
|
||||
OWNER,
|
||||
SCRIPT,
|
||||
&std::fs::read_to_string(path.join(SCRIPT)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
fn event(entity: u64, x: i32, y: i32, health: i32) -> Value {
|
||||
json!({"entity":entity, "x":x, "y":y, "health":health, "tick":999,
|
||||
"entities":[{"entity":entity,"health":health}],"objectives":{}})
|
||||
}
|
||||
|
||||
fn call(runtime: &mut ScriptRuntime, hook: &str, event: Value) -> Vec<Command> {
|
||||
runtime.invoke(OWNER, SCRIPT, hook, &event, 303).unwrap()
|
||||
}
|
||||
|
||||
fn queued_spawn_replay(package: &Path) {
|
||||
let mut base = Registry::default();
|
||||
let content: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,"weapon":"base:projectile_forward_guns"},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Spawn lifecycle collision regression","duration":500,"auto_complete":false,
|
||||
"spawns":[{"tick":0,"enemy":"iron_coast_boss_3:unit","x":112,"y":120}]}]
|
||||
})).unwrap();
|
||||
base.apply(content, "base").unwrap();
|
||||
let discovery = modding::discover(package.parent().unwrap());
|
||||
let mut active = ActiveMods::build(&base, &discovery, &[OWNER.to_string()], None).unwrap();
|
||||
// A real collision during advance kills this fragile boss before dispatch.
|
||||
// Its on_spawn callback is queued before on_death, and both see no live boss.
|
||||
active
|
||||
.registry
|
||||
.enemies
|
||||
.get_mut(&"iron_coast_boss_3:unit".into())
|
||||
.unwrap()
|
||||
.health = 1;
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.difficulty = 2;
|
||||
pilot.credits = 0;
|
||||
let mut reused_id = None;
|
||||
for replay in 0..3 {
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([144, 160]),
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
world.enemies.is_empty(),
|
||||
"collision must remove the boss before dispatch"
|
||||
);
|
||||
assert_eq!(
|
||||
world.objectives.get("iron_coast_boss_3:defeated"),
|
||||
Some(&1),
|
||||
"replay {replay} must count its new lifecycle"
|
||||
);
|
||||
assert_eq!(
|
||||
world.player.credits, 1800,
|
||||
"exactly one engine reward per lifecycle"
|
||||
);
|
||||
let checkpoints = active.scripts.checkpoints();
|
||||
let state = &checkpoints.0["iron_coast_boss_3/scripts/ai.rhai"];
|
||||
let ids = state["dead"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids.len(), 1);
|
||||
if let Some(ref previous) = reused_id {
|
||||
assert_eq!(previous, ids[0]);
|
||||
} else {
|
||||
reused_id = Some(ids[0].clone());
|
||||
}
|
||||
assert!(state["units"].as_object().unwrap().is_empty());
|
||||
assert!(world.projectiles.is_empty(), "dead spawn must not shoot");
|
||||
assert_eq!(world.mod_sounds, vec!["iron_coast_boss_3:death".into()]);
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.credits, 1800);
|
||||
assert_eq!(world.objectives.get("iron_coast_boss_3:defeated"), Some(&1));
|
||||
// Reconstruct the runtime from saved state exactly as a pilot load does.
|
||||
let mut restored = runtime(package);
|
||||
restored.restore(checkpoints);
|
||||
active.scripts = restored;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("pass package directory");
|
||||
let path = Path::new(&path);
|
||||
let mut ai = runtime(path);
|
||||
call(&mut ai, "on_spawn", event(1, 112, -64, 160));
|
||||
for _ in 0..20 {
|
||||
let commands = call(&mut ai, "on_tick", event(1, 112, -64, 160));
|
||||
assert!(
|
||||
!commands
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Projectile { .. } | Command::PlaySound { .. }))
|
||||
);
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let mut volleys = Vec::new();
|
||||
for tick in 1..=108 {
|
||||
let commands = call(&mut ai, "on_tick", event(1, 112, 24, 160));
|
||||
if commands
|
||||
.iter()
|
||||
.any(|c| matches!(c,Command::PlaySound{sound} if sound=="iron_coast_boss_3:warning"))
|
||||
{
|
||||
warnings.push(tick);
|
||||
}
|
||||
let shells = commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
Command::Projectile {
|
||||
weapon,
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
hostile,
|
||||
} => {
|
||||
assert_eq!(weapon, "iron_coast_boss_3:projectile");
|
||||
assert_eq!((*y, *vy, *hostile), (78, 4, true));
|
||||
Some((*x, *vx))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !shells.is_empty() {
|
||||
volleys.push((tick, shells));
|
||||
}
|
||||
}
|
||||
assert_eq!(warnings, vec![45, 99]);
|
||||
assert_eq!(
|
||||
volleys,
|
||||
vec![
|
||||
(54, vec![(147, 0), (147, 1)]),
|
||||
(108, vec![(173, 0), (173, -1)])
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
call(&mut ai, "on_tick", event(1, 144, 24, 160))
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Velocity { vx: -1, vy: 0, .. }))
|
||||
);
|
||||
assert!(
|
||||
call(&mut ai, "on_tick", event(1, 80, 24, 160))
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Velocity { vx: 1, vy: 0, .. }))
|
||||
);
|
||||
// A second boss starts a separate clock and cannot inherit the first gun phase.
|
||||
call(&mut ai, "on_spawn", event(2, 112, 24, 160));
|
||||
assert!(
|
||||
!call(&mut ai, "on_tick", event(2, 112, 24, 160))
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Projectile { .. }))
|
||||
);
|
||||
let mut restored = runtime(path);
|
||||
restored.restore(ai.checkpoints());
|
||||
for _ in 0..111 {
|
||||
assert_eq!(
|
||||
serde_json::to_value(call(&mut ai, "on_tick", event(1, 112, 24, 160))).unwrap(),
|
||||
serde_json::to_value(call(&mut restored, "on_tick", event(1, 112, 24, 160))).unwrap()
|
||||
);
|
||||
}
|
||||
// A dead or absent entity cannot move or fire. Death reports once and never wins a mission itself.
|
||||
assert!(call(&mut ai, "on_tick", event(1, 112, 24, 0)).is_empty());
|
||||
let mut missing = event(1, 112, 24, 160);
|
||||
missing["entities"] = json!([]);
|
||||
assert!(call(&mut ai, "on_tick", missing).is_empty());
|
||||
let death = call(&mut ai, "on_death", event(1, 112, 24, 0));
|
||||
assert!(
|
||||
death.iter().any(
|
||||
|c| matches!(c,Command::Objective{key,value:1} if key=="iron_coast_boss_3:defeated")
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!death
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Complete | Command::Credits { .. }))
|
||||
);
|
||||
assert!(call(&mut ai, "on_death", event(1, 112, 24, 0)).is_empty());
|
||||
let mut second = event(2, 112, 24, 0);
|
||||
second["objectives"] = json!({"iron_coast_boss_3:defeated":1});
|
||||
assert!(
|
||||
call(&mut ai, "on_death", second).iter().any(
|
||||
|c| matches!(c,Command::Objective{key,value:2} if key=="iron_coast_boss_3:defeated")
|
||||
)
|
||||
);
|
||||
queued_spawn_replay(path);
|
||||
println!(
|
||||
"Saltbreaker: arrival/patrol, two-shell alternating volleys, nine-step warnings, independent entities, checkpoint replay, dead-unit safety and exact death objectives passed"
|
||||
);
|
||||
println!(
|
||||
"Real World lethal collision before queued spawn: three restored-state replays reuse the same entity ID and each count and reward exactly once"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user