Retune Iron Coast waves, enemy attacks and weapon presentation

This commit is contained in:
raptorreplica committed 2026-10-17 18:13:20 +11:00
1 parent 00cfcc2031
commit c4a4d821d5
145 files changed
+9087 -2796

No files matched your search

+1
View File
@@ -97,6 +97,7 @@ cases; wider playtesting and complete base-game HD coverage remain work in progr
- [Mod API](docs/modding-api.md)
- [AI mod authoring checklist](docs/mod-authoring-checklist.md)
- [Iron Coast packages](docs/iron-coast.md)
- [Iron Coast pacing and difficulty comparisons](docs/iron-coast-retune.md)
- [Iron Coast II: Black Tide](docs/iron-coast-stage2-design.md)
- [Weapon prices and balance](docs/weapon-balance.md)
- [Sprite and shadow validation](docs/visual-validation.md)
+129
View File
@@ -0,0 +1,129 @@
# Iron Coast pacing and presentation update
The 14 Iron Coast missions now use difficulty-scaled attack groups, clearer
weapon feedback and distinct enemy/boss patterns. Original campaigns and their
content definitions are unchanged.
## Wave schedules
Enabled scheduled enemies, including required targets and bosses:
| Mission | Training | Rookie | Veteran | Elite |
| --- | ---: | ---: | ---: | ---: |
| I 1 — Breakwater | 25 | 34 | 43 | 52 |
| I 2 — Tidal Relay | 29 | 40 | 51 | 62 |
| I 3 — Saltworks Convoy | 32 | 45 | 58 | 71 |
| I 4 — Stormwall | 35 | 50 | 65 | 84 |
| I 5 — Cinder Docks | 45 | 64 | 83 | 107 |
| I 6 — Iron Crown | 64 | 92 | 120 | 145 |
| II 1 — Nightbreak Inlet | 30 | 43 | 55 | 67 |
| II 2 — Undertow Run | 31 | 44 | 60 | 76 |
| II 3 — Stormglass Refinery | 42 | 50 | 62 | 78 |
| II 4 — Floodgate Zero | 42 | 50 | 62 | 78 |
| II 5 — Skybreaker Arsenal | 45 | 53 | 65 | 81 |
| II 6 — Emberwake Shipyards | 46 | 54 | 66 | 82 |
| II 7 — Siege of the Iron Fleet | 57 | 65 | 77 | 93 |
| II 8 — Black Tide | 61 | 69 | 81 | 97 |
Counts are not simultaneous enemies or difficulty quotas. The 27 original
missions have median scheduled totals of 139 / 139 / 228 / 313, but their
lengths, target durability and attacks differ. Iron Coast now introduces flank
groups and classic aircraft between objectives, with recovery space before
bosses. Required targets remain reachable; aircraft leaving the playfield do
not grant kills or complete objectives.
## Comparison method
The baseline covers all 27 original missions and all 14 Iron Coast missions at
all four difficulties. Controlled pressure observations use the same firing and
movement policy, with replenished shields to observe the complete schedule.
Those measurements describe activity, not ordinary-player survival. Separate
paid routes use actual inventory, shop transactions and carried shield charge.
Median mean-visible enemies during the controlled observations:
| Difficulty | Original missions | Stage I before → after | Stage II before → after |
| --- | ---: | ---: | ---: |
| Training | 0.65 | 0.28 → 0.52 | 0.22 → 0.42 |
| Rookie | 0.67 | 0.29 → 0.86 | 0.22 → 0.50 |
| Veteran | 1.30 | 0.31 → 1.26 | 0.25 → 0.59 |
| Elite | 1.98 | 0.31 → 1.52 | 0.25 → 0.68 |
The final Elite Iron Crown schedule removes ten extra reinforcements to leave
recovery space before the Fleet. Its 145 scheduled enemies remain above
Veteran’s 120. All lower-difficulty schedules are unchanged by that correction.
The final paired Stage I routes complete all six missions at all four settings,
with 96 full pilot/script-state/RNG save/load checks across baseline and revised
routes. The Elite finale completes at tick 2,649 with 47 shield using the actual
loaned Ion Lance and three purchased Field Repairs costing 37,500 credits.
The same controller’s earlier runs without those purchases died before the
Fleet; those failures remain recorded. This is one demonstrated paid strategy,
not a promise that every loadout or movement policy survives.
Stage II completes all eight missions and a finale replay at all four settings
using carried inventory, paid supplies and normal damage. The lowest observed
shield is 12 on Elite. Late-miss checks separately verify all mandatory targets
remain reachable and objectives can finish; those observer checks replenish
shields and are not survival results.
The final package metadata passes actual 45-mod resource validation and exact
save-pin roundtrips at every difficulty. Gameplay measurements use the frozen
runtime definitions/scripts; the final version-number, README and blank-line
whitespace corrections do not change gameplay tokens.
## Enemy silhouettes and attacks
Scouts and interceptors retain 24×24 logical bodies; turrets and siege units
remain 32×32, and skimmers remain 32×24. Those footprints fit their original
game roles; the update changes movement and firing rather than enlarging every
enemy. Scouts now enter, attack and break away. Interceptors make a faster
lateral pass and reversing exit. Ground units retain reachable firing lanes.
Aim locks at the start of each short salvo. Difficulty adjusts warning time,
shot speed, salvo length and recovery. Ordinary muzzle feedback uses compact
cues instead of explosion blooms. Enemy projectile artwork is 6–7 logical
pixels wide. Its visible size does not change the engine’s existing hostile
projectile collision threshold.
The first six bosses now have different patrols, dives, health phases and
warning/recovery sequences. Definition health is 240 / 280 / 320 / 360 / 400 / 420,
within the range of comparable original bosses; normal difficulty scaling applies.
The first three use 84×56
bodies, the next two 96×64 and Iron Crown 108×72. Stage II bosses retain their
existing distinct bodies, health, damage and rewards, with revised attack timing.
## Weapons and feedback
Pulse Cannon, Seeker Swarm, Ion Lance, Railburst Cannon and Depth Charge retain
their damage, targeting, cooldowns, effective ranges and prices. Pulse, rail and
depth-charge shots use more compact artwork; brief hit arcs replace oversized
explosions. Seeker artwork stays larger because the smaller trial lost readability.
Audio is gated to actual attacks and hits, reducing sustained overlapping noise.
| Weapon | Price | Role |
| --- | ---: | --- |
| Pulse Cannon | 185,000 | Primary weapon augmentation |
| Seeker Swarm | 285,000 | Selectable homing missiles |
| Ion Lance | 425,000 | Selectable penetrating beam |
| Railburst Cannon | 685,000 | Selectable piercing rail shot |
| Depth Charge | 525,000 | Selectable ground attack |
Controlled damage comparisons cover 125 cases; 30 complete gameplay-state
comparisons preserve damage, trajectories, RNG, collision, cooldowns and health
while allowing cosmetic effects/audio to differ. SD/HD enemy and boss captures
also check silhouettes, warning periods and firing feedback. Those bounded
checks do not establish every loadout’s balance or hardware audio quality.
## Using the update
Enable **Iron Coast — Complete Campaign** or **Iron Coast II — Black Tide** in
Mods and apply at the main menu. Dependencies select automatically. Existing
pilots may receive the normal changed-mod warning; accepting it preserves saved
equipment and progression. Use Ctrl to fire, Alt/Shift to cycle equipment, Z for
Special and Space for owned bombs. Select mission-provided repairs before
using Special; they consume actual items. Mission 6 lends an Ion Lance when
inventory space permits—select it and hold Fire.
Package-local acceptance files describe their recorded versions. Retained
pre-retune 1.0 results are historical; they are not new-version playthrough claims.
+15 -5
View File
@@ -1,6 +1,6 @@
# Iron Coast content packages
Iron Coast contains six independently toggleable mission mods, five shared
Iron Coast Stage I contains six independently toggleable mission mods, five shared
weapon and enemy mods, a shared soundtrack, six stage boss mods, and an optional
campaign assembly mod. Enable **Iron
Coast — Complete Campaign** (`iron_coast_campaign`) to select the full campaign
@@ -13,6 +13,13 @@ needed by another enabled mod remain selected. The Mods manager validates versio
and dependency order and applies the change at the main menu. The new weapons can also be used in the original campaigns;
the enemy packages can be referenced from other missions.
Stage II — **Black Tide** adds eight more independent missions, eight bosses,
three shared enemies, two weapons and a three-track soundtrack. Enable
`iron_coast_stage2_campaign` to select that route and its dependencies. See
[Black Tide’s package instructions](../mods/iron_coast_stage2_campaign/README.md).
The [pacing and presentation update](iron-coast-retune.md) documents the current
waves, enemy patterns, weapon feedback and all four difficulty comparisons.
| Package | Content |
| --- | --- |
| `iron_coast_campaign` | Links all six missions into one campaign without replacing mission scripts or artwork |
@@ -35,7 +42,9 @@ the enemy packages can be referenced from other missions.
| `seeker_swarm` | Selectable homing missile volleys |
| `ion_lance` | Selectable penetrating beam |
All packages are version 1.0.0 and use mod API 1. Every mission requires
All packages use mod API 1. Retuned enemies, bosses and missions use patch
versions 1.0.1 (Iron Crown 1.0.2); the five retuned weapons use 1.1.0.
Unchanged packages retain 1.0.0. Every Stage I mission requires
compatible 1.x versions of its five shared content mods, soundtrack and stage
boss. The campaign assembly requires all six mission packages; shared dependencies are selected transitively.
Save compatibility records each enabled package's exact version and content
@@ -54,7 +63,7 @@ retains that dependency; the confirmation lists the actual affected packages.
## Mission 1: Breakwater
The player raids a coastal industrial harbor. Ten scouts cover the approach,
The player raids a coastal industrial harbor. Difficulty-scaled scouts cover the approach,
four ground turrets protect the docks, two destructible supply depots release
repair kits, and the command ship arrives at step 720. Destroy at least two
turrets and the command ship to finish. Surviving turrets stop at y=72 and the boss stays in the playfield until
@@ -153,8 +162,9 @@ MUS score played through the same FM synthesizer as the original game music;
its source composition, generated score and provenance are included.
The generated mod assets are included in Git, so a fresh checkout includes
the complete content packages. The original base-game asset catalog is still
required locally to play the game and remains excluded from version control.
the complete content packages. The original base-game asset catalog is included in the standalone
`RustRaptor/data/` folder and tracked in this repository. Local review captures
and build caches remain excluded from version control.
To rebuild the mission assets from their included generated masters, run:
```sh
+19 -8
View File
@@ -21,15 +21,26 @@ current enemy snapshots. A finite 16,384-property Rhai map limit fixes documente
queries failing on ordinary enemy groups; operation, command and 64 KiB saved-state
limits remain unchanged. All 45 modding regression tests pass. Three actual 1080p
SDL captures (menu, S1 Wave 7, S3 Wave 9) match the previous binary byte for byte.
Windows cross-build, PE imports and shared-data verification pass; this source
revision still needs its own native Windows CI result.
Windows cross-build, PE imports and shared-data verification pass. This API
revision passed its own native Windows CI at 5,340 frames: 360 tests passed,
zero failed and 26 were ignored. The subsequent 5,341-frame artwork append
has a separate CI observation; the 360-test result is not relabeled for it.
Iron Coast retuning is active in `.reference/campaign-retune-20261017/`.
The baseline covers all 27 original and 14 mod missions at all four difficulties.
Mission waves, enemy movement, bosses and weapon presentation are being validated
in a combined private mod overlay before application. The working mod packages
have not yet been replaced. The 45-picture terrain context sample found visible
road-material seams; affected artwork remains held for repair.
Iron Coast’s final retune is applied to the working packages: 14 scaled mission
schedules, five shared enemy AIs, 14 boss patterns and five weapons’ visual/audio
feedback. The combined 45-package overlay passes resource validation and save
pins; paid routes complete both stages at all four difficulties. Stage I Elite
uses the documented loan and legitimately purchased repairs; earlier failing
policies remain preserved. See `docs/iron-coast-retune.md` for counts and limits.
All six bounded SD/HD flight screenshots and three actual menu screenshots
have been reviewed. The shop, mod diagnostics and mission selector remain
readable, and the new versions appear correctly. These are presentation checks,
not a claim of exhaustive human playtesting.
HD continuation remains active. Four more boss frames have genuine World
captures awaiting material review; unused-picture and retained-detail trials
remain private. The terrain context sample found road-material seams, so those
15 tiles remain held. No whole-remaster completion is claimed.
# Earlier 5,339-frame checkpoint — retained historical observation
+17 -5
View File
@@ -1,9 +1,21 @@
# Ion Lance 1.0.0
# Ion Lance
Enable **Ion Lance** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (425,000 credits, maximum one) or collect its pickup. Choose it in Inventory or cycle special weapons during a mission. The equipped weapon fires with Fire or Special; it consumes no ammunition.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
Projectile: 8 damage, 12 logical pixels per step, 14 step cooldown, `beam`, air and ground targets. The narrow beam pierces every enemy in its column for two steps (16 total damage per enemy per trigger). Rhai stores bounded burst/hit counters and emits local firing/impact sounds and effects.
Selectable reusable special. A brief beam hits every air or ground enemy in the column ahead of your ship. Aim the column, then use Fire or Special while selected. No ammunition is consumed.
`art/equipment-sd.png` is a 16×16 logical icon, `art/equipment-hd.png` its detailed 4× variant. Projectile canvases are 4×32 SD and 16×128 HD. Switch HD artwork in Options. The generated master images, SD/HD sprites, WAV files, provenance and reproduction inputs are tracked in Git. Original game assets and decoded reference images remain outside version control. `python3 generate_audio.py` reproduces both sound files.
Buy **Ion Lance** for **425,000 credits**; carry at most 1. Select it through Inventory or the Cycle control when it is a special weapon.
Validate locally with `cargo run --no-default-features -- --mods mods --validate-mods ion_lance`. Read the shared [API documentation](../../docs/modding-api.md) for save pins, dependencies and sandbox limits.
`ion_lance:projectile`: 8 damage, speed 12, 14-tick cooldown, `beam` behavior.
## Feedback
The actual gameplay renderer draws a **2px-wide column** from the top of the playfield to the pilot for the existing two beam ticks. It does not draw the supplied 4×32 projectile PNG in that path; that asset and its declaration are retained. Damage 8 per beam tick, 14-tick cadence, column aiming and penetration are unchanged. One compact 2-tick contact arc per projectile, 3-tick flash spacing and 6-tick impact-audio spacing replace repeated explosions/audio at every column target. Fire/impact volume is 70/46. Price stays 425,000.
Only declared display sizing, cosmetic hit commands and playback levels/priorities change. No PNG/WAV pixels or samples, equipment icon dimensions, projectile collision rules, damage, cooldown, offsets, targeting, ammunition, capacity, controls or prices are changed. No new muzzle explosion or persistent trail is emitted. Normal original enemy deaths and terrain-destruction effects remain engine-controlled.
The three rapid weapons retain their prior burst/hit counters and reset only their cosmetic tick/projectile gates when mission ticks restart, accepting older saved script state without new fields. This version changes the package save pin; existing pilot compatibility confirmation remains the normal mod mechanism.
## Validation and history
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+4 -4
View File
@@ -50,19 +50,19 @@
"id": "ion_lance:fire",
"name": "Ion Lance Fire",
"file": "audio/fire.wav",
"volume": 92,
"volume": 70,
"pan": 127,
"pitch": 128,
"priority": 1
"priority": 2
},
{
"id": "ion_lance:impact",
"name": "Ion Lance Impact",
"file": "audio/impact.wav",
"volume": 78,
"volume": 46,
"pan": 127,
"pitch": 128,
"priority": 2
"priority": 3
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
id = "ion_lance"
name = "Ion Lance"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Naval science-fiction ion lance: independent weapon, equipment, SD/HD artwork and local firing/impact audio."
content = "content.json"
+32 -7
View File
@@ -1,20 +1,45 @@
// Built-in beam projectiles handle the normal volley and collision rules.
// Firing callbacks add audio and counters; impact callbacks add hit visuals.
// Firing callbacks add audio and counters; compact two-tick contact arcs are cosmetic.
fn on_fire(event, state, random) {
// Runtime script state can survive a mission change while tick/entity IDs restart.
// Reset only cosmetic gates; the existing burst/hit counters keep their semantics.
let last_fire = if state.contains("last_fire_tick") { state.last_fire_tick } else { -1 };
let restarted = event.tick <= last_fire
|| (state.contains("feedback_tick") && event.tick <= state.feedback_tick)
|| (state.contains("sound_tick") && event.tick <= state.sound_tick);
let bursts = if state.contains("bursts") { state.bursts % 100000 + 1 } else { 1 };
let hits = if state.contains("hits") { state.hits } else { 0 };
let commands = [
#{ op: "play_sound", sound: "ion_lance:fire" }
];
#{ commands: commands, state: #{ bursts: bursts, hits: hits } }
#{ commands: commands, state: #{ bursts: bursts, hits: hits,
// A new volley cannot be the preceding beam's second hit tick. Forget its
// entity ID, which may be reused in another mission even after a late first shot.
feedback_projectile: -1,
feedback_tick: if !restarted && state.contains("feedback_tick") { state.feedback_tick } else { -10 },
sound_tick: if !restarted && state.contains("sound_tick") { state.sound_tick } else { -10 },
last_fire_tick: event.tick } }
}
fn on_hit(event, state, random) {
let bursts = if state.contains("bursts") { state.bursts } else { 0 };
let hits = if state.contains("hits") { state.hits % 100000 + 1 } else { 1 };
let commands = [
#{ op: "play_sound", sound: "ion_lance:impact" },
#{ op: "effect", x: event.x, y: event.y }
];
#{ commands: commands, state: #{ bursts: bursts, hits: hits } }
// One compact contact flash per projectile, not one explosion per beam target.
// A separate six-tick audio gate also bounds coincident paired/swarm hits.
let previous_projectile = if state.contains("feedback_projectile") { state.feedback_projectile } else { -1 };
let previous_tick = if state.contains("feedback_tick") { state.feedback_tick } else { -10 };
let sound_tick = if state.contains("sound_tick") { state.sound_tick } else { -10 };
let commands = [];
let feedback = event.projectile != previous_projectile && event.tick - previous_tick >= 3;
if feedback {
commands.push(#{op: "arc", x: event.x - 2, y: event.y + 2,
to_x: event.x + 2, to_y: event.y - 2, ticks: 2});
}
let audible = event.tick - sound_tick >= 6;
if audible { commands.push(#{op: "play_sound", sound: "ion_lance:impact"}); }
#{ commands: commands, state: #{ bursts: bursts, hits: hits,
feedback_projectile: if feedback { event.projectile } else { previous_projectile },
feedback_tick: if feedback { event.tick } else { previous_tick },
sound_tick: if audible { event.tick } else { sound_tick },
last_fire_tick: if state.contains("last_fire_tick") { state.last_fire_tick } else { -1 } } }
}
+7 -9
View File
@@ -1,17 +1,15 @@
# Iron Coast Command Cruiser
Independent version 1.0.0 enemy package for mod API 1. Enable/disable it from the in-game Mods screen and use it in any mission through `iron_coast_boss:unit`. Iron Coast mission 1 supplies placements and completion objectives; this package does not add a mission or override base content.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
Airborne command cruiser enters at 2 pixels/tick, settles at y24 and patrols x80..144 indefinitely. It fires a fan of three 3 damage shells every 54 ticks. A standard pilot can destroy it with starter guns; it stays in their firing corridor.
Enemy `iron_coast_boss:unit` is an airborne boss with **240** nominal health and a **84×56** logical canvas. Actual destruction awards 1,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Logical collision canvas `96×64`, top-left anchor `(0,0)`, health `160`, reward `1800` credits, firing interval `54` simulation ticks. The engine halves boss health on difficulties 0/1; difficulty 2/3 uses the declared health. SD mode uses `art/unit-sd.png`; enable HD art in Settings for `art/unit-hd.png`. The HD canvas is 8× logical resolution and bypasses automatic ScaleNx. Projectile artwork has an 8×8 logical canvas and an independent 64×64 HD version.
Three-way fan / open-center gate / locked single shot. Each life has three health phases (above two-thirds, at/below two-thirds, at/below one-third of its actual spawn health). Every phase change restarts a full warning. Brief 8-pixel, 8-tick anchored charge arcs and existing quiet audio precede the salvo; the warning and short burst hold position, then the boss recovers into a bounded lateral patrol. Gun positions follow the existing artwork. No homing retarget occurs after aim is locked.
The boss uses two registered frames (idle/amber charged); the scout and turret use stable single frames. All supplied variants stay on the same canvas and anchor. `art-qa.json` records transparency bounds and, for the boss, animation silhouette overlap.
Warning leads are 18 / 16 / 14 / 12 ticks for Training / Rookie / Veteran / Elite. Recovery leaves a new approach opportunity; watch the charge cue and move into the opening before the volley.
Enemy deaths increment the current mission objective `iron_coast_boss:defeated`. Persistent script `destroyed`, `hits` and `shots` counters are statistics; current-mission objectives reset when the mission restarts. Callbacks guard retired entities and use deterministic simulation data. No wall clock or unseeded random input enters the scripts.
Only actual death increments `iron_coast_boss:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
Generated PNG masters, SD/HD strips, previews and WAV sounds are included in Git. Original reference images under `art/source/` are excluded. Exact built-in imagegen prompts are in `prompts/`, with source IDs and generated-image provenance in `provenance.json`.
## Validation and history
To rebuild derived artwork from supplied generated masters and regenerate audio, install Pillow and run `python3 build_assets.py`. To regenerate only audio with the Python standard library, run `python3 build_assets.py --audio-only`. WAVs are original procedural mono 16-bit PCM at 22050 Hz; `audio-recipe.json` records deterministic seeds, sizes and hashes. The art pipeline resizes and assembles supplied images; it does not redraw them.
Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_boss`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+15 -6
View File
@@ -11,8 +11,8 @@
"art": {
"png": "art/projectile-sd.png",
"hd_png": "art/projectile-hd.png",
"logical_width": 8,
"logical_height": 8
"logical_width": 5,
"logical_height": 5
}
}
],
@@ -20,17 +20,17 @@
{
"id": "iron_coast_boss:unit",
"name": "Iron Coast Command Cruiser",
"health": 160,
"health": 240,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 96,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 96,
"logical_height": 64,
"logical_width": 84,
"logical_height": 56,
"frames": 2
},
"script": "scripts/ai.rhai"
@@ -54,6 +54,15 @@
"pan": 127,
"pitch": 128,
"priority": 4
},
{
"id": "iron_coast_boss:warning",
"name": "Quiet charge cue",
"file": "audio/shot.wav",
"volume": 35,
"pan": 127,
"pitch": 170,
"priority": 2
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss"
name = "Iron Coast Command Cruiser"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "A persistent patrol boss with a three-shell cannon fan. Includes SD and HD artwork, deterministic Rhai AI and generated sound effects."
description = "A three-phase Command Cruiser encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+107 -35
View File
@@ -1,56 +1,128 @@
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Private 1.0.1 retune: per-entity telegraph, locked lanes and recovery.
// No legacy selectors. Old persisted counters and cursor fields are retained.
fn live_actor(event) {
// Current API refreshes actor after earlier committed callbacks. An explicit
// absent/dead actor must not fall back to a stale queued event payload.
if event.contains("actor") {
if type_of(event.actor) == "map" { return event.actor; }
return #{health:0,x:0,y:0};
}
false
// Historical API fallback only: its entities snapshot is also current.
for enemy in event.entities { if enemy.entity == event.entity { return enemy; } }
#{health:0,x:0,y:0}
}
fn settings(event) {
// API v1 fallback is Training until the centrally supplied field is present.
if event.contains("difficulty") { return event.difficulty; }
0
}
fn cursor(event, state, key) {
let unit = if state.contains("units") && state.units.contains(key) {state.units[key]} else {#{}};
if !unit.contains("direction") { unit.direction = if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1}; }
if !unit.contains("age") { unit.age = if state.contains("ages") && state.ages.contains(key) {state.ages[key]} else {0}; }
if !unit.contains("clock") { unit.clock = 0; }
if !unit.contains("volley") { unit.volley = if unit.contains("shots") {unit.shots} else {0}; }
if !unit.contains("phase") { unit.phase = 0; }
if !unit.contains("max_health") { unit.max_health = live_actor(event).health; }
if unit.max_health < 1 { unit.max_health = 1; }
if !unit.contains("lock_x") { unit.lock_x = event.player.x + 16; }
if !unit.contains("pattern") { unit.pattern = unit.volley % 3; }
unit
}
// Per-entity directions permit multiple cruisers without sharing a patrol cursor.
fn on_spawn(event, state, random) {
if !is_alive(event) { return #{ commands: [], state: state }; }
if !state.contains("directions") { state.directions = #{}; }
state.directions[event.entity.to_string()] = 1;
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
if !state.contains("dead") { state.dead = #{}; }
state.dead.remove(key);
if state.contains("dead_order") {let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;}
// Register even a queued spawn killed before this callback: its death counts.
state.units[key] = #{direction:1,age:0,clock:0,volley:0,phase:0,max_health:live_actor(event).health,lock_x:event.player.x+16,pattern:0};
if live_actor(event).health <= 0 { 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 {
let actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let unit = cursor(event,state,key);
if actor.y < 24 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
let key = event.entity.to_string();
if !state.contains("directions") { state.directions = #{}; }
let direction = if state.directions.contains(key) { state.directions[key] } else { 1 };
if event.x >= 144 { direction = -1; }
if event.x <= 80 { direction = 1; }
state.directions[key] = direction;
let commands = [
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
#{ op: "velocity", entity: event.entity, vx: direction, vy: 0 }
];
if event.tick % 54 == 0 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 35, y: 64, vx: -2, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 48, y: 64, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 61, y: 64, vx: 2, vy: 4, hostile: true });
let difficulty = settings(event);
unit.difficulty=difficulty;
let phase = if actor.health*3 <= unit.max_health {2} else if actor.health*3 <= unit.max_health*2 {1} else {0};
// A health transition always restarts the full warning; it cannot skip ahead
// to an untelegraphed salvo when the period changes.
if phase != unit.phase { unit.phase=phase; unit.clock=0; }
let period = 96 - difficulty*6 - phase*6;
let lead = 18-difficulty*2;
let speed = 3 + if difficulty >= 2 {1} else {0};
if unit.clock >= period { unit.clock=0; }
let width=84;
let left=actor.x+33;
let center=actor.x+width/2;
let right=actor.x+50;
let muzzle_y=actor.y+39;
let center_y=actor.y+52;
let commands=[];
if unit.clock == 0 {
unit.lock_x=event.player.x+16;
unit.pattern=(unit.volley+phase)%3;
commands.push(#{op:"play_sound",sound:"iron_coast_boss:warning"});
if unit.pattern == 2 {commands.push(#{op:"arc",x:center-4,y:center_y,to_x:center+4,to_y:center_y,ticks:8,anchor:event.entity,to_anchor:event.entity});}
else {
commands.push(#{op:"arc",x:left-4,y:muzzle_y,to_x:left+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"arc",x:right-4,y:muzzle_y,to_x:right+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
}
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
if unit.clock == lead {
if unit.pattern == 0 { commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:center,y:center_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:left,y:muzzle_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:right,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
}
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss:shot"});
state.shots=if state.contains("shots") {state.shots+1} else {1};
}
// Warning and short burst are stationary hit windows; reposition only in
// recovery. The ship never leaves the forward-gun reach or dives into player.
let resting=unit.clock <= lead;
if actor.x >= 320-width-32 {unit.direction=-1;}
if actor.x <= 32 {unit.direction=1;}
let vx=if resting {0} else {unit.direction*(1+if difficulty>=2 {1} else {0})};
let y=if resting {actor.y} else {24};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
commands.push(#{op:"animation",entity:event.entity,frame:if unit.clock<=lead {1} else {0}});
if unit.clock == lead {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
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) {
if state.contains("directions") { state.directions.remove(event.entity.to_string()); }
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("dead_order") {state.dead_order=[];}
state.dead_order.push(key);
if state.dead_order.len()>32 {let old=state.dead_order.remove(0);state.dead.remove(old);}
if state.contains("units") {state.units.remove(key);}
if state.contains("directions") {state.directions.remove(key);}
if state.contains("ages") {state.ages.remove(key);}
state.destroyed=if state.contains("destroyed") {state.destroyed+1} else {1};
let defeated=if event.objectives.contains("iron_coast_boss:defeated") {event.objectives["iron_coast_boss:defeated"]+1} else {1};
#{ commands: [
#{ op: "objective", key: "iron_coast_boss:defeated", value: defeated },
#{ op: "effect", x: event.x + 35, y: event.y + 36 },
#{ op: "effect", x: event.x + 61, y: event.y + 36 },
#{ op: "play_sound", sound: "iron_coast_boss:death" }
], state: state }
#{commands:[#{op:"objective",key:"iron_coast_boss:defeated",value:defeated},
#{op:"effect",x:event.x+84/3,y:event.y+56/2},
#{op:"effect",x:event.x+84*2/3,y:event.y+56/2},
#{op:"play_sound",sound:"iron_coast_boss:death"}],state:state}
}
+7 -25
View File
@@ -1,33 +1,15 @@
# Iron Coast: Relay Warden
Standalone API 1 boss package used by the Tidal Relay mission. It has no dependencies and never decides when a mission finishes. Enable the mission to select this package automatically through its dependency.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
- Enemy: `iron_coast_boss_2:unit`, health 160, reward 1,800 credits, airborne boss.
- Projectile: `iron_coast_boss_2:projectile`, damage 3, speed 4, 8×8 logical canvas.
- Completion observation: `iron_coast_boss_2:defeated` increases once for each actual death. Duplicate notifications are harmless; despawning or time passing does not count.
- Art: one complete static frame on a 96×64 logical canvas. SD and HD preserve the same origin, full silhouette and true alpha. HD is 768×512; gameplay collision dimensions remain 96×64.
- Audio: `shot`, `warning` and `death` namespaced sound IDs, each a generated, non-silent 22,050 Hz, mono, 16-bit PCM WAV.
Enemy `iron_coast_boss_2:unit` is an airborne boss with **280** nominal health and a **84×56** logical canvas. Actual destruction awards 1,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
The Warden enters at two logical pixels per tick, stops at y=24, and patrols x=80…144. Each entity tracks its own arrival timer and direction. An audible radar charge starts 12 ticks before each 54-tick volley. Odd volleys fire two parallel outer lanes; even volleys fire two diverging inner lanes. The opening below its central radar dish remains readable. Each queued spawn registers its life even if a projectile has already killed it before callback dispatch; that early kill still counts when the engine delivers its actual death. Timers and directions are removed when the aircraft dies, and dead units cannot emit movement, warnings or shots. Difficulty scaling comes from the engine, including its existing half-health rule on the two lower difficulties.
Wide radar gate / outward fork / warned locked doublet. Each life has three health phases (above two-thirds, at/below two-thirds, at/below one-third of its actual spawn health). Every phase change restarts a full warning. Brief 8-pixel, 8-tick anchored charge arcs and existing quiet audio precede the salvo; the warning and short burst hold position, then the boss recovers into a bounded lateral patrol. Gun positions follow the existing artwork. No homing retarget occurs after aim is locked.
## Asset sources
Warning leads are 18 / 16 / 14 / 12 ticks for Training / Rookie / Veteran / Elite. Recovery leaves a new approach opportunity; watch the charge cue and move into the opening before the volley.
The two masters were generated using the built-in imagegen tool from the saved prompts, with the existing generated Iron Coast cruiser inspected for material and color style. The Warden and cyan radar pulse have new silhouettes; no original game art or recordings are copied into this package. `provenance.json`, `art-qa.json` and `audio-recipe.json` record inputs, processing, alpha bounds and hashes. All PNGs, WAVs and prompts are intended for Git.
Only actual death increments `iron_coast_boss_2:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
Recreate the SD/HD output and audio from the saved masters with:
## Validation and history
```sh
python3 RustRaptor/mods/iron_coast_boss_2/build_assets.py
```
## Validation
The package contains a small Rust validation program that uses the game's public modding and deterministic simulation API. It validates package artwork and WAV decoding, both telegraphs and alternating volleys, separate entity timers, actual death rewards/objectives, repeated and dead callbacks, cleanup, a real lethal projectile during the spawn step, and reuse of an entity ID for a new life. It requires no original game assets.
```sh
CARGO_TARGET_DIR=RustRaptor/target cargo run --offline \
--manifest-path RustRaptor/mods/iron_coast_boss_2/validation/Cargo.toml \
--bin validate-boss
```
Its saved result is `validation/report.json`. Tidal Relay's validation also exercises this boss in the complete mission with the real original content registry.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+31 -7
View File
@@ -20,25 +20,49 @@
{
"id": "iron_coast_boss_2:unit",
"name": "Relay Warden",
"health": 160,
"health": 280,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 90,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 96,
"logical_height": 64,
"logical_width": 84,
"logical_height": 56,
"frames": 1
},
"script": "scripts/ai.rhai"
}
],
"sounds": [
{ "id": "iron_coast_boss_2:shot", "name": "Relay Warden pulse volley", "file": "audio/shot.wav", "volume": 95, "pan": 127, "pitch": 128, "priority": 2 },
{ "id": "iron_coast_boss_2:warning", "name": "Relay Warden radar charge", "file": "audio/warning.wav", "volume": 86, "pan": 127, "pitch": 128, "priority": 2 },
{ "id": "iron_coast_boss_2:death", "name": "Relay Warden destruction", "file": "audio/death.wav", "volume": 120, "pan": 127, "pitch": 128, "priority": 4 }
{
"id": "iron_coast_boss_2:shot",
"name": "Relay Warden pulse volley",
"file": "audio/shot.wav",
"volume": 95,
"pan": 127,
"pitch": 128,
"priority": 2
},
{
"id": "iron_coast_boss_2:warning",
"name": "Relay Warden radar charge",
"file": "audio/warning.wav",
"volume": 86,
"pan": 127,
"pitch": 128,
"priority": 2
},
{
"id": "iron_coast_boss_2:death",
"name": "Relay Warden destruction",
"file": "audio/death.wav",
"volume": 120,
"pan": 127,
"pitch": 128,
"priority": 4
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss_2"
name = "Iron Coast: Relay Warden"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "A broad radar-aircraft boss for Tidal Relay, with an audible warning and alternating two-lane pulse volleys. Includes transparent SD/HD art and generated sounds."
description = "A three-phase Relay Warden encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+104 -58
View File
@@ -1,84 +1,130 @@
// All cursors are per entity. Arrival and attack timers use simulation callbacks.
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Private 1.0.1 retune: per-entity telegraph, locked lanes and recovery.
// No legacy selectors. Old persisted counters and cursor fields are retained.
fn live_actor(event) {
// Current API refreshes actor after earlier committed callbacks. An explicit
// absent/dead actor must not fall back to a stale queued event payload.
if event.contains("actor") {
if type_of(event.actor) == "map" { return event.actor; }
return #{health:0,x:0,y:0};
}
false
// Historical API fallback only: its entities snapshot is also current.
for enemy in event.entities { if enemy.entity == event.entity { return enemy; } }
#{health:0,x:0,y:0}
}
fn settings(event) {
// API v1 fallback is Training until the centrally supplied field is present.
if event.contains("difficulty") { return event.difficulty; }
0
}
fn cursor(event, state, key) {
let unit = if state.contains("units") && state.units.contains(key) {state.units[key]} else {#{}};
if !unit.contains("direction") { unit.direction = if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1}; }
if !unit.contains("age") { unit.age = if state.contains("ages") && state.ages.contains(key) {state.ages[key]} else {0}; }
if !unit.contains("clock") { unit.clock = 0; }
if !unit.contains("volley") { unit.volley = if unit.contains("shots") {unit.shots} else {0}; }
if !unit.contains("phase") { unit.phase = 0; }
if !unit.contains("max_health") { unit.max_health = live_actor(event).health; }
if unit.max_health < 1 { unit.max_health = 1; }
if !unit.contains("lock_x") { unit.lock_x = event.player.x + 16; }
if !unit.contains("pattern") { unit.pattern = unit.volley % 3; }
unit
}
fn on_spawn(event, state, random) {
// Register the life before checking current liveness: the engine can kill a
// queued spawn during advance, before it dispatches this callback. Its later
// actual death still needs one objective update, without movement or audio.
if !state.contains("directions") { state.directions = #{}; }
if !state.contains("ages") { state.ages = #{}; }
let key = event.entity.to_string();
state.directions[key] = 1;
state.ages[key] = 0;
if !is_alive(event) { return #{ commands: [], state: state }; }
if !state.contains("units") { state.units = #{}; }
if !state.contains("dead") { state.dead = #{}; }
state.dead.remove(key);
if state.contains("dead_order") {let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;}
// Register even a queued spawn killed before this callback: its death counts.
state.units[key] = #{direction:1,age:0,clock:0,volley:0,phase:0,max_health:live_actor(event).health,lock_x:event.player.x+16,pattern:0};
if live_actor(event).health <= 0 { 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 {
let actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let unit = cursor(event,state,key);
if actor.y < 20 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
let key = event.entity.to_string();
// An untracked unit cannot acquire another entity's attack timer.
if !state.contains("ages") || !state.ages.contains(key) {
return #{ commands: [], state: state };
}
let direction = state.directions[key];
if event.x >= 144 { direction = -1; }
if event.x <= 80 { direction = 1; }
state.directions[key] = direction;
let age = state.ages[key] + 1;
state.ages[key] = age;
let commands = [
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
#{ op: "velocity", entity: event.entity, vx: direction, vy: 0 }
];
// Twelve ticks of warning precede the shot: about half a second at 70/3 Hz.
if age % 54 == 42 {
let difficulty = settings(event);
unit.difficulty=difficulty;
let phase = if actor.health*3 <= unit.max_health {2} else if actor.health*3 <= unit.max_health*2 {1} else {0};
// A health transition always restarts the full warning; it cannot skip ahead
// to an untelegraphed salvo when the period changes.
if phase != unit.phase { unit.phase=phase; unit.clock=0; }
let period = 90 - difficulty*6 - phase*6;
let lead = 18-difficulty*2;
let speed = 3 + if difficulty >= 2 {1} else {0};
if unit.clock >= period { unit.clock=0; }
let width=84;
let left=actor.x+33;
let center=actor.x+width/2;
let right=actor.x+50;
let muzzle_y=actor.y+44;
let center_y=actor.y+52;
let commands=[];
if unit.clock == 0 {
unit.lock_x=event.player.x+16;
unit.pattern=(unit.volley+phase)%3;
commands.push(#{op:"play_sound",sound:"iron_coast_boss_2:warning"});
if unit.pattern == 2 {commands.push(#{op:"arc",x:center-4,y:center_y,to_x:center+4,to_y:center_y,ticks:8,anchor:event.entity,to_anchor:event.entity});}
else {
commands.push(#{op:"arc",x:left-4,y:muzzle_y,to_x:left+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"arc",x:right-4,y:muzzle_y,to_x:right+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
}
if age % 54 == 0 {
// Alternate parallel outer lanes with a diverging inner fork.
// Both leave a readable opening directly under the central radar dish.
if (age / 54) % 2 == 1 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 24, y: 75, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 72, y: 75, vx: 0, vy: 4, hostile: true });
} else {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 36, y: 75, vx: -1, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 60, y: 75, vx: 1, vy: 4, hostile: true });
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
if unit.clock == lead {
if unit.pattern == 0 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:left,y:muzzle_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:right,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
}
if unit.clock == lead+6 && unit.pattern == 2 && difficulty >= 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss_2:shot"});
state.shots=if state.contains("shots") {state.shots+1} else {1};
}
// Warning and short burst are stationary hit windows; reposition only in
// recovery. The ship never leaves the forward-gun reach or dives into player.
let resting=unit.clock <= lead+6;
if actor.x >= 320-width-32 {unit.direction=-1;}
if actor.x <= 32 {unit.direction=1;}
let vx=if resting {0} else {unit.direction*(1+if difficulty>=2 {1} else {0})};
let triangle=if (unit.age/12)%8<4 {(unit.age/12)%8} else {8-(unit.age/12)%8};
let y=if resting {actor.y} else {20+if phase>0 {triangle*6/4} else {0}};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if unit.clock == lead+6 {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
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();
// A death callback must refer to a spawned unit that is absent from live queries.
// Removing its cursor also makes repeated death notifications harmless.
if is_alive(event) || !state.contains("ages") || !state.ages.contains(key) {
return #{ commands: [], state: state };
}
state.ages.remove(key);
state.directions.remove(key);
if !state.contains("dead") {state.dead=#{};}
if state.dead.contains(key) {return #{commands:[],state:state};}
state.dead[key]=true;
if !state.contains("dead_order") {state.dead_order=[];}
state.dead_order.push(key);
if state.dead_order.len()>32 {let old=state.dead_order.remove(0);state.dead.remove(old);}
if state.contains("units") {state.units.remove(key);}
if state.contains("directions") {state.directions.remove(key);}
if state.contains("ages") {state.ages.remove(key);}
state.destroyed=if state.contains("destroyed") {state.destroyed+1} else {1};
let defeated=if event.objectives.contains("iron_coast_boss_2:defeated") {event.objectives["iron_coast_boss_2:defeated"]+1} else {1};
#{ commands: [
#{ op: "objective", key: "iron_coast_boss_2:defeated", value: defeated },
#{ op: "effect", x: event.x + 32, y: event.y + 38 },
#{ op: "effect", x: event.x + 64, y: event.y + 38 },
#{ op: "play_sound", sound: "iron_coast_boss_2:death" }
], state: state }
#{commands:[#{op:"objective",key:"iron_coast_boss_2:defeated",value:defeated},
#{op:"effect",x:event.x+84/3,y:event.y+56/2},
#{op:"effect",x:event.x+84*2/3,y:event.y+56/2},
#{op:"play_sound",sound:"iron_coast_boss_2:death"}],state:state}
}
+10 -6
View File
@@ -1,11 +1,15 @@
# Saltbreaker
# Iron Coast 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`.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
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.
Enemy `iron_coast_boss_3:unit` is an airborne boss with **320** nominal health and a **84×56** logical canvas. Actual destruction awards 1,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
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.
Alternating single battery / delayed inward fork / side-switching pair. Each life has three health phases (above two-thirds, at/below two-thirds, at/below one-third of its actual spawn health). Every phase change restarts a full warning. Brief 8-pixel, 8-tick anchored charge arcs and existing quiet audio precede the salvo; the warning and short burst hold position, then the boss recovers into a bounded lateral patrol. Gun positions follow the existing artwork. No homing retarget occurs after aim is locked.
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.
Warning leads are 18 / 16 / 14 / 12 ticks for Training / Rookie / Veteran / Elite. Recovery leaves a new approach opportunity; watch the charge cue and move into the opening before the volley.
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.
Only actual death increments `iron_coast_boss_3:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
## Validation and history
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+63 -13
View File
@@ -1,18 +1,68 @@
{
"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 },
"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": 320,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 90,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 84,
"logical_height": 56,
"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 }
{
"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
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss_3"
name = "Iron Coast Saltbreaker"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "An armored convoy-escort VTOL boss with alternating, audible side-cannon warnings, SD and HD artwork, and deterministic serializable AI."
description = "A three-phase Saltbreaker encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+100 -41
View File
@@ -1,73 +1,132 @@
// 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; }
// Private 1.0.1 retune: per-entity telegraph, locked lanes and recovery.
// No legacy selectors. Old persisted counters and cursor fields are retained.
fn live_actor(event) {
// Current API refreshes actor after earlier committed callbacks. An explicit
// absent/dead actor must not fall back to a stale queued event payload.
if event.contains("actor") {
if type_of(event.actor) == "map" { return event.actor; }
return #{health:0,x:0,y:0};
}
false
// Historical API fallback only: its entities snapshot is also current.
for enemy in event.entities { if enemy.entity == event.entity { return enemy; } }
#{health:0,x:0,y:0}
}
fn settings(event) {
// API v1 fallback is Training until the centrally supplied field is present.
if event.contains("difficulty") { return event.difficulty; }
0
}
fn cursor(event, state, key) {
let unit = if state.contains("units") && state.units.contains(key) {state.units[key]} else {#{}};
if !unit.contains("direction") { unit.direction = if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1}; }
if !unit.contains("age") { unit.age = if state.contains("ages") && state.ages.contains(key) {state.ages[key]} else {0}; }
if !unit.contains("clock") { unit.clock = 0; }
if !unit.contains("volley") { unit.volley = if unit.contains("shots") {unit.shots} else {0}; }
if !unit.contains("phase") { unit.phase = 0; }
if !unit.contains("max_health") { unit.max_health = live_actor(event).health; }
if unit.max_health < 1 { unit.max_health = 1; }
if !unit.contains("lock_x") { unit.lock_x = event.player.x + 16; }
if !unit.contains("pattern") { unit.pattern = unit.volley % 3; }
unit
}
fn on_spawn(event, state, random) {
let key = event.entity.to_string();
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}; }
if state.contains("dead_order") {let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;}
// Register even a queued spawn killed before this callback: its death counts.
state.units[key] = #{direction:1,age:0,clock:0,volley:0,phase:0,max_health:live_actor(event).health,lock_x:event.player.x+16,pattern:0};
if live_actor(event).health <= 0 { 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 actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],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"});
let unit = cursor(event,state,key);
if actor.y < 18 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
let difficulty = settings(event);
unit.difficulty=difficulty;
let phase = if actor.health*3 <= unit.max_health {2} else if actor.health*3 <= unit.max_health*2 {1} else {0};
// A health transition always restarts the full warning; it cannot skip ahead
// to an untelegraphed salvo when the period changes.
if phase != unit.phase { unit.phase=phase; unit.clock=0; }
let period = 90 - difficulty*6 - phase*6;
let lead = 18-difficulty*2;
let speed = 3 + if difficulty >= 2 {1} else {0};
if unit.clock >= period { unit.clock=0; }
let width=84;
let left=actor.x+30;
let center=actor.x+width/2;
let right=actor.x+54;
let muzzle_y=actor.y+47;
let center_y=actor.y+52;
let commands=[];
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});
unit.lock_x=event.player.x+16;
unit.pattern=(unit.volley+phase)%3;
commands.push(#{op:"play_sound",sound:"iron_coast_boss_3:warning"});
if unit.pattern == 2 {commands.push(#{op:"arc",x:center-4,y:center_y,to_x:center+4,to_y:center_y,ticks:8,anchor:event.entity,to_anchor:event.entity});}
else {
commands.push(#{op:"arc",x:left-4,y:muzzle_y,to_x:left+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"arc",x:right-4,y:muzzle_y,to_x:right+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
}
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
let active = if unit.volley % 2 == 0 {left} else {right};
let inward = if unit.volley % 2 == 0 {1} else {-1};
if unit.clock == lead { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:active,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
if unit.clock == lead+6 {
if unit.pattern == 0 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:active,y:muzzle_y,vx:inward,vy:speed,hostile:true}); }
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
}
if unit.clock == lead {
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};
}
// Warning and short burst are stationary hit windows; reposition only in
// recovery. The ship never leaves the forward-gun reach or dives into player.
let resting=unit.clock <= lead+6;
if actor.x >= 320-width-32 {unit.direction=-1;}
if actor.x <= 32 {unit.direction=1;}
let vx=if resting {0} else {unit.direction*(1+if difficulty>=2 {1} else {0})};
let triangle=if (unit.age/12)%8<4 {(unit.age/12)%8} else {8-(unit.age/12)%8};
let y=if resting {actor.y} else {18+if phase>0 {triangle*12/4} else {0}};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if unit.clock == lead+6 {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
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("dead_order") {state.dead_order=[];}
state.dead_order.push(key);
if state.dead_order.len()>32 {let old=state.dead_order.remove(0);state.dead.remove(old);}
if state.contains("units") {state.units.remove(key);}
if state.contains("directions") {state.directions.remove(key);}
if state.contains("ages") {state.ages.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}
#{commands:[#{op:"objective",key:"iron_coast_boss_3:defeated",value:defeated},
#{op:"effect",x:event.x+84/3,y:event.y+56/2},
#{op:"effect",x:event.x+84*2/3,y:event.y+56/2},
#{op:"play_sound",sound:"iron_coast_boss_3:death"}],state:state}
}
+8 -17
View File
@@ -1,24 +1,15 @@
# Stormwall Arsenal
# Iron Coast: Stormwall Arsenal
An independent Iron Coast boss mod for mission 4. The wide aerial arsenal carries two massive downward-facing batteries, red storm-warning stripes and four rear engines. It has its own true transparent SD/HD boss and shell artwork, alarm, cannon and destruction sounds.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
Enable `iron_coast_boss_4` to register the boss; the Stormwall mission selects it as a dependency. This package has no dependencies, overrides or mission definitions.
Enemy `iron_coast_boss_4:unit` is an airborne boss with **360** nominal health and a **96×64** logical canvas. Actual destruction awards 1,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
| Definition | ID |
| --- | --- |
| Enemy | `iron_coast_boss_4:unit` |
| Projectile | `iron_coast_boss_4:projectile` |
| Death objective | `iron_coast_boss_4:defeated` |
| Sounds | `iron_coast_boss_4:warning`, `iron_coast_boss_4:shot`, `iron_coast_boss_4:death` |
Paused battery brackets / alternate-side curtain / locked central follow-up. Each life has three health phases (above two-thirds, at/below two-thirds, at/below one-third of its actual spawn health). Every phase change restarts a full warning. Brief 8-pixel, 8-tick anchored charge arcs and existing quiet audio precede the salvo; the warning and short burst hold position, then the boss recovers into a bounded lateral patrol. Gun positions follow the existing artwork. No homing retarget occurs after aim is locked.
The 96×64 boss enters at two logical pixels per simulation tick, holds y=24 and patrols x=80–144. Every 54 ticks it pauses for 12 ticks with a two-note arming alarm, then fires three 3-damage shells at speed 4. The third shell alternates between batteries. Each boss stores its own direction and attack age, so concurrent bosses remain independent. Ordinary difficulty scaling applies to its 160 health; destruction awards 1,800 credits.
Warning leads are 18 / 16 / 14 / 12 ticks for Training / Rookie / Veteran / Elite. Recovery leaves a new approach opportunity; watch the charge cue and move into the opening before the volley.
Actual death increments `iron_coast_boss_4:defeated` once and retires that entity's live state. A dead boss never keeps firing, and a fresh sortie safely resets reused entity IDs even when a lethal projectile hits before its queued spawn callback runs. The script does not declare mission success: the mission combines the death objective with its other targets.
Only actual death increments `iron_coast_boss_4:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
`art/unit-sd.png` and `art/unit-hd.png` share a 96×64 logical canvas (HD is 384×256). `art/projectile-sd.png` and `art/projectile-hd.png` share an 8×8 logical canvas (HD is 64×64). Both use one static frame, consistent registration and preserved alpha. `art/preview.png` shows both modes. The first shell master is retained for provenance but is not loaded; v2 gives a readable SD core.
## Validation and history
The built-in image generation tool supplied the artwork. Exact prompts are in `prompts/`, tool results and processing details in `provenance.json`, and image dimensions, bounds and hashes in `art-qa.json`. The package includes generated masters, runtime art and WAVs for Git tracking. Original catalog reference pixels are excluded.
`python3 build_assets.py` reproduces the SD/HD packaging and deterministic 22,050 Hz mono PCM16 sounds from saved masters. `audio-recipe.json` records seeds, nonzero peaks/RMS and hashes; no recordings or external sound downloads are used.
After building RustRaptor in release mode, run `python3 validate.py` for six tests through the real simulation and Rhai callbacks: warning timing and alternate batteries, independent multi-boss state, once-only destruction, saved state/RNG determinism, fresh sorties, lethal spawn-step hits with restored death state, duplicate death callbacks and persistent patrol bounds. Results are recorded in `acceptance-report.json`. The normal Rust mod validation CLI also validates this package independently.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+30 -13
View File
@@ -1,5 +1,6 @@
{
"weapons": [{
"weapons": [
{
"id": "iron_coast_boss_4:projectile",
"name": "Stormwall Arsenal battery shell",
"damage": 3,
@@ -13,16 +14,18 @@
"logical_width": 8,
"logical_height": 8
}
}],
"enemies": [{
}
],
"enemies": [
{
"id": "iron_coast_boss_4:unit",
"name": "Stormwall Arsenal",
"health": 160,
"health": 360,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 90,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
@@ -30,21 +33,35 @@
"logical_height": 64
},
"script": "scripts/ai.rhai"
}],
"sounds": [{
}
],
"sounds": [
{
"id": "iron_coast_boss_4:warning",
"name": "Stormwall battery arming warning",
"file": "audio/warning.wav",
"volume": 80, "pan": 127, "pitch": 128, "priority": 2
}, {
"volume": 80,
"pan": 127,
"pitch": 128,
"priority": 2
},
{
"id": "iron_coast_boss_4:shot",
"name": "Stormwall battery discharge",
"file": "audio/shot.wav",
"volume": 95, "pan": 127, "pitch": 128, "priority": 2
}, {
"volume": 95,
"pan": 127,
"pitch": 128,
"priority": 2
},
{
"id": "iron_coast_boss_4:death",
"name": "Stormwall Arsenal destruction",
"file": "audio/death.wav",
"volume": 120, "pan": 127, "pitch": 128, "priority": 4
}]
"volume": 120,
"pan": 127,
"pitch": 128,
"priority": 4
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss_4"
name = "Iron Coast: Stormwall Arsenal"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "A storm-hardened twin-turret arsenal with alternating telegraphed battery fire. Includes independent SD/HD artwork and synthesized sounds."
description = "A three-phase Stormwall Arsenal encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+107 -40
View File
@@ -1,66 +1,133 @@
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Private 1.0.1 retune: per-entity telegraph, locked lanes and recovery.
// No legacy selectors. Old persisted counters and cursor fields are retained.
fn live_actor(event) {
// Current API refreshes actor after earlier committed callbacks. An explicit
// absent/dead actor must not fall back to a stale queued event payload.
if event.contains("actor") {
if type_of(event.actor) == "map" { return event.actor; }
return #{health:0,x:0,y:0};
}
false
// Historical API fallback only: its entities snapshot is also current.
for enemy in event.entities { if enemy.entity == event.entity { return enemy; } }
#{health:0,x:0,y:0}
}
fn settings(event) {
// API v1 fallback is Training until the centrally supplied field is present.
if event.contains("difficulty") { return event.difficulty; }
0
}
fn cursor(event, state, key) {
let unit = if state.contains("units") && state.units.contains(key) {state.units[key]} else {#{}};
if !unit.contains("direction") { unit.direction = if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1}; }
if !unit.contains("age") { unit.age = if state.contains("ages") && state.ages.contains(key) {state.ages[key]} else {0}; }
if !unit.contains("clock") { unit.clock = 0; }
if !unit.contains("volley") { unit.volley = if unit.contains("shots") {unit.shots} else {0}; }
if !unit.contains("phase") { unit.phase = 0; }
if !unit.contains("max_health") { unit.max_health = live_actor(event).health; }
if unit.max_health < 1 { unit.max_health = 1; }
if !unit.contains("lock_x") { unit.lock_x = event.player.x + 16; }
if !unit.contains("pattern") { unit.pattern = unit.volley % 3; }
unit
}
// Every live boss owns its own clock/direction. A fresh spawn clears reused IDs
// from previous sorties; no wall clock or rendering state enters the simulation.
fn on_spawn(event, state, random) {
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
if !state.contains("dead") { state.dead = #{}; }
let key = event.entity.to_string();
// A queued spawn still starts a new lifecycle if advance already killed it.
// Clear saved guards first so its following death is counted for this sortie.
state.dead.remove(key);
state.units.remove(key);
if !is_alive(event) { return #{ commands: [], state: state }; }
state.units[key] = #{ direction: 1, age: 0 };
if state.contains("dead_order") {let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;}
// Register even a queued spawn killed before this callback: its death counts.
state.units[key] = #{direction:1,age:0,clock:0,volley:0,phase:0,max_health:live_actor(event).health,lock_x:event.player.x+16,pattern:0};
if live_actor(event).health <= 0 { 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 }; }
let actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
if !state.units.contains(key) { state.units[key] = #{ direction: 1, age: 0 }; }
if event.y < 24 {
let unit = cursor(event,state,key);
if actor.y < 20 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
let unit = state.units[key];
if event.x >= 144 { unit.direction = -1; }
if event.x <= 80 { unit.direction = 1; }
let phase = unit.age % 54;
let commands = [#{ op: "move", entity: event.entity, x: event.x, y: 24 }];
// Twelve ticks of a visible pause plus a two-note alarm telegraph each salvo.
commands.push(#{ op: "velocity", entity: event.entity, vx: if phase < 12 { 0 } else { unit.direction }, vy: 0 });
if phase == 0 { commands.push(#{ op: "play_sound", sound: "iron_coast_boss_4:warning" }); }
if phase == 12 {
let left_active = (unit.age / 54) % 2 == 0;
// The outer shells come from both batteries; the third shell alternates.
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_4:projectile", x: event.x + 24, y: 80, vx: -1, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_4:projectile", x: event.x + 72, y: 80, vx: 1, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_4:projectile", x: event.x + if left_active { 28 } else { 68 }, y: 80, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_4:shot" });
state.salvos = if state.contains("salvos") { state.salvos + 1 } else { 1 };
let difficulty = settings(event);
unit.difficulty=difficulty;
let phase = if actor.health*3 <= unit.max_health {2} else if actor.health*3 <= unit.max_health*2 {1} else {0};
// A health transition always restarts the full warning; it cannot skip ahead
// to an untelegraphed salvo when the period changes.
if phase != unit.phase { unit.phase=phase; unit.clock=0; }
let period = 90 - difficulty*6 - phase*6;
let lead = 18-difficulty*2;
let speed = 3 + if difficulty >= 2 {1} else {0};
if unit.clock >= period { unit.clock=0; }
let width=96;
let left=actor.x+28;
let center=actor.x+width/2;
let right=actor.x+68;
let muzzle_y=actor.y+56;
let center_y=actor.y+58;
let commands=[];
if unit.clock == 0 {
unit.lock_x=event.player.x+16;
unit.pattern=(unit.volley+phase)%3;
commands.push(#{op:"play_sound",sound:"iron_coast_boss_4:warning"});
if unit.pattern == 2 {commands.push(#{op:"arc",x:center-4,y:center_y,to_x:center+4,to_y:center_y,ticks:8,anchor:event.entity,to_anchor:event.entity});}
else {
commands.push(#{op:"arc",x:left-4,y:muzzle_y,to_x:left+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"arc",x:right-4,y:muzzle_y,to_x:right+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
}
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
if unit.clock == lead {
if unit.pattern == 0 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_4:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_4:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_4:projectile",x:left,y:muzzle_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_4:projectile",x:right,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_4:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
}
if unit.clock == lead+8 && unit.phase >= 1 {
let active = if unit.volley % 2 == 0 {left} else {right};
commands.push(#{op:"projectile",weapon:"iron_coast_boss_4:projectile",x:active,y:muzzle_y,vx:0,vy:speed,hostile:true});
}
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss_4:shot"});
state.shots=if state.contains("shots") {state.shots+1} else {1};
}
// Warning and short burst are stationary hit windows; reposition only in
// recovery. The ship never leaves the forward-gun reach or dives into player.
let resting=unit.clock <= lead+8;
if actor.x >= 320-width-32 {unit.direction=-1;}
if actor.x <= 32 {unit.direction=1;}
let vx=if resting {0} else {unit.direction*(1+if difficulty>=2 {1} else {0})};
let triangle=if (unit.age/12)%8<4 {(unit.age/12)%8} else {8-(unit.age/12)%8};
let y=if resting {actor.y} else {20+if phase>0 {triangle*4/4} else {0}};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if unit.clock == lead+8 {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
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("dead_order") {state.dead_order=[];}
state.dead_order.push(key);
if state.dead_order.len()>32 {let old=state.dead_order.remove(0);state.dead.remove(old);}
if state.contains("units") {state.units.remove(key);}
if state.contains("directions") {state.directions.remove(key);}
if state.contains("ages") {state.ages.remove(key);}
state.destroyed=if state.contains("destroyed") {state.destroyed+1} else {1};
let defeated=if event.objectives.contains("iron_coast_boss_4:defeated") {event.objectives["iron_coast_boss_4:defeated"]+1} else {1};
#{ commands: [
#{ op: "objective", key: "iron_coast_boss_4:defeated", value: defeated },
#{ op: "effect", x: event.x + 24, y: event.y + 40 },
#{ op: "effect", x: event.x + 72, y: event.y + 40 },
#{ op: "play_sound", sound: "iron_coast_boss_4:death" }
], state: state }
#{commands:[#{op:"objective",key:"iron_coast_boss_4:defeated",value:defeated},
#{op:"effect",x:event.x+96/3,y:event.y+64/2},
#{op:"effect",x:event.x+96*2/3,y:event.y+64/2},
#{op:"play_sound",sound:"iron_coast_boss_4:death"}],state:state}
}
+9 -26
View File
@@ -1,32 +1,15 @@
# Cinder Hammer
# Iron Coast Cinder Hammer
An independent boss mod for Iron Coast mission 5, **Cinder Docks**. The furnace
gunship patrols persistently at the top of the combat area. A pressure-warning
sound precedes three molten projectiles fired six simulation ticks apart. The
center mortar travels vertically; the two plasma rounds spread outward.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
- Enemy: `iron_coast_boss_5:unit`; health 160, reward 1,800, logical size 96×64.
- Mortar and plasma: damage 3, speed 4; a 54-tick attack cycle.
- Death objective: `iron_coast_boss_5:defeated`, incremented only on actual death.
- Manifest API 1, package version 1.0.0, no dependencies or overrides.
Enemy `iron_coast_boss_5:unit` is an airborne boss with **400** nominal health and a **96×64** logical canvas. Actual destruction awards 1,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
The package never completes a mission itself. A mission must spawn the enemy,
declare this package as a dependency, and require its death objective. Each
enemy instance stores its own attack clock and patrol direction in serializable
Rhai state. Dead or removed enemies cannot issue attacks or movement commands.
Three staggered furnaces / converging outer pair / locked mortar. Each life has three health phases (above two-thirds, at/below two-thirds, at/below one-third of its actual spawn health). Every phase change restarts a full warning. Brief 8-pixel, 8-tick anchored charge arcs and existing quiet audio precede the salvo; the warning and short burst hold position, then the boss recovers into a bounded lateral patrol. Gun positions follow the existing artwork. No homing retarget occurs after aim is locked.
SD PNGs are derived from the same transparent generated masters as the 8× HD
PNGs. Both modes retain the same logical canvas and collision dimensions.
`art/preview.png` shows the HD hull and exact SD silhouette enlarged for review.
The generation prompts are in `prompts/`; `provenance.json` records tool mode
and source hashes. All artwork and audio belong in Git with this mod.
Warning leads are 18 / 16 / 14 / 12 ticks for Training / Rookie / Veteran / Elite. Recovery leaves a new approach opportunity; watch the charge cue and move into the opening before the volley.
Run `python3 build_assets.py` to reproduce the resized art and synthesized mono
22,050 Hz, 16-bit PCM warning, shot and destruction sounds. Python and Pillow
are required. `audio-recipe.json` and `art-qa.json` record file hashes and checks.
Only actual death increments `iron_coast_boss_5:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
Run `cargo test --locked --manifest-path validation/Cargo.toml` from this folder
for five asset-independent checks of attack timing, multiple-instance state,
save serialization, dead-entity guards, patrol turns, actual simulation
persistence, rewards and the single death objective. The simulation test also
validates the real package-local PNGs and WAVs.
## Validation and history
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+2 -2
View File
@@ -35,12 +35,12 @@
{
"id": "iron_coast_boss_5:unit",
"name": "Cinder Hammer",
"health": 160,
"health": 400,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 96,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss_5"
name = "Iron Coast Cinder Hammer"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "A furnace gunship boss for Cinder Docks: persistent patrol, audible heat warning and staggered mortar/plasma fire. Includes matching SD/HD sprites and synthesized sound effects."
description = "A three-phase Cinder Hammer encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+104 -56
View File
@@ -1,82 +1,130 @@
// Persistent furnace gunship. Each instance has its own serializable clock.
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Private 1.0.1 retune: per-entity telegraph, locked lanes and recovery.
// No legacy selectors. Old persisted counters and cursor fields are retained.
fn live_actor(event) {
// Current API refreshes actor after earlier committed callbacks. An explicit
// absent/dead actor must not fall back to a stale queued event payload.
if event.contains("actor") {
if type_of(event.actor) == "map" { return event.actor; }
return #{health:0,x:0,y:0};
}
false
// Historical API fallback only: its entities snapshot is also current.
for enemy in event.entities { if enemy.entity == event.entity { return enemy; } }
#{health:0,x:0,y:0}
}
fn initialize(state, key) {
if !state.contains("units") { state.units = #{}; }
if !state.units.contains(key) {
state.units[key] = #{ age: 0, direction: 1, shots: 0 };
fn settings(event) {
// API v1 fallback is Training until the centrally supplied field is present.
if event.contains("difficulty") { return event.difficulty; }
0
}
state
fn cursor(event, state, key) {
let unit = if state.contains("units") && state.units.contains(key) {state.units[key]} else {#{}};
if !unit.contains("direction") { unit.direction = if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1}; }
if !unit.contains("age") { unit.age = if state.contains("ages") && state.ages.contains(key) {state.ages[key]} else {0}; }
if !unit.contains("clock") { unit.clock = 0; }
if !unit.contains("volley") { unit.volley = if unit.contains("shots") {unit.shots} else {0}; }
if !unit.contains("phase") { unit.phase = 0; }
if !unit.contains("max_health") { unit.max_health = live_actor(event).health; }
if unit.max_health < 1 { unit.max_health = 1; }
if !unit.contains("lock_x") { unit.lock_x = event.player.x + 16; }
if !unit.contains("pattern") { unit.pattern = unit.volley % 3; }
unit
}
fn on_spawn(event, state, random) {
if !is_alive(event) { return #{ commands: [], state: state }; }
let key = event.entity.to_string();
state = initialize(state, key);
// A pilot save contains previous script state; this is a fresh enemy instance.
state.units[key] = #{ age: 0, direction: 1, shots: 0 };
if !state.contains("units") { state.units = #{}; }
if !state.contains("dead") { state.dead = #{}; }
state.dead.remove(key);
if state.contains("dead_order") {let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;}
// Register even a queued spawn killed before this callback: its death counts.
state.units[key] = #{direction:1,age:0,clock:0,volley:0,phase:0,max_health:live_actor(event).health,lock_x:event.player.x+16,pattern:0};
if live_actor(event).health <= 0 { return #{commands:[],state:state}; }
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}
}
fn on_tick(event, state, random) {
// A lethal hit earlier in this tick must never create a ghost attack.
if event.health <= 0 || !is_alive(event) { return #{ commands: [], state: state }; }
if event.y < 24 {
let actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let unit = cursor(event,state,key);
if actor.y < 18 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
let key = event.entity.to_string();
state = initialize(state, key);
let unit = state.units[key];
unit.age += 1;
if event.x >= 144 { unit.direction = -1; }
if event.x <= 80 { unit.direction = 1; }
let commands = [
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
#{ op: "velocity", entity: event.entity, vx: unit.direction, vy: 0 }
];
// 12 ticks of warning before the first muzzle fires, then three shells six
// ticks apart. The right/center/left lanes leave space to dodge the fan.
let phase = unit.age % 54;
if phase == 36 {
let difficulty = settings(event);
unit.difficulty=difficulty;
let phase = if actor.health*3 <= unit.max_health {2} else if actor.health*3 <= unit.max_health*2 {1} else {0};
// A health transition always restarts the full warning; it cannot skip ahead
// to an untelegraphed salvo when the period changes.
if phase != unit.phase { unit.phase=phase; unit.clock=0; }
let period = 96 - difficulty*6 - phase*6;
let lead = 18-difficulty*2;
let speed = 3 + if difficulty >= 2 {1} else {0};
if unit.clock >= period { unit.clock=0; }
let width=96;
let left=actor.x+31;
let center=actor.x+width/2;
let right=actor.x+65;
let muzzle_y=actor.y+54;
let center_y=actor.y+58;
let commands=[];
if unit.clock == 0 {
unit.lock_x=event.player.x+16;
unit.pattern=(unit.volley+phase)%3;
commands.push(#{op:"play_sound",sound:"iron_coast_boss_5:warning"});
if unit.pattern == 2 {commands.push(#{op:"arc",x:center-4,y:center_y,to_x:center+4,to_y:center_y,ticks:8,anchor:event.entity,to_anchor:event.entity});}
else {
commands.push(#{op:"arc",x:left-4,y:muzzle_y,to_x:left+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"arc",x:right-4,y:muzzle_y,to_x:right+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
}
if phase == 48 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_5:plasma", x: event.x + 24, y: event.y + 55, vx: -2, vy: 4, hostile: true });
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
if unit.clock == lead {
if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:left,y:muzzle_y,vx:1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:right,y:muzzle_y,vx:-1,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); }
}
if unit.clock == lead+6 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:mortar",x:center,y:center_y,vx:if unit.pattern == 2 {aim} else {0},vy:speed-1,hostile:true}); }
if unit.clock == lead+12 && unit.pattern != 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss_5:shot"});
unit.shots += 1;
}
if phase == 0 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_5:mortar", x: event.x + 44, y: event.y + 58, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_5:shot" });
unit.shots += 1;
}
if phase == 6 && unit.age > 6 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_5:plasma", x: event.x + 64, y: event.y + 55, vx: 2, vy: 4, hostile: true });
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_5:shot" });
unit.shots += 1;
state.shots=if state.contains("shots") {state.shots+1} else {1};
}
// Warning and short burst are stationary hit windows; reposition only in
// recovery. The ship never leaves the forward-gun reach or dives into player.
let resting=unit.clock <= lead+12;
if actor.x >= 320-width-32 {unit.direction=-1;}
if actor.x <= 32 {unit.direction=1;}
let vx=if resting {0} else {unit.direction*(1+if difficulty>=2 {1} else {0})};
let triangle=if (unit.age/12)%8<4 {(unit.age/12)%8} else {8-(unit.age/12)%8};
let y=if resting {actor.y} else {18+if phase>0 {triangle*10/4} else {0}};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if unit.clock == lead+12 {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
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) {
if state.contains("units") { state.units.remove(event.entity.to_string()); }
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("dead_order") {state.dead_order=[];}
state.dead_order.push(key);
if state.dead_order.len()>32 {let old=state.dead_order.remove(0);state.dead.remove(old);}
if state.contains("units") {state.units.remove(key);}
if state.contains("directions") {state.directions.remove(key);}
if state.contains("ages") {state.ages.remove(key);}
state.destroyed=if state.contains("destroyed") {state.destroyed+1} else {1};
let defeated=if event.objectives.contains("iron_coast_boss_5:defeated") {event.objectives["iron_coast_boss_5:defeated"]+1} else {1};
#{ commands: [
#{ op: "objective", key: "iron_coast_boss_5:defeated", value: defeated },
#{ op: "effect", x: event.x + 24, y: event.y + 40 },
#{ op: "effect", x: event.x + 64, y: event.y + 40 },
#{ op: "play_sound", sound: "iron_coast_boss_5:death" }
], state: state }
#{commands:[#{op:"objective",key:"iron_coast_boss_5:defeated",value:defeated},
#{op:"effect",x:event.x+96/3,y:event.y+64/2},
#{op:"effect",x:event.x+96*2/3,y:event.y+64/2},
#{op:"play_sound",sound:"iron_coast_boss_5:death"}],state:state}
}
+8 -47
View File
@@ -1,54 +1,15 @@
# Iron Crown boss
# Iron Coast: Iron Crown Boss
A standalone API 1 mod for the Iron Coast finale. It adds a new command
Dreadnought with broad armored wings and three forward cannon pods, matching
the industrial overhead style of the original game. The package has no mod
dependencies and does not replace any original or Iron Coast enemy definition.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
- Enemy: `iron_coast_boss_6:unit`; health 160, reward 1800, air boss, logical 96×64.
- Weapon: `iron_coast_boss_6:projectile`; damage 3, speed 4, logical 8×8.
- Objective: every actual boss death increments `iron_coast_boss_6:defeated`.
- Entry: fly downward to y 24, then patrol between x 80 and x 144.
- Attack: every 54 simulation ticks, alternate a three-shell fan with two
straight cannon lanes. A rising charging sound begins nine ticks before
each volley. At 70/3 Hz this gives 0.386 seconds warning before a 2.314-second
volley cycle. Charging creates no explosion effects.
- State: per-entity patrol direction, age and volley cursor; removed on death.
Supports the finale's two staged bosses and simultaneous spawns. It never
completes a mission directly or fires from retired entities.
Enemy `iron_coast_boss_6:unit` is an airborne boss with **420** nominal health and a **108×72** logical canvas. Actual destruction awards 1,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Difficulty uses the engine's original boss health rule: definitions retain 160
health, and the two lower difficulty levels spawn bosses with 80 health.
Cannon fan / open-center twin gate / alternating-side follow-up. Each life has three health phases (above two-thirds, at/below two-thirds, at/below one-third of its actual spawn health). Every phase change restarts a full warning. Brief 8-pixel, 8-tick anchored charge arcs and existing quiet audio precede the salvo; the warning and short burst hold position, then the boss recovers into a bounded lateral patrol. Gun positions follow the existing artwork. No homing retarget occurs after aim is locked.
`art/unit-sd.png` and `art/unit-hd.png` share one centered 96×64 logical canvas.
The HD texture is 768×512 (8×) and the SD version is derived from the same
transparent generated master. The cannon shell likewise has matching 8×8 and
64×64 versions. `art-qa.json` records bounds and source hashes. The supplied
alpha is preserved; runtime colors with alpha below 128 remain transparent.
Warning leads are 18 / 16 / 14 / 12 ticks for Training / Rookie / Veteran / Elite. Recovery leaves a new approach opportunity; watch the charge cue and move into the opening before the volley.
The built-in imagegen tool generated both masters. Exact prompts live in
`prompts/`; `provenance.json` records their outputs. All authored images and
sounds belong in Git. Original sprite references remain outside this package.
Only actual death increments `iron_coast_boss_6:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
Reproduce the deterministic sounds and SD/HD reductions with:
## Validation and history
```sh
python3 RustRaptor/mods/iron_coast_boss_6/build_assets.py
```
`--audio-only` needs only Python's standard library. `audio-recipe.json`
records seeds, PCM properties and hashes. The sounds are non-silent PCM16
mono 22050 Hz charging, firing and destruction cues; no recordings are used.
Validate activation and lifecycle behavior using the production simulation:
```sh
CARGO_TARGET_DIR=RustRaptor/target cargo run --offline \
--manifest-path RustRaptor/mods/iron_coast_boss_6/validation/Cargo.toml
```
The validation checks alternating patterns, warning timing, independent
entity state, serialized state restoration, death counter/reward behavior,
dead-enemy firing guards and two staged encounters on all four difficulties.
`validation-results.json` records the controlled outcomes. The complete
mission 6 mod runs separate campaign and balance checks with its other enemies.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+4 -4
View File
@@ -20,17 +20,17 @@
{
"id": "iron_coast_boss_6:unit",
"name": "Iron Crown Command Dreadnought",
"health": 160,
"health": 420,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 90,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 96,
"logical_height": 64
"logical_width": 108,
"logical_height": 72
},
"script": "scripts/ai.rhai"
}
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss_6"
name = "Iron Coast: Iron Crown Boss"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "The Iron Crown command dreadnought: a persistent finale boss with telegraphed alternating cannon fans and twin lanes. Includes matching SD/HD artwork and synthesized sound."
description = "A three-phase Iron Crown encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+110 -58
View File
@@ -1,81 +1,133 @@
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Private 1.0.1 retune: per-entity telegraph, locked lanes and recovery.
// No legacy selectors. Old persisted counters and cursor fields are retained.
fn live_actor(event) {
// Current API refreshes actor after earlier committed callbacks. An explicit
// absent/dead actor must not fall back to a stale queued event payload.
if event.contains("actor") {
if type_of(event.actor) == "map" { return event.actor; }
return #{health:0,x:0,y:0};
}
false
// Historical API fallback only: its entities snapshot is also current.
for enemy in event.entities { if enemy.entity == event.entity { return enemy; } }
#{health:0,x:0,y:0}
}
fn settings(event) {
// API v1 fallback is Training until the centrally supplied field is present.
if event.contains("difficulty") { return event.difficulty; }
0
}
fn cursor(event, state, key) {
let unit = if state.contains("units") && state.units.contains(key) {state.units[key]} else {#{}};
if !unit.contains("direction") { unit.direction = if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1}; }
if !unit.contains("age") { unit.age = if state.contains("ages") && state.ages.contains(key) {state.ages[key]} else {0}; }
if !unit.contains("clock") { unit.clock = 0; }
if !unit.contains("volley") { unit.volley = if unit.contains("shots") {unit.shots} else {0}; }
if !unit.contains("phase") { unit.phase = 0; }
if !unit.contains("max_health") { unit.max_health = live_actor(event).health; }
if unit.max_health < 1 { unit.max_health = 1; }
if !unit.contains("lock_x") { unit.lock_x = event.player.x + 16; }
if !unit.contains("pattern") { unit.pattern = unit.volley % 3; }
unit
}
// State is shared by a script, so each staged or simultaneous boss has its own
// patrol direction, charge timer and alternating attack cursor.
fn on_spawn(event, state, random) {
if state.contains("last_death") && state.last_death == event.entity { state.remove("last_death"); }
if !is_alive(event) { return #{ commands: [], state: state }; }
if !state.contains("units") { state.units = #{}; }
state.units[event.entity.to_string()] = #{direction: 1, age: 0, volley: 0};
#{ 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 }; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let cursor = if state.units.contains(key) {
state.units[key]
} else { #{direction: 1, age: 0, volley: 0} };
if event.y < 24 {
state.units[key] = cursor;
if !state.contains("dead") { state.dead = #{}; }
state.dead.remove(key);
if state.contains("dead_order") {let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;}
// Register even a queued spawn killed before this callback: its death counts.
state.units[key] = #{direction:1,age:0,clock:0,volley:0,phase:0,max_health:live_actor(event).health,lock_x:event.player.x+16,pattern:0};
if live_actor(event).health <= 0 { return #{commands:[],state:state}; }
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}
}
fn on_tick(event, state, random) {
let actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let unit = cursor(event,state,key);
if actor.y < 12 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
if event.x >= 144 { cursor.direction = -1; }
if event.x <= 80 { cursor.direction = 1; }
cursor.age += 1;
let commands = [
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
#{ op: "velocity", entity: event.entity, vx: cursor.direction, vy: 0 }
];
// Nine simulation ticks (0.386 seconds) of audible warning precede each
// volley. Charging does not create an explosion in front of the aircraft.
if cursor.age % 54 == 45 {
let difficulty = settings(event);
unit.difficulty=difficulty;
let phase = if actor.health*3 <= unit.max_health {2} else if actor.health*3 <= unit.max_health*2 {1} else {0};
// A health transition always restarts the full warning; it cannot skip ahead
// to an untelegraphed salvo when the period changes.
if phase != unit.phase { unit.phase=phase; unit.clock=0; }
let period = 90 - difficulty*6 - phase*6;
let lead = 18-difficulty*2;
let speed = 3 + if difficulty >= 2 {1} else {0};
if unit.clock >= period { unit.clock=0; }
let width=108;
let left=actor.x+37;
let center=actor.x+width/2;
let right=actor.x+71;
let muzzle_y=actor.y+61;
let center_y=actor.y+67;
let commands=[];
if unit.clock == 0 {
unit.lock_x=event.player.x+16;
unit.pattern=(unit.volley+phase)%3;
commands.push(#{op:"play_sound",sound:"iron_coast_boss_6:warning"});
if unit.pattern == 2 {commands.push(#{op:"arc",x:center-4,y:center_y,to_x:center+4,to_y:center_y,ticks:8,anchor:event.entity,to_anchor:event.entity});}
else {
commands.push(#{op:"arc",x:left-4,y:muzzle_y,to_x:left+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"arc",x:right-4,y:muzzle_y,to_x:right+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
}
if cursor.age % 54 == 0 {
if cursor.volley % 2 == 0 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 28, y: event.y + 60, vx: -2, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 48, y: event.y + 60, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 68, y: event.y + 60, vx: 2, vy: 4, hostile: true });
} else {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 28, y: event.y + 60, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 68, y: event.y + 60, vx: 0, vy: 4, hostile: true });
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
if unit.clock == lead {
if unit.pattern == 0 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:center,y:center_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:left,y:muzzle_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:right,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
}
if unit.clock == lead+8 && unit.pattern == 2 && difficulty >= 1 {
let active = if unit.volley % 2 == 0 {left} else {right};
commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:active,y:muzzle_y,vx:0,vy:speed,hostile:true});
}
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss_6:shot"});
cursor.volley += 1;
state.shots=if state.contains("shots") {state.shots+1} else {1};
}
state.units[key] = cursor;
// Warning and short burst are stationary hit windows; reposition only in
// recovery. The ship never leaves the forward-gun reach or dives into player.
let resting=unit.clock <= lead+8;
if actor.x >= 320-width-32 {unit.direction=-1;}
if actor.x <= 32 {unit.direction=1;}
let vx=if resting {0} else {unit.direction*(1+if difficulty>=2 {1} else {0})};
let triangle=if (unit.age/12)%8<4 {(unit.age/12)%8} else {8-(unit.age/12)%8};
let y=if resting {actor.y} else {12+if phase>0 {triangle*10/4} else {0}};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if unit.clock == lead+8 {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
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();
// Retired entities cannot fire. Preserve other live bosses' cursors, and
// still count an aircraft destroyed before its queued spawn hook runs.
if state.contains("last_death") && state.last_death == event.entity {
return #{ commands: [], state: state };
}
state.last_death = event.entity;
if !state.contains("dead") {state.dead=#{};}
if state.dead.contains(key) {return #{commands:[],state:state};}
state.dead[key]=true;
if !state.contains("dead_order") {state.dead_order=[];}
state.dead_order.push(key);
if state.dead_order.len()>32 {let old=state.dead_order.remove(0);state.dead.remove(old);}
if state.contains("units") {state.units.remove(key);}
if state.contains("directions") {state.directions.remove(key);}
if state.contains("ages") {state.ages.remove(key);}
state.destroyed=if state.contains("destroyed") {state.destroyed+1} else {1};
let defeated = if event.objectives.contains("iron_coast_boss_6:defeated") {
event.objectives["iron_coast_boss_6:defeated"] + 1
} else { 1 };
#{ commands: [
#{ op: "objective", key: "iron_coast_boss_6:defeated", value: defeated },
#{ op: "effect", x: event.x + 28, y: event.y + 44 },
#{ op: "effect", x: event.x + 68, y: event.y + 44 },
#{ op: "play_sound", sound: "iron_coast_boss_6:death" }
], state: state }
let defeated=if event.objectives.contains("iron_coast_boss_6:defeated") {event.objectives["iron_coast_boss_6:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_boss_6:defeated",value:defeated},
#{op:"effect",x:event.x+108/3,y:event.y+72/2},
#{op:"effect",x:event.x+108*2/3,y:event.y+72/2},
#{op:"play_sound",sound:"iron_coast_boss_6:death"}],state:state}
}
+12 -53
View File
@@ -1,62 +1,21 @@
# Depth Charge 1.0.0
# Depth Charge
Enable **Depth Charge** in Mods, apply at the main menu, then buy it in the shop
for **525,000 credits**. It is an independent API 1 mod without dependencies or
overrides. Maximum one; resale returns 262,500 credits. It occupies a normal
inventory slot and preserves other equipment.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
This is a **selectable, reusable ground/naval special weapon**. Choose it in
Inventory or with Alt/Shift, then hold Ctrl to launch. Space remains the owned
mega-bomb control. It does not consume charges or auto-attack merely because it
is equipped. Always-equipped forward guns remain available alongside it.
Ground/naval blast, 36px radius. Selectable reusable ground/naval special. Ctrl launches a slow charge; a real ground hit deals 72 damage and splashes at most three other ground enemies within 36 pixels (36 inner / 20 outer). Deliberate 2.06-second cadence; no air damage or ammunition consumed. Alt/Shift selects weapons; Space drops mega bombs.
The native straight projectile travels six logical pixels per simulation tick.
A real ground-enemy collision deals 72 primary damage and starts one local
ground-blast effect. At most three other living ground enemies can receive
collateral damage: 36 damage within 16 pixels of the primary enemy's center,
or 20 damage between 16 and 36 pixels inclusive. The closest neighbors are
selected first, with stable entity order breaking ties. Aircraft, offscreen
enemies, repeated targets and centers outside that radius are excluded. If the
primary target dies, its contact still triggers the local blast correctly.
Buy **Depth Charge** for **525,000 credits**; carry at most 1. Select it through Inventory or the Cycle control when it is a special weapon.
The deliberate 48-tick cooldown is about 2.06 seconds at 70/3 Hz. The charge does
not home or detonate automatically in empty space. Slow travel makes moving
ground targets an aiming challenge; there is no screen-wide damage command.
Destructible terrain receives native primary damage, but cannot trigger scripted
collateral because the public snapshot contains enemies rather than terrain
cells. Enemy contacts emit impact audio; a miss produces only the launch sound
and projectile. The normal native terrain destruction effects remain available.
`iron_coast_depth_charge:projectile`: 72 damage, speed 6, 48-tick cooldown, `straight` behavior.
SD equipment/projectile canvases are 16×16 and 8×12; detailed HD variants are
64×64 and 32×48. Options selects SD or HD artwork without changing logical
placement or damage. Both are single aligned frames with transparent margins.
[Artwork provenance](art-provenance.json) records unchanged generated masters,
prompts, final PNG hashes and the Rust preparation recipe. All new images, WAVs,
source masters and recipes are included; decoded original style references are
kept outside the mod.
## Feedback
`python3 generate_audio.py` reproduces the mechanical launch and low naval impact
from deterministic synthesis without external recordings. `prepare_art.rs`
rebuilds SD/HD fitting from the included source masters. The companion Railburst
package's [review helper](../iron_coast_railburst/validation/review.py) is a developer
tool for both packages and is not a gameplay dependency.
The unchanged 8×12 SD / 32×48 HD projectile files now display on a proportional **6×9 logical canvas**. Point collision, 6px/tick speed, 48-tick cadence, 72 damage, ground-only targeting, three-neighbor limit and 16/36px falloff remain unchanged. A real enemy hit shows one compact **16px / 15-tick** impact (0.64s) instead of the former 40px / 43-tick ground blast (1.84s); the visual is a contact marker, not a drawn damage-radius boundary. Fire/impact volume is 80/72. Price stays 525,000.
From the RustRaptor directory:
Only declared display sizing, cosmetic hit commands and playback levels/priorities change. No PNG/WAV pixels or samples, equipment icon dimensions, projectile collision rules, damage, cooldown, offsets, targeting, ammunition, capacity, controls or prices are changed. No new muzzle explosion or persistent trail is emitted. Normal original enemy deaths and terrain-destruction effects remain engine-controlled.
```sh
cargo test --locked --no-default-features --test iron_coast_stage2_weapons
cargo run --locked --no-default-features -- --data data --mods mods \
--validate-mods iron_coast_depth_charge
IRON_COAST_STAGE2_BALANCE_REPORT=/tmp/stage2-balance.json \
cargo test --locked --no-default-features --test iron_coast_stage2_weapons \
real_catalog_700_tick_balance_and_coexistence_report -- --ignored --nocapture
```
The three rapid weapons retain their prior burst/hit counters and reset only their cosmetic tick/projectile gates when mission ticks restart, accepting older saved script state without new fields. This version changes the package save pin; existing pilot compatibility confirmation remains the normal mod mechanism.
[validation/balance.json](validation/balance.json) records the actual controlled
700-tick damage observations supporting this price and role. Portable checks
cover ground filtering, inner/outer falloff, the finite neighbor cap, misses,
kills, cooldown, independent activation, coexistence, half-price resale, full
inventory preservation, deterministic state/RNG continuation and whole-tick
rollback. [validation/review.json](validation/review.json) distinguishes public
decoder/blitter/mixer captures from live desktop review. Read the [API](../../docs/modding-api.md)
for save pins and script execution limits.
## Validation and history
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+6 -6
View File
@@ -17,8 +17,8 @@
"art": {
"png": "art/projectile-sd.png",
"hd_png": "art/projectile-hd.png",
"logical_width": 8,
"logical_height": 12,
"logical_width": 6,
"logical_height": 9,
"nearest": true
},
"script": "scripts/weapon.rhai"
@@ -49,19 +49,19 @@
"id": "iron_coast_depth_charge:fire",
"name": "Depth Charge Fire",
"file": "audio/fire.wav",
"volume": 88,
"volume": 80,
"pan": 127,
"pitch": 128,
"priority": 1
"priority": 2
},
{
"id": "iron_coast_depth_charge:impact",
"name": "Depth Charge Impact",
"file": "audio/impact.wav",
"volume": 82,
"volume": 72,
"pan": 127,
"pitch": 128,
"priority": 2
"priority": 3
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_depth_charge"
name = "Depth Charge"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Independent Iron Coast II selectable upgrade with SD/HD artwork and procedural fire/impact audio."
content = "content.json"
@@ -20,7 +20,7 @@ fn on_hit(event, state, random) {
}
}
let commands = [#{op: "play_sound", sound: "iron_coast_depth_charge:impact"},
#{op: "effect", kind: "ground_blast", x: center_x, y: center_y}];
#{op: "effect", kind: "small_air", x: center_x, y: center_y}];
let struck = [event.entity];
for neighbor in 0..3 {
let chosen = #{};
+11 -39
View File
@@ -1,45 +1,17 @@
# Iron Coast — Mission 1: Breakwater
Enable this package in the in-game Mods screen to select its seven installed dependencies automatically:
`iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss`, `pulse_cannon`,
`seeker_swarm`, `ion_lance`, and `iron_coast_music`. Each package is independent and version 1.0.0.
Choose **Iron Coast 1 — Breakwater** in the mission list. The original
Coastal Steel FM soundtrack loops during flight.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss`, `pulse_cannon`, `seeker_swarm`, `ion_lance`, `iron_coast_music`.
Disable at least two harbor turrets and destroy the command ship to complete
the raid. Scout waves arrive during the approach; two supply depots release
repair kits. When inventory space allows, the mission grants a trial Pulse
Cannon loan and three field repairs. The loan is returned before the completed
flight is saved; a cannon you already own is preserved. Field repairs cost
12,500 credits each in the shop, a premium over the original 10,000-credit
hangar energy refill for use during combat.
Repairs restore 25 shield when selected and used with the special button.
Victory awards 5,000 credits in addition to ordinary destruction rewards.
A full inventory can still start the mission; existing equipment is preserved
and new grants are skipped when there is no free slot.
Mission `iron_coast_mission_1:mission_01`: **Iron Coast 1 — Breakwater**. Destroy two required turrets and the Command Cruiser. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
This mission uses explicit script completion. Its duration is a catalog hint;
waiting out the timer cannot win. The boss stays in the firing corridor, and
surviving objective turrets stay on screen and can be hit with starter forward guns. Equipment and
mission progress remain subject to the normal mod-save compatibility checks.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **25 / 34 / 43 / 52**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
The generated masters, packaged SD/HD PNGs and synthesized WAVs are included
in Git with their definitions, scripts, generator, prompts and validation
report. Original game data and the original style-reference PNGs remain
outside Git. The three
`art/*-source-hd.png` inputs were generated with the built-in imagegen tool
using the original terrain and military sprite as style/scale references.
Run `python3 mods/iron_coast_mission_1/build_assets.py --palette
../data/misc/PALETTE_DAT` to regenerate the exact
packaged crops, SD palette conversion, HD footprints and synthesized WAVs.
See `prompts.md`, `asset-report.json`, and [Iron Coast documentation](../../docs/iron-coast.md) for provenance
and validation limits.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
The release acceptance run completes with starter guns at step **882**
(shield 94) and the trial Pulse Cannon at step **786** (shield 92), both with
7,715 credits. These are deterministic headless runs on difficulty 2: initial
shield 100, held fire, position fixed at (144,160), repairs selected on step 2
and the special button pressed every 90 steps starting at step 61. They use
the mission's granted/dropped repairs; they apply no direct shield refills or
enemy damage commands. Human-play difficulty and audiovisual approval remain
subject to normal play testing.
## Equipment supplied by the mission
The mission supplies three Field Repair kits when capacity allows (25 energy per real Special use) and a temporary Pulse Cannon trial if one is not already carried. The Pulse Cannon is an automatic primary; hold Fire. The trial is removed at mission end.
## Validation and history
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+303 -41
View File
@@ -97,7 +97,7 @@
"missions": [
{
"id": "iron_coast_mission_1:mission_01",
"name": "Iron Coast 1 \u2014 Breakwater",
"name": "Iron Coast 1 — Breakwater",
"duration": 2000,
"auto_complete": false,
"columns": 9,
@@ -2699,106 +2699,368 @@
],
"spawns": [
{
"tick": 30,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 68,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 65,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 200,
"y": -24
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 110,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 150,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 228,
"y": -24
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 118,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 118,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 118,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 118,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 118,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 180,
"enemy": "iron_coast_turret:unit",
"x": 144,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 224,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 224,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 224,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 224,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 224,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 230,
"enemy": "iron_coast_mission_1:depot",
"x": 144,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 270,
"tick": 326,
"enemy": "iron_coast_scout:unit",
"x": 88,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 305,
"tick": 326,
"enemy": "iron_coast_scout:unit",
"x": 180,
"y": -24
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 345,
"tick": 326,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 326,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 326,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 350,
"enemy": "iron_coast_turret:unit",
"x": 144,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 438,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 438,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 438,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 438,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 438,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 440,
"enemy": "iron_coast_turret:unit",
"x": 48,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 450,
"enemy": "iron_coast_mission_1:depot",
"x": 144,
"y": -32
},
{
"tick": 490,
"enemy": "iron_coast_scout:unit",
"x": 72,
"y": -24
},
{
"tick": 530,
"enemy": "iron_coast_scout:unit",
"x": 210,
"y": -24
"y": -32,
"difficulty": 0
},
{
"tick": 540,
"enemy": "iron_coast_turret:unit",
"x": 240,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 600,
"tick": 554,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 554,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 554,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 554,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 554,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 626,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 626,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 626,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 626,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 626,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 720,
"enemy": "iron_coast_boss:unit",
"x": 112,
"y": -64
"y": -64,
"difficulty": 0
},
{
"tick": 740,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 740,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 740,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 740,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 740,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 860,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 860,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 860,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 860,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 860,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
}
],
"art": {
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_mission_1"
name = "Iron Coast — Mission 1: Breakwater"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "Raid an industrial shoreline, disable two harbor turrets and destroy the Breakwater command ship. Includes SD/HD port terrain, supply depots, a trial Pulse Cannon and synthesized mission audio."
content = "content.json"
+9 -68
View File
@@ -1,76 +1,17 @@
# Iron Coast 2 — Tidal Relay
# Iron Coast — Mission 2: Tidal Relay
Disrupt the tidal communications network after the Breakwater raid. Destroy
all three armored navigation relays, then defeat the **Relay Warden** radar
carrier. Relays anchor in the firing corridor until destroyed; neither an
escaped optional enemy nor an early boss kill can lock the objectives.
Each relay yields 140 credits and recovers 12 ordinary energy. Victory pays
7,500 credits in addition to normal destruction and salvage rewards.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss_2`, `iron_coast_music`, `pulse_cannon`, `seeker_swarm`, `ion_lance`.
Enable this package in **Mods** to select its eight-package standalone set:
this mission, `iron_coast_scout`, `iron_coast_turret`, `pulse_cannon`,
`seeker_swarm`, `ion_lance`, `iron_coast_boss_2`, and `iron_coast_music`.
Choose **Iron Coast 2 — Tidal Relay** in the mission menu. The optional
`iron_coast_campaign` assembly package connects it to the full six-level
route. This individual package has no next-mission override and can be played
without the other five mission packages.
Mission `iron_coast_mission_2:mission_02`: **Iron Coast 2 — Tidal Relay**. Destroy all three relays and the Relay Warden. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
The flight grants a mission-only Pulse Cannon when a free inventory slot
exists. The cannon is returned on success or failure; a cannon already owned
by the pilot is retained. A full 20-slot inventory skips the loan. No kits,
ammunition, or premium weapon purchases are required for the acceptance route.
The duration of 2,300 ticks is a catalog hint; timer expiry does not win.
The Relay Warden arrives at tick 860 and remains available until destroyed.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **29 / 40 / 51 / 62**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
The waves mix Iron Coast scouts and turrets with untouched original enemy
registry definitions and their native animation, guns, and movement:
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
| Spawn | Original definition | Native movement |
| --- | --- | --- |
| Tick 75, x216 | `base:enemy_0_20` / SHIP05G1_PIC | Six-leg zigzag entry, speed 3 |
| Tick 330, x168 | `base:enemy_0_23` / SHIP06G1_PIC | Wide crossing sweep, speed 3 |
| Tick 620, x176 | `base:enemy_0_56` / SHIP25G1_PIC | Authored entry then player chase, speed 5 |
## Equipment supplied by the mission
All three appear on every difficulty. Their original assets remain in the
separately installed game catalog; this package neither duplicates nor
redefines original enemy data. New package-local art supplies the relay
station, briefing and scrolling tidal causeway in SD and HD. The shared
**Coastal Steel** soundtrack is a 96-second generated MUS loop played by
the Rust FM synthesizer. Six local WAV cues cover approach, network alert,
relay destruction, network shutdown, boss arrival and victory.
A temporary Pulse Cannon trial is supplied when it is not already carried and an inventory slot is available. It fires automatically with the primary Fire control and is returned after the mission.
All three generated art masters, 216 pairs of terrain crops, SD/HD briefing
and relay sprites, WAVs, scripts and provenance are included in Git.
`prompts.json` and `prompts.md` record the exact built-in imagegen prompts.
The original VGA palette remains outside Git. The builder converts the full
SD terrain canvas before splitting tiles, uses a reflected scroll period with
identical repeat boundaries, and normalizes excessive transparent padding
around the relay silhouette without changing its logical 32×32 canvas.
## Validation and history
```sh
python3 RustRaptor/mods/iron_coast_mission_2/build_assets.py
cargo run --locked --manifest-path RustRaptor/Cargo.toml --no-default-features \
-- --data data --mods RustRaptor/mods \
--validate-mods iron_coast_scout,iron_coast_turret,pulse_cannon,seeker_swarm,ion_lance,iron_coast_boss_2,iron_coast_music,iron_coast_mission_2
cargo run --locked \
--manifest-path RustRaptor/mods/iron_coast_mission_2/validation/Cargo.toml \
--target-dir RustRaptor/target --bin validate-mission
```
The standalone validator uses the actual engine and locally installed original
catalog. It preserves script state between four mission restarts, exercises a
registered 75-energy/10,000-credit pilot, a 100-energy fixture, an owned cannon
and a full inventory, and checks exact completion gating and one-time reward.
It fires continuously at y160, moving at most four pixels per tick toward the
first live relay, or x144 when no relay is alive. It injects no damage, shield,
credit or purchased-weapon commands during its playable routes.
The normal registration run wins at **tick 926**, with **86 energy** and
**21,365 total credits** (11,365 earned). All three native enemies are observed
moving. The full-inventory run wins at tick 930 with 82 energy, no loaned cannon
and ordinary equipment preserved. Combat shield charge may naturally change.
The owned-cannon run retains the pilot's cannon. All six local cues dispatch.
See `acceptance-report.json` for exact independent/restarted-run results and
`asset-report.json` for palette, seam, transparency and waveform checks.
Human play difficulty and audiovisual preference still require normal play
review; these controlled simulations establish a viable path.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+371 -39
View File
@@ -79,7 +79,7 @@
"missions": [
{
"id": "iron_coast_mission_2:mission_02",
"name": "Iron Coast 2 \u2014 Tidal Relay",
"name": "Iron Coast 2 — Tidal Relay",
"duration": 2300,
"auto_complete": false,
"columns": 9,
@@ -2681,106 +2681,438 @@
],
"spawns": [
{
"tick": 35,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 72,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 75,
"enemy": "base:enemy_0_20",
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 130,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 112,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 112,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 112,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 112,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 112,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 180,
"enemy": "iron_coast_mission_2:relay",
"x": 96,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 220,
"enemy": "base:enemy_0_23",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 220,
"enemy": "base:enemy_0_24",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 220,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 220,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 220,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 250,
"enemy": "iron_coast_turret:unit",
"x": 48,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 290,
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 88,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 330,
"enemy": "base:enemy_0_23",
"x": 168,
"y": -32
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 360,
"enemy": "iron_coast_mission_2:relay",
"x": 176,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 410,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 410,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 410,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 410,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 410,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 420,
"enemy": "iron_coast_turret:unit",
"x": 240,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 455,
"tick": 496,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 496,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 496,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 496,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 496,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 520,
"enemy": "iron_coast_mission_2:relay",
"x": 136,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 585,
"tick": 602,
"enemy": "iron_coast_scout:unit",
"x": 72,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 620,
"enemy": "base:enemy_0_56",
"x": 176,
"y": -32
"tick": 602,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 602,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 602,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 602,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 665,
"enemy": "iron_coast_turret:unit",
"x": 144,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 715,
"enemy": "iron_coast_scout:unit",
"x": 112,
"y": -24
"tick": 700,
"enemy": "base:enemy_0_20",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 760,
"tick": 700,
"enemy": "base:enemy_0_20",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 700,
"enemy": "iron_coast_scout:unit",
"x": 184,
"y": -24
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 700,
"enemy": "base:enemy_0_20",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 700,
"enemy": "base:enemy_0_20",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 774,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 774,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 774,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 774,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 774,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 860,
"enemy": "iron_coast_boss_2:unit",
"x": 112,
"y": -64
"y": -64,
"difficulty": 0
},
{
"tick": 888,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 888,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 888,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 888,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 888,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1008,
"enemy": "base:enemy_0_23",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1008,
"enemy": "base:enemy_0_24",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1008,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1008,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1008,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
}
],
"art": {
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_mission_2"
name = "Iron Coast — Mission 2: Tidal Relay"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "Disrupt three anchored radar relays across the tidal causeway, then destroy the command cruiser. Includes SD/HD communications-island terrain, relay artwork and synthesized mission cues."
content = "content.json"
+6 -60
View File
@@ -1,67 +1,13 @@
# Iron Coast — Mission 3: Saltworks Convoy
Intercept three armored cargo transports, disable two escort turrets and destroy
the **Saltbreaker** command cruiser. Cargo wrecks recover maintenance supplies
that restore 15 shield each. The completion bonus is 7,500 credits, in addition
to enemy destruction rewards. Waiting out the duration cannot complete it.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss_3`, `pulse_cannon`, `seeker_swarm`, `ion_lance`, `iron_coast_music`.
Enable this individual mod to select its installed dependencies automatically:
`iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss_3`, `pulse_cannon`,
`seeker_swarm`, `ion_lance`, and `iron_coast_music`, all compatible with `^1.0`.
Then choose **Iron Coast 3 — Saltworks Convoy** in the mission list. The optional
Iron Coast campaign assembly places this mission after Mission 2; the individual
mod works independently and does not override any other mission.
Mission `iron_coast_mission_3:mission_03`: **Iron Coast 3 — Saltworks Convoy**. Destroy three cargo transports, two escort turrets and Saltbreaker. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
The saltworks include rectangular evaporation basins, steel conveyors, brine
channels and a central cargo route. Eighteen fighter arrivals mix fourteen
scripted Iron Coast scouts with four original Raptor enemy definitions:
`base:enemy_0_20` uses a weaving descent; `base:enemy_0_21` and
`base:enemy_0_22` make opposing lateral sweeps; `base:enemy_0_73` makes a
curved approach. Their original sprite IDs, animation, weapons, movement paths,
health and rewards are retained. They spawn before the command cruiser.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **32 / 45 / 58 / 71**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
Cargo transports and required turrets stop at their interdiction lines instead
of scrolling out of reach. The Saltbreaker remains in its patrol corridor until
destroyed. A full 20-slot inventory can start and complete this mission: it
does not grant loans or additional equipment, and cargo recovery restores
shield directly. Mission start resets the objective and phase state on retries.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
All new artwork and sound are included in Git: three built-in imagegen master
images, their packaged SD and HD variants, five synthesized WAV cues, the
deterministic asset builder and provenance. SD uses the original game palette;
HD keeps the same logical canvases. Terrain is quantized as one complete image
before tile extraction. A forward/reflected repeat makes both scroll boundaries
match exactly. `asset-report.json` records dimensions, transparency, palette
checks, seam checks, source hashes and audio hashes.
## Validation and history
Regenerate the packaged images and cues from the checked-in masters:
```sh
python3 RustRaptor/mods/iron_coast_mission_3/build_assets.py
```
The default builder palette comes from the separately installed original catalog
at `data/misc/PALETTE_DAT`; use `--palette PATH` to select another copy. Original
game assets are not copied into the mod. `prompts.md` and `prompts.json` document
generation, including the style-reference roles.
Run the local acceptance probe from the repository root:
```sh
RustRaptor/mods/iron_coast_mission_3/validate.sh
```
The probe uses the actual original catalog and scripts, tests registered pilots
(75 shield and 10,000 credits) on difficulties 1–3, tests a default deterministic
fixture, checks that essential targets remain available, and checks restart,
full-inventory and single-reward behavior. Ordinary playthroughs use starter
forward guns with held fire at logical position `(144, 160)` and inject no
damage, credits, shield or inventory commands into the playthrough. The separate retention
test restores test shield only to observe waiting behavior; it is not used as
a successful gameplay proof. `validation-report.txt` records final results.
Final registered-pilot starter results: difficulty 1 completed at tick **1184**
with **88** shield; difficulties 2 and 3 completed at tick **1264** with
**83** shield. All three ended with **21,515** credits. The full 20-slot flight
completed at tick **1178** with 82 shield and retained every non-energy item
and count. No paid refills or premium weapon purchases are required.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+424 -71
View File
@@ -68,7 +68,7 @@
"missions": [
{
"id": "iron_coast_mission_3:mission_03",
"name": "Iron Coast 3 \u2014 Saltworks Convoy",
"name": "Iron Coast 3 — Saltworks Convoy",
"duration": 3000,
"auto_complete": false,
"columns": 9,
@@ -2886,148 +2886,501 @@
],
"spawns": [
{
"tick": 40,
"enemy": "base:enemy_0_20",
"x": 80,
"y": -32
},
{
"tick": 75,
"tick": 36,
"enemy": "iron_coast_scout:unit",
"x": 200,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 125,
"enemy": "base:enemy_0_21",
"x": 232,
"y": -32
"tick": 36,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 36,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 36,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 36,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 116,
"enemy": "base:enemy_0_23",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 116,
"enemy": "base:enemy_0_24",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 116,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 116,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 116,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 130,
"enemy": "iron_coast_turret:unit",
"x": 144,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 190,
"enemy": "iron_coast_mission_3:convoy",
"x": 148,
"y": -40
"y": -40,
"difficulty": 0
},
{
"tick": 235,
"enemy": "iron_coast_scout:unit",
"x": 228,
"y": -24
"tick": 214,
"enemy": "base:enemy_1_25",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 280,
"enemy": "iron_coast_scout:unit",
"x": 70,
"y": -24
"tick": 214,
"enemy": "base:enemy_1_26",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 340,
"tick": 214,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 405,
"tick": 214,
"enemy": "base:enemy_1_25",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 214,
"enemy": "base:enemy_1_26",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 306,
"enemy": "iron_coast_scout:unit",
"x": 200,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 306,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 306,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 306,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 306,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 398,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 398,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 398,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 398,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 398,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 430,
"enemy": "iron_coast_mission_3:convoy",
"x": 148,
"y": -40
"y": -40,
"difficulty": 0
},
{
"tick": 450,
"tick": 488,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 488,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 488,
"enemy": "iron_coast_scout:unit",
"x": 92,
"y": -24
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 488,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 488,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 500,
"enemy": "iron_coast_turret:unit",
"x": 144,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 555,
"tick": 582,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 582,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 582,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 615,
"enemy": "base:enemy_0_22",
"x": 64,
"y": -32
},
{
"tick": 660,
"tick": 582,
"enemy": "iron_coast_scout:unit",
"x": 70,
"y": -24
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 582,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 674,
"enemy": "base:enemy_1_25",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 674,
"enemy": "base:enemy_1_26",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 674,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 674,
"enemy": "base:enemy_1_25",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 674,
"enemy": "base:enemy_1_26",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 720,
"enemy": "iron_coast_mission_3:convoy",
"x": 148,
"y": -40
"y": -40,
"difficulty": 0
},
{
"tick": 730,
"tick": 774,
"enemy": "base:enemy_0_20",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 774,
"enemy": "base:enemy_0_20",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 774,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 790,
"tick": 774,
"enemy": "base:enemy_0_20",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 774,
"enemy": "base:enemy_0_20",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 868,
"enemy": "iron_coast_scout:unit",
"x": 210,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 865,
"tick": 868,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 868,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 868,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 868,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 980,
"enemy": "base:enemy_0_73",
"x": 128,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 920,
"tick": 980,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 980,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 975,
"enemy": "iron_coast_scout:unit",
"x": 220,
"y": -24
"tick": 980,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1030,
"enemy": "iron_coast_scout:unit",
"x": 80,
"y": -24
},
{
"tick": 1070,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"tick": 980,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1100,
"enemy": "iron_coast_boss_3:unit",
"x": 112,
"y": -64
"y": -64,
"difficulty": 0
},
{
"tick": 1128,
"enemy": "base:enemy_0_23",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1128,
"enemy": "base:enemy_0_24",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1128,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1128,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1128,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1260,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1260,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1260,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1260,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1260,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
}
],
"art": {
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_mission_3"
name = "Iron Coast — Mission 3: Saltworks Convoy"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "Intercept three armored cargo transports through the salt flats, disable their two escorts and destroy the convoy command cruiser. New SD/HD saltworks terrain, cargo sprites and synthesized radio cues."
content = "content.json"
+8 -61
View File
@@ -1,70 +1,17 @@
# Iron Coast — Mission 4: Stormwall
Enable this individual mission in **Mods**. Its seven installed dependencies
are selected automatically: `iron_coast_scout`, `iron_coast_turret`,
`iron_coast_boss_4`, `pulse_cannon`, `seeker_swarm`, `ion_lance`, and
`iron_coast_music`, all at
compatible 1.x versions. This mission is independently playable; the optional
Iron Coast campaign pack supplies campaign ordering.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss_4`, `pulse_cannon`, `seeker_swarm`, `ion_lance`, `iron_coast_music`.
Choose **Iron Coast 4 — Stormwall**. Scout flights screen four coastal
batteries on the right-hand cliff. Destroy all four emplacements and the
Stormwall Arsenal to finish. Turrets enter at ticks 180, 340, 620 and 820;
the Arsenal enters at 1150. Surviving batteries stop at logical y=72 and
the Arsenal patrols at the top of the battlefield until killed. Waiting out
the 3000-tick duration never completes the mission.
Mission `iron_coast_mission_4:mission_04`: **Iron Coast 4 — Stormwall**. Destroy four required turrets and Stormwall Arsenal. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
Six original aircraft entries mix with fourteen Iron Coast scouts. The
`base:enemy_0_73` and `base:enemy_0_74` pair follows curved nine-point
routes; `base:enemy_0_23` and `base:enemy_0_24` cross the battlefield;
`base:enemy_1_25` and `base:enemy_1_26` use a second original curved entry.
Their catalog definitions retain original flight points, speed, firing,
animation and rewards. This package references them directly and contains
no copied original sprites or definitions.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **35 / 50 / 65 / 84**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
Hold Fire, move under each battery on the right, then follow the Arsenal's
horizontal patrol. Its battery alarms and visible pauses warn before each
three-shell salvo. A Pulse Cannon is loaned when absent and an inventory slot
is free. It fires alongside the primary guns without weapon selection. The
loan is returned on success or failure; owned equipment is preserved. Full
20-slot inventories skip the loan and start without deleting equipment.
Storm effects are artwork and warning sounds; there is no hidden weather
damage. The separate soundtrack mod supplies the looping **Coastal Steel**
MUS composition through Rust FM synthesis. Victory awards 12,000 credits
plus ordinary enemy destruction rewards.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
The mission contains 216 SD/HD terrain pairs, matching SD/HD briefing art,
two generated source masters and four synthesized WAV cues. Artwork, sounds,
prompts and provenance are packaged here for Git. Original game data remains
external. See `prompts.json`, `prompts.md` and `asset-report.json`.
## Equipment supplied by the mission
Regenerate the deterministic packaging from the project root:
A temporary Pulse Cannon trial is supplied when needed and capacity allows. Hold Fire to use it alongside the forward guns; the temporary item is returned after the mission.
```sh
python3 RustRaptor/mods/iron_coast_mission_4/build_assets.py
```
## Validation and history
After building RustRaptor in release mode, validate the real catalog, scripts
and playthrough without changing any saves:
```sh
python3 RustRaptor/mods/iron_coast_mission_4/validate.py
```
Actual-input acceptance uses difficulty 2, held fire, y=160, and horizontal
movement limited to four logical pixels per step, aiming at a visible turret
first, then the Arsenal, then aircraft. It uses the mission loan and no
purchased equipment, direct damage commands or shield refills. Separate
tests check original enemies seen and moving, late reachable objectives,
reward gating, one-time payout, full-inventory victory, loan return on failure,
successful replay with retained script state and owned equipment.
Final revision results are recorded in `acceptance-report.json`.
The revised eight-package run passes seven focused tests. A normal
75-shield/10,000-credit pilot completes at tick **1218** with **63 shield
and 27,805 credits**; the 100-shield/zero-credit fixture ends at the same
step with **88 shield and 17,805 credits**. All six native aircraft types
are observed moving in both runs. A veteran with twenty occupied inventory
slots also completes without receiving the loan or losing equipment.
These controlled runs establish a viable path; they do not establish
balancing for every player, loadout or difficulty.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+514 -82
View File
@@ -40,7 +40,7 @@
"missions": [
{
"id": "iron_coast_mission_4:mission_04",
"name": "Iron Coast 4 \u2014 Stormwall",
"name": "Iron Coast 4 — Stormwall",
"duration": 3000,
"auto_complete": false,
"columns": 9,
@@ -2858,160 +2858,592 @@
],
"spawns": [
{
"tick": 35,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 56,
"y": -24
},
{
"tick": 85,
"enemy": "base:enemy_0_73",
"x": 170,
"y": -80,
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 140,
"enemy": "base:enemy_0_74",
"x": 70,
"y": -80,
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 32,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 124,
"enemy": "base:enemy_1_25",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 124,
"enemy": "base:enemy_1_26",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 124,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 124,
"enemy": "base:enemy_1_25",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 124,
"enemy": "base:enemy_1_26",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 180,
"enemy": "iron_coast_turret:unit",
"x": 224,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 245,
"enemy": "iron_coast_scout:unit",
"x": 104,
"y": -24
"tick": 220,
"enemy": "base:enemy_0_20",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 295,
"tick": 220,
"enemy": "base:enemy_0_20",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 220,
"enemy": "iron_coast_scout:unit",
"x": 184,
"y": -24
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 220,
"enemy": "base:enemy_0_20",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 220,
"enemy": "base:enemy_0_20",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 240,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
},
{
"tick": 310,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 310,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 310,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 310,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 310,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 340,
"enemy": "iron_coast_turret:unit",
"x": 208,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 405,
"tick": 400,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 400,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 400,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 400,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 400,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 496,
"enemy": "base:enemy_0_23",
"x": 184,
"y": -72,
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 455,
"tick": 496,
"enemy": "base:enemy_0_24",
"x": 88,
"y": -72,
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 510,
"tick": 496,
"enemy": "iron_coast_scout:unit",
"x": 208,
"y": -24
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 560,
"tick": 496,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 496,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 586,
"enemy": "iron_coast_scout:unit",
"x": 104,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 586,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 586,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 586,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 586,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 606,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
},
{
"tick": 620,
"enemy": "iron_coast_turret:unit",
"x": 240,
"y": -32
},
{
"tick": 680,
"enemy": "iron_coast_scout:unit",
"x": 184,
"y": -24
},
{
"tick": 730,
"enemy": "base:enemy_1_25",
"x": 176,
"y": -64,
"y": -32,
"difficulty": 0
},
{
"tick": 780,
"enemy": "base:enemy_1_26",
"tick": 676,
"enemy": "base:enemy_0_20",
"x": 64,
"y": -64,
"y": -32,
"difficulty": 0
},
{
"tick": 676,
"enemy": "base:enemy_0_20",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 676,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 676,
"enemy": "base:enemy_0_20",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 676,
"enemy": "base:enemy_0_20",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 766,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 766,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 766,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 766,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 766,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 820,
"enemy": "iron_coast_turret:unit",
"x": 192,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 870,
"tick": 860,
"enemy": "iron_coast_scout:unit",
"x": 208,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 925,
"tick": 860,
"enemy": "iron_coast_scout:unit",
"x": 104,
"y": -24
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 980,
"enemy": "iron_coast_scout:unit",
"x": 184,
"y": -24
},
{
"tick": 1040,
"enemy": "iron_coast_scout:unit",
"x": 56,
"y": -24
},
{
"tick": 1100,
"tick": 860,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 860,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 860,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 956,
"enemy": "base:enemy_0_23",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 956,
"enemy": "base:enemy_0_24",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 956,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 956,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 956,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 976,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
},
{
"tick": 1054,
"enemy": "base:enemy_1_25",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1054,
"enemy": "base:enemy_1_26",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1054,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1054,
"enemy": "base:enemy_1_25",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1054,
"enemy": "base:enemy_1_26",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1150,
"enemy": "iron_coast_boss_4:unit",
"x": 112,
"y": -64
"y": -64,
"difficulty": 0
},
{
"tick": 1260,
"tick": 1182,
"enemy": "iron_coast_scout:unit",
"x": 208,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1320,
"tick": 1182,
"enemy": "iron_coast_scout:unit",
"x": 104,
"y": -24
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1400,
"tick": 1182,
"enemy": "iron_coast_scout:unit",
"x": 184,
"y": -24
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1182,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1182,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1302,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1302,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1302,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1302,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1302,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1422,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1422,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1422,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1422,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1422,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1442,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
}
],
"art": {
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_mission_4"
name = "Iron Coast — Mission 4: Stormwall"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "Breach a storm-lashed cliff battery, silence four emplacements and destroy the Stormwall Arsenal. Original curved and crossing fighter patrols mix with Iron Coast scouts. Includes SD/HD storm-coast art, mission-only Pulse Cannon loan, warning cues and the Iron Coast soundtrack."
content = "content.json"
+9 -66
View File
@@ -1,74 +1,17 @@
# Iron Coast 5 — Cinder Docks
# Iron Coast — Mission 5: Cinder Docks
Enable this individual mission package in the Mods screen. Dependency selection
adds `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss_5`, `pulse_cannon`,
`seeker_swarm`, `ion_lance` and `iron_coast_music`, each compatible with version
1.x. Choose **Iron Coast 5 — Cinder Docks** in the mission menu. This mission has
no `next` reference; the optional Iron Coast campaign package supplies six-level
progression while the level stays independently playable.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss_5`, `pulse_cannon`, `seeker_swarm`, `ion_lance`, `iron_coast_music`.
Destroy both armored furnace stations, then defeat **Cinder Hammer** to shut down
the shipbuilding yard. The stations arrive at ticks 240 and 920 and stay at y=24
until destroyed. The boss arrives at tick 1660 and remains in the firing area.
Killing the boss first still requires the stations. Victory awards 15,000 credits
once, plus ordinary destruction rewards, including 1,200 per station and 1,800
for Cinder Hammer. The 2,600-tick duration is a catalog hint; waiting cannot win.
Mission `iron_coast_mission_5:mission_05`: **Iron Coast 5 — Cinder Docks**. Destroy both foundries and Cinder Hammer. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
Original enemies join the custom scouts: two opposing diagonal sweep libraries
(`base:enemy_0_21`, `base:enemy_0_22`), two curved-entry libraries
(`base:enemy_1_25`, `base:enemy_1_26`), a straight light fighter
(`base:enemy_0_13`) and a dock boat (`base:enemy_0_8`). Their original assets,
banking, movement paths, projectiles, collision and rewards remain in the base
registry, with no clones or overrides. These escorts and the ordinary turrets
are optional targets. All native spawns are enabled on every difficulty.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **45 / 64 / 83 / 107**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
Slag warnings alternate between the outer left and right lanes, followed 35
simulation ticks later by three falling shells. The central corridor stays clear.
Three coolant injectors are supplied if inventory room permits, including the
physical slot cost of reserve super-shields. A full 20-slot inventory can enter
without losing equipment. No premium weapon is borrowed or required.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
Hold Fire (default Ctrl), line up with each station and keep the ship near y=160.
Alt or Shift changes selected equipment. Select **Foundry Coolant Injector** and
press Special (default Z) when energy drops to 65 or below; each use restores 30
and consumes one injector. The item costs 15,000 credits per injector and has a
five-unit capacity, matching the Iron Coast repair rate of 500 credits per shield
unit. Ordinary forward guns keep firing while a coolant injector is selected.
The shared **Coastal Steel** music loops through the mission; nine local cues
cover approach, furnace warnings, slag, cannon fire, shutdown, boss arrival,
coolant use and victory.
## Equipment supplied by the mission
New generated terrain, briefing and transparent furnace-station masters are
packaged as SD and HD PNGs. SD uses the original game palette; HD uses separate
physical dimensions while preserving logical collision footprints. A reflected
terrain period makes the repetition edges match exactly. The exact imagegen
prompts, asset source hashes, procedural audio generator and generated outputs
are tracked with the mod. See `prompts.md`, `prompts.json`, `provenance.json`
and `asset-report.json`.
The mission supplies three coolant cells when inventory capacity allows. Select coolant with Cycle, then press Special to restore 30 energy per consumed cell. Healing is not automatic.
Regenerate the outputs with:
## Validation and history
```sh
python3 RustRaptor/mods/iron_coast_mission_5/build_content.py
python3 RustRaptor/mods/iron_coast_mission_5/build_assets.py
```
Run `RustRaptor/mods/iron_coast_mission_5/validate.sh` for local original-data
acceptance. Its control fixture uses only starter guns, bounded horizontal
movement toward a station's x+8 coordinate (otherwise x=144), and selectable
coolant when shield is at most 65. It tests normal registration resources
(75 shield, 10,000 credits), the 100-shield fixture, identical repeated outcomes,
all six native enemies appearing and moving, full-slot entry, one-time rewards
and mission-state reset on re-entry. It does not directly damage enemies or refill
shield during the controlled flight. Results are in `validation-report.json`.
The original asset catalog is required locally and remains outside Git.
The final local run finishes at tick **1,818** (about 78 seconds) from normal
registration resources with **71 shield**, **31,950 credits** and two coolant
uses. Repeating the same controls produces an identical world digest. The
100-shield/zero-credit fixture finishes at the same tick with 66 shield and
21,950 credits using one coolant. All six native libraries were seen moving in
every run; full 20-slot inventories with zero or four reserve super-shields
preserved their equipment. Required objectives, one-time rewards and restarting
with retained script state passed. Human difficulty and presentation remain
subject to ordinary play testing.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+660 -61
View File
@@ -144,7 +144,7 @@
"missions": [
{
"id": "iron_coast_mission_5:mission_05",
"name": "Iron Coast 5 \u2014 Cinder Docks",
"name": "Iron Coast 5 — Cinder Docks",
"duration": 2600,
"auto_complete": false,
"columns": 9,
@@ -2963,154 +2963,753 @@
],
"spawns": [
{
"tick": 50,
"enemy": "base:enemy_0_22",
"x": 40,
"y": -24
"tick": 40,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 88,
"enemy": "base:enemy_0_21",
"x": 208,
"y": -24
"tick": 40,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 126,
"tick": 40,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 40,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 40,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 128,
"enemy": "base:enemy_0_20",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 128,
"enemy": "base:enemy_0_20",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 128,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 128,
"enemy": "base:enemy_0_20",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 128,
"enemy": "base:enemy_0_20",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 180,
"enemy": "iron_coast_turret:unit",
"x": 40,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 214,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 214,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 214,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 214,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 214,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 234,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
},
{
"tick": 240,
"enemy": "iron_coast_mission_5:foundry",
"x": 112,
"y": -48
"y": -48,
"difficulty": 0
},
{
"tick": 360,
"enemy": "base:enemy_1_26",
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -24
"y": -32,
"difficulty": 0
},
{
"tick": 398,
"enemy": "base:enemy_1_25",
"x": 208,
"y": -24
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 436,
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 302,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 414,
"enemy": "base:enemy_0_23",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 414,
"enemy": "base:enemy_0_24",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 414,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 414,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 414,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 500,
"enemy": "iron_coast_turret:unit",
"x": 232,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 620,
"enemy": "base:enemy_0_13",
"x": 72,
"y": -24
"tick": 508,
"enemy": "base:enemy_1_25",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 658,
"enemy": "iron_coast_scout:unit",
"tick": 508,
"enemy": "base:enemy_1_26",
"x": 216,
"y": -24
"y": -32,
"difficulty": 0
},
{
"tick": 696,
"tick": 508,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 508,
"enemy": "base:enemy_1_25",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 508,
"enemy": "base:enemy_1_26",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 606,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 606,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 606,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 606,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 606,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 626,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
},
{
"tick": 704,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 704,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 704,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 704,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 704,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 800,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 800,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 800,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 800,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 800,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 810,
"enemy": "iron_coast_turret:unit",
"x": 40,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 898,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 898,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 898,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 898,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 898,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 920,
"enemy": "iron_coast_mission_5:foundry",
"x": 176,
"y": -48
"y": -48,
"difficulty": 0
},
{
"tick": 1040,
"enemy": "base:enemy_0_8",
"tick": 1000,
"enemy": "base:enemy_1_25",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1000,
"enemy": "base:enemy_1_26",
"x": 216,
"y": -24
"y": -32,
"difficulty": 0
},
{
"tick": 1078,
"enemy": "iron_coast_scout:unit",
"x": 200,
"y": -24
},
{
"tick": 1116,
"tick": 1000,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 1000,
"enemy": "base:enemy_1_25",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1000,
"enemy": "base:enemy_1_26",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1020,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
},
{
"tick": 1094,
"enemy": "base:enemy_0_20",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1094,
"enemy": "base:enemy_0_20",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1094,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1094,
"enemy": "base:enemy_0_20",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1094,
"enemy": "base:enemy_0_20",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1198,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1198,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1198,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1198,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1198,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1210,
"enemy": "iron_coast_turret:unit",
"x": 232,
"y": -32
"y": -32,
"difficulty": 0
},
{
"tick": 1300,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1300,
"enemy": "base:enemy_0_74",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1300,
"enemy": "iron_coast_scout:unit",
"x": 56,
"y": -24
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1338,
"enemy": "iron_coast_scout:unit",
"tick": 1300,
"enemy": "base:enemy_0_73",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1300,
"enemy": "base:enemy_0_74",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1396,
"enemy": "base:enemy_0_23",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1396,
"enemy": "base:enemy_0_24",
"x": 216,
"y": -24
"y": -32,
"difficulty": 0
},
{
"tick": 1376,
"tick": 1396,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 1396,
"enemy": "base:enemy_0_23",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1396,
"enemy": "base:enemy_0_24",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1416,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
},
{
"tick": 1500,
"enemy": "iron_coast_scout:unit",
"x": 80,
"y": -24
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1538,
"tick": 1500,
"enemy": "iron_coast_scout:unit",
"x": 200,
"y": -24
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1576,
"tick": 1500,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24
"y": -24,
"difficulty": 1
},
{
"tick": 1500,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1500,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1586,
"enemy": "base:enemy_0_20",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1586,
"enemy": "base:enemy_0_20",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1586,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1586,
"enemy": "base:enemy_0_20",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1586,
"enemy": "base:enemy_0_20",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1660,
"enemy": "iron_coast_boss_5:unit",
"x": 112,
"y": 24
"y": 24,
"difficulty": 0
},
{
"tick": 1694,
"enemy": "base:enemy_0_21",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1694,
"enemy": "base:enemy_0_22",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1694,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1694,
"enemy": "base:enemy_0_21",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1694,
"enemy": "base:enemy_0_22",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1830,
"enemy": "iron_coast_scout:unit",
"x": 64,
"y": -32,
"difficulty": 0
},
{
"tick": 1830,
"enemy": "iron_coast_scout:unit",
"x": 216,
"y": -32,
"difficulty": 0
},
{
"tick": 1830,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 1
},
{
"tick": 1830,
"enemy": "iron_coast_scout:unit",
"x": 24,
"y": -32,
"difficulty": 2
},
{
"tick": 1830,
"enemy": "iron_coast_scout:unit",
"x": 264,
"y": -32,
"difficulty": 3
},
{
"tick": 1850,
"enemy": "iron_coast_scout:unit",
"x": 136,
"y": -24,
"difficulty": 3
}
],
"art": {
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_mission_5"
name = "Iron Coast — Mission 5: Cinder Docks"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "Shut down two armored furnace stations in a hostile shipbuilding yard, then destroy its launch cruiser. Includes SD/HD industrial terrain, foundry targets, coolant injectors and synthesized warning cues."
content = "content.json"
+10 -63
View File
@@ -1,72 +1,19 @@
# Iron Coast 6 — Iron Crown
# Iron Coast — Mission 6: Iron Crown
The finale raids the offshore command citadel. Destroy all six fixed batteries
and both command cruisers to end the Iron Coast operation. The second cruiser
arrives at tick 2,350, about 101 seconds into the mission; there is no survival
timeout that bypasses the objectives. Surviving batteries remain anchored at
y48 or y80 and cruisers patrol at y24, so required targets cannot escape.
API 1 package, version 1.0.2. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss_6`, `iron_coast_music`, `pulse_cannon`, `seeker_swarm`, `ion_lance`.
This is an independent local mod with mission ID
`iron_coast_mission_6:mission_06`. Enable it in **Mods**; the game automatically
selects its seven installed dependencies: `iron_coast_scout`,
`iron_coast_turret`, `iron_coast_boss_6`, `pulse_cannon`, `seeker_swarm`,
`ion_lance` and `iron_coast_music`, all compatible with `^1.0`. It has no
mission-to-mission dependency
and no definition overrides. The optional `iron_coast_campaign` assembly adds
the full six-level progression.
Mission `iron_coast_mission_6:mission_06`: **Iron Coast 6 — Iron Crown**. Destroy all six batteries, the Fleet and Iron Crown. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
Select **Ion Lance** with Alt or Shift and hold Ctrl to fire. Aim the narrow
column at ground batteries, then track the broad command cruiser. Space drops
bombs if the pilot owns them. The sortie lends an Ion Lance only when a free
inventory slot exists, returns that loan on success or failure, and preserves
an already owned copy. A full 20-slot inventory, including physical reserve
super-shield modules, can still start without equipment loss. Pilots receive
25 shield on arrival and 20 shield at each cruiser phase, clamped to 100.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **64 / 92 / 120 / 145**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
The finale awards a 30,000-credit completion bonus once, in addition to normal
enemy rewards. There are 21 custom scout sorties, six classic aircraft, six
batteries and two
Iron Crown bosses. The classic aircraft directly reference `base:enemy_0_73`,
`base:enemy_0_74`, `base:enemy_0_23`, `base:enemy_0_24`, `base:enemy_0_95` and
`base:enemy_0_96`, retaining their original loop, sweeping and banking flight
paths without cloning or overriding definitions. Original pixels stay outside
this package. The campaign music is `iron_coast_music:iron_coast`.
The terrain definition spans 3,800 ticks, about 163 seconds, and completion
remains objective-driven if a pilot needs longer to finish surviving targets.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
`art/terrain-source-hd.png` and `art/briefing-source-hd.png` are new artwork
generated using the built-in imagegen skill. `prompts.json` records both exact
prompts, reference role and provenance. The source terrain is packaged into
216 paired 32px SD/128px HD tiles, plus a 320×200 SD/1280×800 HD briefing card.
SD art uses the original game's palette. A single joined-canvas conversion
preserves horizontal tile seams; reflected vertical repetition has identical
boundary pixels. Four original PCM16 mono WAV cues provide approach, fleet
alarm, final command alarm and victory audio. Generated art and audio belong
in Git; no original catalog image is copied into this package.
Version 1.0.2 removes ten additional Elite reinforcement entries to leave recovery space before the Fleet. Training, Rookie and Veteran schedules, all required targets, boss health and objective scripts are unchanged.
Repackage derived files with:
## Equipment supplied by the mission
```sh
python RustRaptor/mods/iron_coast_mission_6/build_assets.py
```
The mission loans an Ion Lance when it is not already carried and an inventory slot is available. Use Cycle to select Ion Lance, then hold Fire (or use Special) to fire it. The loan is returned at mission end; a missing final inventory entry does not mean it was unused.
The local palette source is required only for repackaging. Validate actual
shipped mod files and production deterministic simulation with:
## Validation and history
```sh
CARGO_TARGET_DIR=RustRaptor/target cargo run --offline --manifest-path \
RustRaptor/mods/iron_coast_mission_6/validation/Cargo.toml
```
The runner checks all four difficulties with the registered pilot's 75 shield
and 10,000 credits and the deterministic fixture's 100 shield and zero credits.
It uses the loan without buying a premium weapon, bounds lateral movement to
4 pixels per tick, tests six reachable batteries, full inventories, reserve
modules, restart reset, owned-weapon retention on success/failure and one-time
reward payment. The eight loan runs finish at ticks 2,385–2,426 with 100 shield.
A registered pilot with 20 neutral inventory slots and only the starter guns
finishes at tick 2,512 with 95 shield, without receiving a loan.
It records outcomes in `validation-results.json`. The actual original asset
catalog and native enemy definitions are used locally. Additional neutral
fixture items only fill inventory slots for the no-loan safety check. Set
`RAPTOR_TEST_DATA` if the original catalog lives elsewhere.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_mission_6"
name = "Iron Coast — Mission 6: Iron Crown"
version = "1.0.0"
version = "1.0.2"
api_version = 1
description = "Assault the offshore command citadel, disable six anchored batteries and destroy both command cruisers. The Iron Coast finale includes SD/HD fortress terrain, an optional Ion Lance loan and synthesized mission audio."
content = "content.json"
+12 -54
View File
@@ -1,63 +1,21 @@
# Railburst Cannon 1.0.0
# Railburst Cannon
Enable **Railburst Cannon** in Mods, apply at the main menu, then buy it in the
shop for **685,000 credits**. It is an independent API 1 mod without dependencies
or overrides. Maximum one; resale returns 342,500 credits. It occupies a normal
inventory slot and never replaces or deletes another upgrade.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
This is a **selectable, reusable special weapon**. Select it in Inventory or with
Alt/Shift during a mission, then hold Ctrl to fire. Space still drops an owned
mega bomb. Merely owning or selecting the cannon does not auto-fire it. No
ammunition is consumed; always-equipped forward guns can fire alongside it.
Pierces up to 3 aligned targets. Selectable reusable special. Ctrl fires a fast rail slug at air or ground targets. A real hit penetrates up to two more enemies ahead within a narrow 24-pixel lane, with falling damage (42/32/22). Best against aligned armor; no ammunition consumed. Alt/Shift selects weapons; Space drops mega bombs.
A native straight rail slug travels 16 logical pixels per simulation tick,
dealing 42 damage on its first real air or ground collision. That contact
penetrates at most two further living enemies ahead for 32 and 22 damage. Their
centers must stay within 12 pixels of the original firing x coordinate and within
160 pixels ahead of the first target's center. The nearest forward target wins;
stable entity order breaks ties. The primary projectile retires normally, while
the two additional contacts resolve through bounded `on_hit` damage commands.
This is finite contact-triggered penetration rather than a persistent beam.
Buy **Railburst Cannon** for **685,000 credits**; carry at most 1. Select it through Inventory or the Cycle control when it is a special weapon.
The cooldown is 22 ticks, about 0.94 seconds at the original 70/3 Hz simulation
rate. Aim carefully: it does not home, acquire offscreen enemies, turn sideways
or clear the screen. Native point collision can miss exceptionally tiny targets
between its 16-pixel steps. Destructible terrain receives native primary damage;
the API exposes enemy snapshots, so terrain does not trigger scripted penetration.
Small air/ground impacts and impact audio occur only after an enemy contact;
empty-space shots produce fire audio and the projectile, without muzzle blasts.
`iron_coast_railburst:projectile`: 42 damage, speed 16, 22-tick cooldown, `straight` behavior.
The SD equipment/projectile canvases are 16×16 and 4×20, with detailed 4× HD
variants at 64×64 and 16×80. Toggle HD artwork in Options. Both modes share logical
placement and gameplay dimensions; the single-frame master has no animation
misalignment. [Artwork provenance](art-provenance.json) binds untouched built-in
generated masters, prompts, output hashes and the preparation recipe. Generated
masters, final sprites, original procedural WAVs and reproduction inputs belong
to this package; decoded original reference art is kept outside it.
## Feedback
`python3 generate_audio.py` reproduces the firing crack and short metal impact.
`prepare_art.rs` reproduces SD/HD fitting from the included source PNGs. The
shared read-only [native review helper](validation/review.py) builds this project,
validates both independent packages and writes indexed art/audio captures to an
explicit evidence directory. It requires the included original catalog for the
engine palette and instruments and never changes pilots or selected mods.
The unchanged 4×20 SD / 16×80 HD projectile files now display on a proportional **3×15 logical canvas**. Point collision, 16px/tick speed, 22-tick cadence, 42/32/22 damage, 24px penetration lane and 160px follow-through limit are unchanged. Each of at most three struck targets receives a short 8px, 2-tick cosmetic contact streak, instead of long air/ground explosions. Fire/impact volume is 80/66. Price stays 685,000.
From the RustRaptor directory:
Only declared display sizing, cosmetic hit commands and playback levels/priorities change. No PNG/WAV pixels or samples, equipment icon dimensions, projectile collision rules, damage, cooldown, offsets, targeting, ammunition, capacity, controls or prices are changed. No new muzzle explosion or persistent trail is emitted. Normal original enemy deaths and terrain-destruction effects remain engine-controlled.
```sh
cargo test --locked --no-default-features --test iron_coast_stage2_weapons
cargo run --locked --no-default-features -- --data data --mods mods \
--validate-mods iron_coast_railburst
IRON_COAST_STAGE2_BALANCE_REPORT=/tmp/stage2-balance.json \
cargo test --locked --no-default-features --test iron_coast_stage2_weapons \
real_catalog_700_tick_balance_and_coexistence_report -- --ignored --nocapture
python3 mods/iron_coast_railburst/validation/review.py --output /tmp/stage2-native
```
The three rapid weapons retain their prior burst/hit counters and reset only their cosmetic tick/projectile gates when mission ticks restart, accepting older saved script state without new fields. This version changes the package save pin; existing pilot compatibility confirmation remains the normal mod mechanism.
See [validation/balance.json](validation/balance.json) for controlled 700-tick
single-target, off-axis, aligned-column, mixed-cluster and forward-gun combination
observations, and [validation/review.json](validation/review.json) for the tested
scope. These stationary fixtures establish the price/role comparison, not a claim
about every campaign encounter. Script counters and RNG checkpoints serialize
normally with pilot state; rejected callback commands roll back the whole tick.
Read the [API](../../docs/modding-api.md) for save compatibility and sandbox limits.
## Validation and history
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+6 -6
View File
@@ -17,8 +17,8 @@
"art": {
"png": "art/projectile-sd.png",
"hd_png": "art/projectile-hd.png",
"logical_width": 4,
"logical_height": 20,
"logical_width": 3,
"logical_height": 15,
"nearest": true
},
"script": "scripts/weapon.rhai"
@@ -49,19 +49,19 @@
"id": "iron_coast_railburst:fire",
"name": "Railburst Cannon Fire",
"file": "audio/fire.wav",
"volume": 88,
"volume": 80,
"pan": 127,
"pitch": 128,
"priority": 1
"priority": 2
},
{
"id": "iron_coast_railburst:impact",
"name": "Railburst Cannon Impact",
"file": "audio/impact.wav",
"volume": 82,
"volume": 66,
"pan": 127,
"pitch": 128,
"priority": 2
"priority": 3
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_railburst"
name = "Railburst Cannon"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Independent Iron Coast II selectable upgrade with SD/HD artwork and procedural fire/impact audio."
content = "content.json"
@@ -12,16 +12,15 @@ fn on_hit(event, state, random) {
let shots = if state.contains("shots") { state.shots } else { 0 };
let hits = if state.contains("hits") { state.hits % 100000 + 1 } else { 1 };
let origin_y = event.y;
let ground = false;
for enemy in event.entities {
if enemy.entity == event.entity {
origin_y = enemy.y + enemy.height / 2;
ground = enemy.ground;
}
}
let kind = if ground { "small_ground" } else { "small_air" };
// A short rail contact streak leaves the struck body readable.
let commands = [#{op: "play_sound", sound: "iron_coast_railburst:impact"},
#{op: "effect", kind: kind, x: event.x, y: event.y}];
#{op: "arc", x: event.x - 2, y: event.y + 4,
to_x: event.x + 2, to_y: event.y - 4, ticks: 2}];
let struck = [event.entity];
let previous_y = origin_y;
let damages = [32, 22];
@@ -42,8 +41,8 @@ fn on_hit(event, state, random) {
}
if !chosen.contains("entity") { break; }
commands.push(#{op: "damage", entity: chosen.entity, amount: damages[hop]});
let impact = if chosen.ground { "small_ground" } else { "small_air" };
commands.push(#{op: "effect", kind: impact, x: chosen.x, y: chosen.y});
commands.push(#{op: "arc", x: chosen.x - 2, y: chosen.y + 4,
to_x: chosen.x + 2, to_y: chosen.y - 4, ticks: 2});
struck.push(chosen.entity);
previous_y = chosen.y;
hits = hits % 100000 + 1;
+10 -8
View File
@@ -1,17 +1,19 @@
# Iron Coast Scout
Independent version 1.0.0 enemy package for mod API 1. Enable/disable it from the in-game Mods screen and use it in any mission through `iron_coast_scout:unit`. Iron Coast mission 1 supplies placements and completion objectives; this package does not add a mission or override base content.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
Airborne interceptor weaves left/right every 24 ticks, advances 1 pixel/tick, aims at the pilot and fires a 2 damage shell.
Straight entry at 3 pixels/tick, one 2-pixel lateral firing pass, a brief break, then a committed 3-pixel/tick exit. No indefinite bounce or slow alternating wobble.
Logical collision canvas `24×24`, top-left anchor `(0,0)`, health `14`, reward `75` credits, firing interval `54` simulation ticks. The engine halves boss health on difficulties 0/1; difficulty 2/3 uses the declared health. SD mode uses `art/unit-sd.png`; enable HD art in Settings for `art/unit-hd.png`. The HD canvas is 8× logical resolution and bypasses automatic ScaleNx. Projectile artwork has an 8×8 logical canvas and an independent 64×64 HD version.
The body remains 24×24, health 14, reward 75. SD and HD use the existing aligned PNGs. All PNG/WAV/master/prompt/provenance bytes are unchanged. Nonlegacy hostile projectile collision remains the engine's fixed `abs(dx)<10 && abs(dy)<10` point-to-player-center test, independent of artwork size. The following canvas change only changes visible sampling; it does not shrink that threshold. Projectile logical canvas is 6×6 in both modes; the complete existing square PNG canvas is sampled by the normal renderer, without crop or asset repaint. Its narrow visible core is checked in both modes.
The boss uses two registered frames (idle/amber charged); the scout and turret use stable single frames. All supplied variants stay on the same canvas and anchor. `art-qa.json` records transparency bounds and, for the boss, animation silhouette overlap.
All four difficulty profiles are authored in Rhai. Training/Rookie/Veteran/Elite (difficulty0/1/2/3) repeat periods are [64, 54, 46, 40] ticks, short-salvo counts [1, 1, 2, 2], windups [9, 8, 7, 7], projectile maximum-axis speeds [3, 4, 4, 5]; successive salvo shots are 5 ticks apart. These periods are script clocks, not the unused engine auto-fire cooldown. The enemy has no automatic weapon so it cannot fire a second implicit volley. Existing `:gun` definitions in Stage 2 remain compatibility content and do not drive this AI.
Enemy deaths increment the current mission objective `iron_coast_scout:defeated`. Persistent script `destroyed`, `hits` and `shots` counters are statistics; current-mission objectives reset when the mission restarts. Callbacks guard retired entities and use deterministic simulation data. No wall clock or unseeded random input enters the scripts.
Aim is frozen at the start of a short salvo. Units only fire when their complete body is visible and still safely above the player. Salvos cancel as soon as that condition fails. Shot audio plays once per actual projectile; siege/skimmer warning audio plays once per salvo. Existing large explosion/flare effects are not used as muzzle blooms. Native death feedback remains engine-owned.
Generated PNG masters, SD/HD strips, previews and WAV sounds are included in Git. Original reference images under `art/source/` are excluded. Exact built-in imagegen prompts are in `prompts/`, with source IDs and generated-image provenance in `provenance.json`.
`event.difficulty` must be integer 0–3; absent or invalid data uses difficulty1 (Rookie). Optional `event.actor` uses the current central-dispatch enemy snapshot, or null for a retired actor. Older engines lacking both new fields use a compatibility lookup through `event.entities`. Current null actors have the integer difficulty field and skip immediately. No full-event native map methods or helper arguments are used in the current path. The dense-wave operation-budget acceptance requires the current actor API; a fallback trace does not prove that new API is installed.
To rebuild derived artwork from supplied generated masters and regenerate audio, install Pillow and run `python3 build_assets.py`. To regenerate only audio with the Python standard library, run `python3 build_assets.py --audio-only`. WAVs are original procedural mono 16-bit PCM at 22050 Hz; `audio-recipe.json` records deterministic seeds, sizes and hashes. The art pipeline resizes and assembles supplied images; it does not redraw them.
Per-entity script state is bounded to 64 entries. Birth/death update the bounded lifecycle index; normal ticks never prune or scan the world when actor metadata is present. State, RNG and salvo timing are deterministic and save-continuable. Only actual engine death callbacks increment `iron_coast_scout:defeated` once. Air exits never award kill credit; required ground units never self-clear. The script never deals direct damage, grants equipment, spawns enemy reinforcements or completes a mission.
Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_scout`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`.
## Validation and history
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+4 -5
View File
@@ -4,15 +4,15 @@
"id": "iron_coast_scout:projectile",
"name": "Iron Coast Scout cannon shell",
"damage": 2,
"speed": 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
"logical_width": 6,
"logical_height": 6
}
}
],
@@ -33,8 +33,7 @@
"logical_height": 24,
"frames": 1
},
"script": "scripts/ai.rhai",
"weapon": "iron_coast_scout:projectile"
"script": "scripts/ai.rhai"
}
],
"sounds": [
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_scout"
name = "Iron Coast Scout"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "An agile weaving interceptor with amber cannon fire. Includes SD and HD artwork, deterministic Rhai AI and generated sound effects."
content = "content.json"
+107 -27
View File
@@ -1,40 +1,120 @@
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Authored attack clocks, not a legacy selector. Art, sound and kill rewards are unchanged.
// The central actor snapshot gives O(1) current-entity access. A legacy-only scan
// remains for dispatchers lacking BOTH actor and difficulty; no full event helper.
// Current dispatch supplies BOTH actor and difficulty. Read compact properties
// directly: native map methods recursively validate the entire event payload.
// Only an older dispatch with neither field enters the legacy array fallback.
fn legacy_actor(entities, id) {
for unit in entities { if unit.entity == id { return unit; } }
()
}
false
fn difficulty(value) {
if type_of(value) == "i64" && value >= 0 && value <= 3 { return value; }
1
}
fn ready(state) {
if !state.contains("units") { state.units = #{}; }
if !state.contains("order") { state.order = []; }
if !state.contains("dead") { state.dead = #{}; }
if !state.contains("dead_order") { state.dead_order = []; }
state
}
fn bump(state, name) {
if !state.contains(name) { return 1; }
if state[name] < 1000000000 { state[name] + 1 } else { state[name] }
}
fn visible(a, player) {
a.x >= 0 && a.x + a.width <= 320 && a.y >= 4 && a.y + a.height <= 174 && a.y + a.height < player.y - 8
}
fn aimed(a, player, speed) {
let dx = player.x + 16 - (a.x + a.width / 2);
let dy = player.y + 16 - (a.y + a.height);
let ax = if dx < 0 {-dx} else {dx};
let norm = if ax > dy {ax} else {dy};
if norm <= 0 { return [0,speed]; }
let vx = dx * speed / norm;
let vy = dy * speed / norm;
[vx, if vy < 1 {1} else {vy}]
}
// Velocity changes are committed after the callback completes.
fn on_spawn(event, state, random) {
if !is_alive(event) { return #{ commands: [], state: state }; }
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 1, vy: 1 }], state: state }
state = ready(state);
let key = event.entity.to_string();
// IDs restart with missions. Replace this lifecycle even if the callback is stale.
state.units.remove(key); state.dead.remove(key);
let order = []; for old in state.order { if old != key { order.push(old); } }
state.order = order;
let dead_order = []; for old in state.dead_order { if old != key { dead_order.push(old); } }
state.dead_order = dead_order;
let a = event.actor;
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
if a == () || a.health <= 0 { return #{commands:[],state:state}; }
// Constant bound; pruning is at birth/death, never an all-entity tick sweep.
if state.order.len() >= 64 { let old = state.order.remove(0); state.units.remove(old); }
state.order.push(key);
state.units[key] = #{age:0, sign:if a.x < 148 {1} else {-1}, cooldown:18 + event.entity % 19, remaining:0, wait:0, aim_x:0, aim_y:3};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:3}],state:state}
}
fn on_tick(event, state, random) {
if event.health <= 0 || !is_alive(event) { return #{ commands: [], state: state }; }
let sideways = if ((event.tick + event.entity * 7) / 24) % 2 == 0 { 1 } else { -1 };
if event.x <= 24 { sideways = 1; }
if event.x >= 272 { sideways = -1; }
#{ commands: [#{ op: "velocity", entity: event.entity, vx: sideways, vy: 1 }], state: state }
state = ready(state);
let key = event.entity.to_string();
let a = event.actor;
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
if a == () || a.health <= 0 { state.units.remove(key); return #{commands:[],state:state}; }
if !state.units.contains(key) { return #{commands:[],state:state}; }
let unit = state.units[key];
unit.age = if unit.age < 1000000000 {unit.age + 1} else {unit.age};
let commands = [];
if a.y > 224 || a.x < -32 || a.x > 328 {
state.units.remove(key);
return #{commands:[#{op:"despawn",entity:event.entity}],state:state};
}
fn on_fire(event, state, random) {
let shots = if state.contains("shots") { state.shots + 1 } else { 1 };
state.shots = shots;
#{ commands: [#{ op: "play_sound", sound: "iron_coast_scout:shot" }], state: state }
// Straight entry, diagonal firing pass, brief level break, then a committed exit.
let vx = if unit.age < 16 {0} else if unit.age < 56 {unit.sign * 2} else if unit.age < 76 {0} else {unit.sign};
let vy = if unit.age < 16 {3} else if unit.age < 56 {1} else if unit.age < 76 {2} else {3};
if unit.age < 76 && a.x < 8 {vx = 2;}
if unit.age < 76 && a.x > 288 {vx = -2;}
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:vy});
let d = difficulty(event.difficulty);
let period = [64, 54, 46, 40][d];
let burst = [1, 1, 2, 2][d];
let windup = [9, 8, 7, 7][d];
let speed = [3, 4, 4, 5][d];
if unit.cooldown > 0 { unit.cooldown -= 1; }
let can_fire = visible(a,event.player) && unit.age >= 12;
if !can_fire { unit.remaining = 0; unit.wait = 0; }
if can_fire && unit.remaining == 0 && unit.cooldown == 0 {
let aim = aimed(a,event.player,speed);
unit.aim_x = aim[0]; unit.aim_y = aim[1];
// This tick consumes one counter step; retain the full documented warning interval.
unit.remaining = burst; unit.wait = windup + 1; unit.cooldown = period;
// No oversized flare: the visible approach/hold is the light-unit telegraph.
}
if unit.wait > 0 { unit.wait -= 1; }
if can_fire && unit.remaining > 0 && unit.wait == 0 {
// Aim is frozen at the start of this short salvo: no homing correction.
commands.push(#{op:"projectile",weapon:"iron_coast_scout:projectile",x:a.x + a.width / 2 - 6 / 2,y:a.y + a.height - 6 / 2,vx:unit.aim_x,vy:unit.aim_y,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_scout:shot"});
unit.remaining -= 1; unit.wait = 5;
state.shots = bump(state,"shots");
}
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 };
state.hits = bump(state,"hits");
#{commands:[],state:state}
}
fn on_death(event, state, random) {
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
state = ready(state);
let key = event.entity.to_string();
// Only the engine's actual death hook advances required-target counters.
// Natural air exits never enter this path and never grant rewards/objectives.
let a = event.actor;
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
if (a != () && a.health > 0) || state.dead.contains(key) { return #{commands:[],state:state}; }
if state.dead_order.len() >= 64 { let old = state.dead_order.remove(0); state.dead.remove(old); }
state.dead[key] = true; state.dead_order.push(key); state.units.remove(key);
state.destroyed = bump(state,"destroyed");
let defeated = if event.objectives.contains("iron_coast_scout:defeated") {event.objectives["iron_coast_scout:defeated"] + 1} else {1};
#{ commands: [
#{ op: "objective", key: "iron_coast_scout:defeated", value: defeated },
#{ op: "play_sound", sound: "iron_coast_scout:death" }
], state: state }
#{commands:[#{op:"objective",key:"iron_coast_scout:defeated",value:defeated},#{op:"play_sound",sound:"iron_coast_scout:death"}],state:state}
}
+6 -33
View File
@@ -1,40 +1,13 @@
# Iron Coast II: Gatebreaker
A standalone boss content package for Black Tide. Enable this package directly
in **Mods**, or enable its mission to select it as a dependency. It adds
`iron_coast_stage2_boss_1:unit` and no campaign, overrides or equipment.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
The surfaced/ground target has 320 health, awards 12,000 credits and retains
a 128×64 logical collision canvas in SD and HD. It remains reachable until killed.
Its two phases change at half of that life’s actual spawn health on every
difficulty, with a new attack timer and twelve ticks of audible warning before
every volley. Difficulty 0/1 uses the engine’s half-health boss rule; 2/3 uses
the nominal health above. Cadence is 84 / 66 simulation ticks
(first/second phase), at the original 70/3 Hz. Every projectile is hostile and
leaves deliberate escape lanes; no phase is invulnerable.
Enemy `iron_coast_stage2_boss_1:unit` is a ground boss with **320** nominal health and a **128×64** logical canvas. Actual destruction awards 12,000 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Actual death increments `iron_coast_stage2_boss_1:defeated` once. It does not finish a mission.
Per-life cursors are removed on death and pruned on spawn/replay; early kills
before the queued spawn callback still register the real death.
Gatebreaker now uses fixed alternating-broadside gate battery. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
Artwork is a genuine new builtin imagegen master, with full alpha and identical
SD/HD canvas alignment. `build_assets.py` reproduces palette-aware SD art, 4× HD
and deterministic PCM WAV effects from the tracked raw masters. Projectile art
is the same newly authored micro-missile independently included with each boss.
See [generation provenance](generation-provenance.json) and
[asset recipe/report](asset-report.json). Original Raptor style references and
local review captures are external.
Only actual death increments `iron_coast_stage2_boss_1:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
From `RustRaptor/`:
## Validation and history
```sh
cargo run --locked --no-default-features -- --mods mods --validate-mods iron_coast_stage2_boss_1
CARGO_TARGET_DIR="$PWD/target" cargo run --locked --release --manifest-path mods/iron_coast_stage2_boss_1/validation/Cargo.toml -- --boss-only
python3 mods/iron_coast_stage2_boss_1/build_assets.py
```
The real-catalog standalone kill, every-difficulty phase boundaries, early and
duplicate hook checks and native Mixer/mute observations passed. Exact results
and scopes are recorded in `acceptance-report.json`.
Asset recipes use Python 3 with Pillow. The game uses the shipped PNG and WAV files.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_1"
name = "Iron Coast II: Gatebreaker"
version = "1.0.0"
version = "1.1.0"
api_version = 1
content = "content.json"
scripts = ["scripts/ai.rhai"]
+112 -47
View File
@@ -1,53 +1,118 @@
// Per-life cursors are serializable and deterministic. No engine-specific boss behavior.
fn alive(event,id) {
for unit in event.entities { if unit.entity==id && unit.health>0 { return true; } }
false
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
if !state.contains("ages") { state.ages=#{}; state.phases=#{}; state.directions=#{}; }
if !state.contains("maximums") {state.maximums=#{};}
// Remove cursors from a prior replay or an explicitly despawned life.
for key in state.ages.keys() {
let present=false;for unit in event.entities {if unit.entity.to_string()==key && unit.health>0 {present=true;}}
if !present { state.ages.remove(key);state.phases.remove(key);state.directions.remove(key);state.maximums.remove(key); }
}
let key=event.entity.to_string();state.ages[key]=0;state.phases[key]=1;state.directions[key]=1;state.maximums[key]=1;
for unit in event.entities {if unit.entity==event.entity && unit.health>0 {state.maximums[key]=unit.health;}}
if !alive(event,event.entity) { 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 || !alive(event,event.entity) { return #{commands:[],state:state}; }
let key=event.entity.to_string();
if !state.contains("ages") || !state.ages.contains(key) { return #{commands:[],state:state}; }
if event.y<20 { return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}; }
// Older saved cursors lack maximums; initialize at their current living health.
if !state.contains("maximums") {state.maximums=#{};}
if !state.maximums.contains(key) {state.maximums[key]=event.health;}
let phase=if event.health*2<=state.maximums[key] {2} else {1};
if state.phases[key]!=phase {state.phases[key]=phase;state.ages[key]=0;}
let age=state.ages[key]+1;state.ages[key]=age;
let commands=[];
commands.push(#{op:"move",entity:event.entity,x:96,y:20});commands.push(#{op:"velocity",entity:event.entity,vx:0,vy:0});
let cycle=if phase==1 {84} else {66};
if age%cycle==cycle-12 {commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_1:warning"});}
if age%cycle==0 {
if phase==1 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_1:projectile",x:120,y:72,vx:0,vy:4,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_1:projectile",x:200,y:72,vx:0,vy:4,hostile:true});
} else {
for lane in [-2,-1,1,2] {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_1:projectile",x:160+lane*18,y:72,vx:lane,vy:4,hostile:true});
}
}
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_1:shot"});
}
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_1:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
if !alive(event) {return #{commands:[],state:state};}
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<20 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*2<=u.max_health {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_1:phase",value:phase});
}
let d=level(event);u.difficulty=d;let periods=[[112,100,92,84],[100,90,82,74]];let warning=[24,22,20,18][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(96).min(96);let y=20;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+64))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning;
if locked {x=u.anchor_x;}
let vx=0;
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_1:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_1:safe_lane",value:if phase==2 {2} else if u.volley%2==0 {3} else {1}});
}
if cycle<warning && cycle%8==0 {
if phase==1 {let side=if u.volley%2==0 {24} else {80};commands=charge(commands,event,x+side,y+54);} else {commands=charge(commands,event,x+24,y+54);commands=charge(commands,event,x+80,y+54);}
}
if cycle==warning {if phase==1 {let side=if u.volley%2==0 {24} else {80};commands=shot(commands,"iron_coast_stage2_boss_1:projectile",x+side,y+54,0,4);commands=shot(commands,"iron_coast_stage2_boss_1:projectile",x+side+20,y+54,0,4);} else {for lane in [-2,-1,1,2] {commands=shot(commands,"iron_coast_stage2_boss_1:projectile",x+64+lane*18,y+54,lane,4);}}commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_1:shot"});}
u.age+=1;
if cycle==warning {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
let key=event.entity.to_string();
if alive(event,event.entity) || !state.contains("ages") || !state.ages.contains(key) {return #{commands:[],state:state};}
state.ages.remove(key);state.phases.remove(key);state.directions.remove(key);state.maximums.remove(key);
let value=if event.objectives.contains("iron_coast_stage2_boss_1:defeated") {event.objectives["iron_coast_stage2_boss_1:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_1:defeated",value:value},#{op:"effect",x:event.x+64,y:event.y+32},#{op:"play_sound",sound:"iron_coast_stage2_boss_1:death"}],state:state}
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_1:defeated") {event.objectives["iron_coast_stage2_boss_1:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_1:defeated",value:count},#{op:"effect",x:event.x+32,y:event.y+32},#{op:"effect",x:event.x+96,y:event.y+32},#{op:"play_sound",sound:"iron_coast_stage2_boss_1:death"}],state:state}
}
+6 -33
View File
@@ -1,40 +1,13 @@
# Iron Coast II: Undertow
A standalone boss content package for Black Tide. Enable this package directly
in **Mods**, or enable its mission to select it as a dependency. It adds
`iron_coast_stage2_boss_2:unit` and no campaign, overrides or equipment.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
The surfaced/ground target has 400 health, awards 16,000 credits and retains
a 48×80 logical collision canvas in SD and HD. It remains reachable until killed.
Its two phases change at half of that life’s actual spawn health on every
difficulty, with a new attack timer and twelve ticks of audible warning before
every volley. Difficulty 0/1 uses the engine’s half-health boss rule; 2/3 uses
the nominal health above. Cadence is 76 / 62 simulation ticks
(first/second phase), at the original 70/3 Hz. Every projectile is hostile and
leaves deliberate escape lanes; no phase is invulnerable.
Enemy `iron_coast_stage2_boss_2:unit` is a ground boss with **400** nominal health and a **48×80** logical canvas. Actual destruction awards 16,000 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Actual death increments `iron_coast_stage2_boss_2:defeated` once. It does not finish a mission.
Per-life cursors are removed on death and pruned on spawn/replay; early kills
before the queued spawn callback still register the real death.
Undertow now uses narrow pursuit ship with locked parallel shots and baitable fan. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
Artwork is a genuine new builtin imagegen master, with full alpha and identical
SD/HD canvas alignment. `build_assets.py` reproduces palette-aware SD art, 4× HD
and deterministic PCM WAV effects from the tracked raw masters. Projectile art
is the same newly authored micro-missile independently included with each boss.
See [generation provenance](generation-provenance.json) and
[asset recipe/report](asset-report.json). Original Raptor style references and
local review captures are external.
Only actual death increments `iron_coast_stage2_boss_2:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
From `RustRaptor/`:
## Validation and history
```sh
cargo run --locked --no-default-features -- --mods mods --validate-mods iron_coast_stage2_boss_2
CARGO_TARGET_DIR="$PWD/target" cargo run --locked --release --manifest-path mods/iron_coast_stage2_boss_1/validation/Cargo.toml -- --boss-only
python3 mods/iron_coast_stage2_boss_2/build_assets.py
```
The real-catalog standalone kill, every-difficulty phase boundaries, early and
duplicate hook checks and native Mixer/mute observations passed. Exact results
and scopes are recorded in `acceptance-report.json`.
Asset recipes use Python 3 with Pillow. The game uses the shipped PNG and WAV files.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_2"
name = "Iron Coast II: Undertow"
version = "1.0.0"
version = "1.1.0"
api_version = 1
content = "content.json"
scripts = ["scripts/ai.rhai"]
+112 -50
View File
@@ -1,56 +1,118 @@
// Per-life cursors are serializable and deterministic. No engine-specific boss behavior.
fn alive(event,id) {
for unit in event.entities { if unit.entity==id && unit.health>0 { return true; } }
false
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
if !state.contains("ages") { state.ages=#{}; state.phases=#{}; state.directions=#{}; }
if !state.contains("maximums") {state.maximums=#{};}
// Remove cursors from a prior replay or an explicitly despawned life.
for key in state.ages.keys() {
let present=false;for unit in event.entities {if unit.entity.to_string()==key && unit.health>0 {present=true;}}
if !present { state.ages.remove(key);state.phases.remove(key);state.directions.remove(key);state.maximums.remove(key); }
}
let key=event.entity.to_string();state.ages[key]=0;state.phases[key]=1;state.directions[key]=1;state.maximums[key]=1;
for unit in event.entities {if unit.entity==event.entity && unit.health>0 {state.maximums[key]=unit.health;}}
if !alive(event,event.entity) { 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 || !alive(event,event.entity) { return #{commands:[],state:state}; }
let key=event.entity.to_string();
if !state.contains("ages") || !state.ages.contains(key) { return #{commands:[],state:state}; }
if event.y<20 { return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}; }
// Older saved cursors lack maximums; initialize at their current living health.
if !state.contains("maximums") {state.maximums=#{};}
if !state.maximums.contains(key) {state.maximums[key]=event.health;}
let phase=if event.health*2<=state.maximums[key] {2} else {1};
if state.phases[key]!=phase {state.phases[key]=phase;state.ages[key]=0;}
let age=state.ages[key]+1;state.ages[key]=age;
let commands=[];
let direction=state.directions[key];
if event.x>=224 {direction=-1;} if event.x<=48 {direction=1;}
state.directions[key]=direction;
commands.push(#{op:"move",entity:event.entity,x:event.x,y:20});commands.push(#{op:"velocity",entity:event.entity,vx:direction,vy:0});
let cycle=if phase==1 {76} else {62};
if age%cycle==cycle-12 {commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_2:warning"});}
if age%cycle==0 {
if phase==1 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_2:projectile",x:event.x+10,y:90,vx:-1,vy:4,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_2:projectile",x:event.x+38,y:90,vx:1,vy:4,hostile:true});
} else {
for lane in [-2,0,2] {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_2:projectile",x:event.x+24+lane*6,y:90,vx:lane,vy:4,hostile:true});
}
}
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_2:shot"});
}
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_2:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
if !alive(event) {return #{commands:[],state:state};}
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<20 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*2<=u.max_health {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_2:phase",value:phase});
}
let d=level(event);u.difficulty=d;let periods=[[112,100,90,82],[100,90,80,72]];let warning=[26,24,22,20][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(48).min(224);let y=20;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+24))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning;
if locked {x=u.anchor_x;}
let desired=(event.player_x+16-24).max(48).min(224);let vx=if locked || (desired-x).abs()<=16 {0} else if desired<x {-1} else {1};
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_2:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_2:locked_aim",value:u.aim});
}
if cycle<warning && cycle%8==0 {
commands=aim_charge(commands,event,x+10,y+70,u.aim,4);commands=aim_charge(commands,event,x+30,y+70,u.aim,4);
}
if cycle==warning {if phase==1 {commands=shot(commands,"iron_coast_stage2_boss_2:projectile",x+10,y+70,u.aim,4);commands=shot(commands,"iron_coast_stage2_boss_2:projectile",x+30,y+70,u.aim,4);} else {for lane in [-1,0,1] {commands=shot(commands,"iron_coast_stage2_boss_2:projectile",x+20,y+70,u.aim+lane,4);}}commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_2:shot"});}
u.age+=1;
if cycle==warning {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
let key=event.entity.to_string();
if alive(event,event.entity) || !state.contains("ages") || !state.ages.contains(key) {return #{commands:[],state:state};}
state.ages.remove(key);state.phases.remove(key);state.directions.remove(key);state.maximums.remove(key);
let value=if event.objectives.contains("iron_coast_stage2_boss_2:defeated") {event.objectives["iron_coast_stage2_boss_2:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_2:defeated",value:value},#{op:"effect",x:event.x+24,y:event.y+40},#{op:"play_sound",sound:"iron_coast_stage2_boss_2:death"}],state:state}
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_2:defeated") {event.objectives["iron_coast_stage2_boss_2:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_2:defeated",value:count},#{op:"effect",x:event.x+12,y:event.y+40},#{op:"effect",x:event.x+36,y:event.y+40},#{op:"play_sound",sound:"iron_coast_stage2_boss_2:death"}],state:state}
}
+6 -8
View File
@@ -1,15 +1,13 @@
# Iron Coast II — Stormglass
Independent API1 enemy package: enable this mod alone to make `iron_coast_stage2_boss_3:unit` available to missions. It does not add a playable campaign, shop item, dependency, override or equipment grant. Mission3 uses this boss; other mod missions can reference the unit and its `:defeated` objective.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
Stormglass is a104×72 ground boss with240 normal health and2400 credit reward. It enters at2pixels per tick then holds y24, patrolling x60–156 at1pixel per tick. Each salvo gives a14-tick visual/audio warning and movement pause. Alternating side turrets fire two3-damage shells every72ticks above halfhealth; below halfhealth a central counter-angled shell joins a60-tick cycle. All projectiles have finite standard collision behavior. The actual spawn-health snapshot defines the half-health transition, so easier difficulty begins in the first phase despite the engine halving starting health.
Enemy `iron_coast_stage2_boss_3:unit` is a ground boss with **240** nominal health and a **104×72** logical canvas. Actual destruction awards 2,400 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
State is deterministic and per entity. Arrival and fire clocks are independent. Dead or missing snapshots cannot shoot; actual deaths increment `iron_coast_stage2_boss_3:defeated` once, while despawn/escape never count. Spawn clears reused entity-ID flags before checking the live snapshot, allowing saved-state mission replays even when the engine collects a ground projectile death on the following step. The boss never completes a mission or pays a script credit reward; the engine grants its one defined reward.
Stormglass now uses slow rail sentry alternating one active turret. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
The unit and shell have genuine transparent SD/8×HD PNGs and untouched builtin masters, with tracked `prompts/`, per-art provenance, `asset-report.json`, `audio-recipe.json` and `build_assets.py`. Runtime-invisible alpha padding is cropped at16 while runtime alpha128samples remain inside the fitted logical canvas. Generated warning/shot/death WAVs are original seeded mono16-bit22050Hz synthesis. The recipe defaults to the bundled `RustRaptor/data/misc/PALETTE_DAT`; decoded original style references remain outside the package.
Only actual death increments `iron_coast_stage2_boss_3:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
From `RustRaptor/`, run `target/release/raptor-rust --data data --mods mods --validate-mods iron_coast_stage2_boss_3` and `cargo run --manifest-path mods/iron_coast_stage2_boss_3/validation/Cargo.toml --target-dir target/mod-validation --bin validate --release -- mods/iron_coast_stage2_boss_3`. The local probe checks exact warning/volley timing and phase sizes, independent entities, arrival/bounds, deterministic saved state, dead/missing guards, actual difficulty0–3 health transitions and three reused-ID lethal-spawn/reward replays. Mission-level combat balance and SD/HD scene evidence are supplied with Mission3.
## Validation and history
Retired death guards are bounded to the most recent128 entity IDs, including deterministic migration of older checkpoints. Spawn resets a reused ID before any alive check; duplicate deaths for the current lifecycle emit no commands. A new World also discards clocks of absent entities while retaining live peers. The `retained-state` validation binary exercises1,205 actual-catalog/native lethal-collision lives, including1,200 distinct IDs, five reused-ID lives, JSON state/RNG restoration, a960-guard old checkpoint, duplicate callbacks, exact rewards and difficulty0–3. It additionally checks1,205 abandoned-live World resets and two independent live clocks. Run it from `RustRaptor/` with `cargo run --manifest-path mods/iron_coast_stage2_boss_3/validation/Cargo.toml --target-dir target/mod-validation --bin retained-state --release -- mods/iron_coast_stage2_boss_3`. The corresponding `validation/retained-state.json` records actual observations; package pins there identify the activation before generated proof files were finalized. The final external freeze ledger binds the complete resulting package.
A health-phase change restarts its per-entity warning clock and immediately telegraphs the new phase. It cannot shorten the14-tick charge by changing the modulo period. The actual World regression lowers health immediately after the second high-health warning at all four difficulties and verifies a fresh warning followed by exactly14ticks before release; it also upgrades an old checkpoint lacking the phase latch.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_3"
name = "Iron Coast II — Stormglass"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Distinct telegraphed Stormglass boss with two health phases, persistent onscreen patrol, SD/HD artwork and original synthesized sounds."
content = "content.json"
+101 -60
View File
@@ -1,77 +1,118 @@
// Deterministic per-entity patrol, health phases and visible/audio warning windows.
fn alive(event) { for unit in event.entities { if unit.entity==event.entity && unit.health>0 { return true; } } false }
fn spawn_health(event) { for unit in event.entities { if unit.entity==event.entity {return unit.health.max(1);} } 1 }
// At most128 retired IDs survive checkpoint/replay. Upgrade older checkpoints
// without a FIFO by retaining a deterministic bounded subset of their guards.
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let retained=#{};let order=[];
for key in state.dead.keys() {
if order.len()>=128 {let oldest=order.remove(0);retained.remove(oldest);}
retained[key]=true;order.push(key);
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
state.dead=retained;state.dead_order=order;
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
state=ready(state);
// Discard clocks of entities absent from this World after an abandoned run.
// Existing live peers retain their independent clocks.
let live_units=#{};
for unit in event.entities {
let unit_key=unit.entity.to_string();
if unit.health>0 && state.units.contains(unit_key) {live_units[unit_key]=state.units[unit_key];}
}
state.units=live_units;
let key=event.entity.to_string();state.dead.remove(key);state.units.remove(key);
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_3:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
// A lethal spawn-step collision still clears this lifecycle's old death flag.
if !alive(event) {return #{commands:[],state:state};}
state.units[key]=#{age:0,direction:1,aim:0,phase:1,max_health:spawn_health(event)};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}
}
fn on_tick(event,state,random) {
if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
if !state.contains("units") {state.units=#{};}
let key=event.entity.to_string();
if !state.units.contains(key) {state.units[key]=#{age:0,direction:1,aim:0,phase:1,max_health:spawn_health(event)};}
if event.y<24 {return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let u=state.units[key];
if !u.contains("max_health") {u.max_health=spawn_health(event);}
if event.x>=156 {u.direction=-1;} if event.x<=60 {u.direction=1;}
let phase=if event.health>u.max_health/2 {1} else {2};
// Each new phase begins a complete warning window, independent of the old
// period. This also safely upgrades checkpoints lacking the phase latch.
if !u.contains("phase") {u.phase=1;}
if phase!=u.phase {u.phase=phase;u.age=0;}
let period=if phase==1 {72} else {60};
let cycle=u.age%period;
let commands=[#{op:"move",entity:event.entity,x:event.x,y:24},#{op:"velocity",entity:event.entity,vx:if cycle<14 {0} else {u.direction*1},vy:0}];
if cycle==0 {
u.aim=if event.player_x<event.x+52 {-1} else {1};
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_3:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_3:phase",value:phase});
commands.push(#{op:"arc",x:event.x+20,y:76,to_x:event.x+20,to_y:94,ticks:8});
commands.push(#{op:"arc",x:event.x+84,y:76,to_x:event.x+84,to_y:94,ticks:8});
}
if cycle==14 {
let side=if (u.age/period)%2==0 {24} else {80};
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_3:shell",x:event.x+side,y:86,vx:u.aim,vy:3,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_3:shell",x:event.x+side,y:86,vx:0,vy:3,hostile:true});
if phase==2 {commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_3:shell",x:event.x+52,y:86,vx:-u.aim,vy:3,hostile:true});}
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_3:shot"});
}
u.age+=1;state.units[key]=u;
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<24 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*2<=u.max_health {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_3:phase",value:phase});
}
let d=level(event);u.difficulty=d;let periods=[[104,94,84,76],[94,84,74,66]];let warning=[24,22,20,18][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(60).min(156);let y=24;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+52))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning;
if locked {x=u.anchor_x;}
if x>=156 {u.direction=-1;} if x<=60 {u.direction=1;} let vx=if locked {0} else {u.direction};
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_3:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_3:active_pod",value:u.volley%2+1});
}
if cycle<warning && cycle%8==0 {
let side=if u.volley%2==0 {24} else {80};commands=charge(commands,event,x+side,y+62);if phase==2 {commands=aim_charge(commands,event,x+104-side,y+62,u.aim,3);}
}
if cycle==warning {let side=if u.volley%2==0 {24} else {80};let outward=if u.volley%2==0 {-1} else {1};commands=shot(commands,"iron_coast_stage2_boss_3:shell",x+side,y+62,0,3);commands=shot(commands,"iron_coast_stage2_boss_3:shell",x+side,y+62,outward,3);if phase==2 {commands=shot(commands,"iron_coast_stage2_boss_3:shell",x+104-side,y+62,u.aim,3);}commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_3:shot"});}
u.age+=1;
if cycle==warning {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
state=ready(state);
let key=event.entity.to_string();if state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let oldest=state.dead_order.remove(0);state.dead.remove(oldest);}
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_3:defeated") {event.objectives["iron_coast_stage2_boss_3:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_3:defeated",value:count},#{op:"effect",x:event.x+20,y:event.y+40},#{op:"effect",x:event.x+84,y:event.y+40},#{op:"play_sound",sound:"iron_coast_stage2_boss_3:death"}],state:state}
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_3:defeated",value:count},#{op:"effect",x:event.x+26,y:event.y+36},#{op:"effect",x:event.x+78,y:event.y+36},#{op:"play_sound",sound:"iron_coast_stage2_boss_3:death"}],state:state}
}
+6 -8
View File
@@ -1,15 +1,13 @@
# Iron Coast II — Floodgate
Independent API1 enemy package: enable this mod alone to make `iron_coast_stage2_boss_4:unit` available to missions. It does not add a playable campaign, shop item, dependency, override or equipment grant. Mission4 uses this boss; other mod missions can reference the unit and its `:defeated` objective.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
Floodgate is a104×64 ground boss with280 normal health and2800 credit reward. It enters at2pixels per tick then holds y24, patrolling x68–148 at2pixels per tick. Each salvo gives a14-tick visual/audio warning and movement pause. Two outward3-damage shells leave a central safe lane every76ticks above halfhealth; below halfhealth two extra inward-positioned outward shells join a64-tick cycle. All projectiles have finite standard collision behavior. The actual spawn-health snapshot defines the half-health transition, so easier difficulty begins in the first phase despite the engine halving starting health.
Enemy `iron_coast_stage2_boss_4:unit` is a ground boss with **280** nominal health and a **104×64** logical canvas. Actual destruction awards 2,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
State is deterministic and per entity. Arrival and fire clocks are independent. Dead or missing snapshots cannot shoot; actual deaths increment `iron_coast_stage2_boss_4:defeated` once, while despawn/escape never count. Spawn clears reused entity-ID flags before checking the live snapshot, allowing saved-state mission replays even when the engine collects a ground projectile death on the following step. The boss never completes a mission or pays a script credit reward; the engine grants its one defined reward.
Floodgate now uses station-to-station battery with outward salvos and central opening. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
The unit and shell have genuine transparent SD/8×HD PNGs and untouched builtin masters, with tracked `prompts/`, per-art provenance, `asset-report.json`, `audio-recipe.json` and `build_assets.py`. Runtime-invisible alpha padding is cropped at16 while runtime alpha128samples remain inside the fitted logical canvas. Generated warning/shot/death WAVs are original seeded mono16-bit22050Hz synthesis. The recipe defaults to the bundled `RustRaptor/data/misc/PALETTE_DAT`; decoded original style references remain outside the package.
Only actual death increments `iron_coast_stage2_boss_4:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
From `RustRaptor/`, run `target/release/raptor-rust --data data --mods mods --validate-mods iron_coast_stage2_boss_4` and `cargo run --manifest-path mods/iron_coast_stage2_boss_4/validation/Cargo.toml --target-dir target/mod-validation --bin validate --release -- mods/iron_coast_stage2_boss_4`. The local probe checks exact warning/volley timing and phase sizes, independent entities, arrival/bounds, deterministic saved state, dead/missing guards, actual difficulty0–3 health transitions and three reused-ID lethal-spawn/reward replays. Mission-level combat balance and SD/HD scene evidence are supplied with Mission4.
## Validation and history
Retired death guards are bounded to the most recent128 entity IDs, including deterministic migration of older checkpoints. Spawn resets a reused ID before any alive check; duplicate deaths for the current lifecycle emit no commands. A new World also discards clocks of absent entities while retaining live peers. The `retained-state` validation binary exercises1,205 actual-catalog/native lethal-collision lives, including1,200 distinct IDs, five reused-ID lives, JSON state/RNG restoration, a960-guard old checkpoint, duplicate callbacks, exact rewards and difficulty0–3. It additionally checks1,205 abandoned-live World resets and two independent live clocks. Run it from `RustRaptor/` with `cargo run --manifest-path mods/iron_coast_stage2_boss_4/validation/Cargo.toml --target-dir target/mod-validation --bin retained-state --release -- mods/iron_coast_stage2_boss_4`. The corresponding `validation/retained-state.json` records actual observations; package pins there identify the activation before generated proof files were finalized. The final external freeze ledger binds the complete resulting package.
A health-phase change restarts its per-entity warning clock and immediately telegraphs the new phase. It cannot shorten the14-tick charge by changing the modulo period. The actual World regression lowers health immediately after the second high-health warning at all four difficulties and verifies a fresh warning followed by exactly14ticks before release; it also upgrades an old checkpoint lacking the phase latch.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_4"
name = "Iron Coast II — Floodgate"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Distinct telegraphed Floodgate boss with two health phases, persistent onscreen patrol, SD/HD artwork and original synthesized sounds."
content = "content.json"
+101 -62
View File
@@ -1,79 +1,118 @@
// Deterministic per-entity patrol, health phases and visible/audio warning windows.
fn alive(event) { for unit in event.entities { if unit.entity==event.entity && unit.health>0 { return true; } } false }
fn spawn_health(event) { for unit in event.entities { if unit.entity==event.entity {return unit.health.max(1);} } 1 }
// At most128 retired IDs survive checkpoint/replay. Upgrade older checkpoints
// without a FIFO by retaining a deterministic bounded subset of their guards.
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let retained=#{};let order=[];
for key in state.dead.keys() {
if order.len()>=128 {let oldest=order.remove(0);retained.remove(oldest);}
retained[key]=true;order.push(key);
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
state.dead=retained;state.dead_order=order;
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
state=ready(state);
// Discard clocks of entities absent from this World after an abandoned run.
// Existing live peers retain their independent clocks.
let live_units=#{};
for unit in event.entities {
let unit_key=unit.entity.to_string();
if unit.health>0 && state.units.contains(unit_key) {live_units[unit_key]=state.units[unit_key];}
}
state.units=live_units;
let key=event.entity.to_string();state.dead.remove(key);state.units.remove(key);
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_4:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
// A lethal spawn-step collision still clears this lifecycle's old death flag.
if !alive(event) {return #{commands:[],state:state};}
state.units[key]=#{age:0,direction:1,aim:0,phase:1,max_health:spawn_health(event)};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}
}
fn on_tick(event,state,random) {
if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
if !state.contains("units") {state.units=#{};}
let key=event.entity.to_string();
if !state.units.contains(key) {state.units[key]=#{age:0,direction:1,aim:0,phase:1,max_health:spawn_health(event)};}
if event.y<24 {return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let u=state.units[key];
if !u.contains("max_health") {u.max_health=spawn_health(event);}
if event.x>=148 {u.direction=-1;} if event.x<=68 {u.direction=1;}
let phase=if event.health>u.max_health/2 {1} else {2};
// Each new phase begins a complete warning window, independent of the old
// period. This also safely upgrades checkpoints lacking the phase latch.
if !u.contains("phase") {u.phase=1;}
if phase!=u.phase {u.phase=phase;u.age=0;}
let period=if phase==1 {76} else {64};
let cycle=u.age%period;
let commands=[#{op:"move",entity:event.entity,x:event.x,y:24},#{op:"velocity",entity:event.entity,vx:if cycle<14 {0} else {u.direction*2},vy:0}];
if cycle==0 {
u.aim=if event.player_x<event.x+52 {-1} else {1};
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_4:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_4:phase",value:phase});
commands.push(#{op:"arc",x:event.x+20,y:76,to_x:event.x+20,to_y:94,ticks:8});
commands.push(#{op:"arc",x:event.x+84,y:76,to_x:event.x+84,to_y:94,ticks:8});
}
if cycle==14 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_4:shell",x:event.x+20,y:78,vx:-1,vy:4,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_4:shell",x:event.x+84,y:78,vx:1,vy:4,hostile:true});
if phase==2 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_4:shell",x:event.x+40,y:78,vx:-1,vy:4,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_4:shell",x:event.x+64,y:78,vx:1,vy:4,hostile:true});
}
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_4:shot"});
}
u.age+=1;state.units[key]=u;
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<24 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*2<=u.max_health {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_4:phase",value:phase});
}
let d=level(event);u.difficulty=d;let periods=[[112,102,92,84],[102,92,82,74]];let warning=[26,24,22,20][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(68).min(148);let y=24;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+52))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning+10;
if locked {x=u.anchor_x;}
let goal=if u.volley%2==0 {68} else {148};let vx=if locked || (goal-x).abs()<=2 {0} else if goal<x {-2} else {2};
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_4:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_4:safe_lane",value:2});
}
if cycle<warning && cycle%8==0 {
commands=charge(commands,event,x+16,y+54);commands=charge(commands,event,x+76,y+54);
}
if cycle==warning {commands=shot(commands,"iron_coast_stage2_boss_4:shell",x+20,y+54,-1,4);commands=shot(commands,"iron_coast_stage2_boss_4:shell",x+84,y+54,1,4);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_4:shot"});} if cycle==warning+10 && phase==2 {commands=shot(commands,"iron_coast_stage2_boss_4:shell",x+40,y+54,-1,4);commands=shot(commands,"iron_coast_stage2_boss_4:shell",x+64,y+54,1,4);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_4:shot"});}
u.age+=1;
if cycle==warning+10 {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
state=ready(state);
let key=event.entity.to_string();if state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let oldest=state.dead_order.remove(0);state.dead.remove(oldest);}
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_4:defeated") {event.objectives["iron_coast_stage2_boss_4:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_4:defeated",value:count},#{op:"effect",x:event.x+20,y:event.y+40},#{op:"effect",x:event.x+84,y:event.y+40},#{op:"play_sound",sound:"iron_coast_stage2_boss_4:death"}],state:state}
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_4:defeated",value:count},#{op:"effect",x:event.x+26,y:event.y+32},#{op:"effect",x:event.x+78,y:event.y+32},#{op:"play_sound",sound:"iron_coast_stage2_boss_4:death"}],state:state}
}
+7 -45
View File
@@ -1,51 +1,13 @@
# Skybreaker — Black Tide boss 5
# Black Tide — Skybreaker
Independent local enemy mod `iron_coast_stage2_boss_5:unit`; no dependencies, no item
or mission overrides. Enable it alone in **Mods** for other content to spawn it.
It has no standalone mission; `iron_coast_stage2_mission_5` supplies its encounter.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
300 health at difficulties 2/3, 150 at 0/1; 6,000-credit actual-death reward.
Its logical canvas is 112×56, with a stable padded transparent
SD PNG and a 4× HD PNG. It is airborne.
Both artwork modes use the same collision dimensions. It enters the y 24 band
and patrols horizontally at 1px/tick over x 80..136.
It remains until killed rather than scrolling or timing out.
Enemy `iron_coast_stage2_boss_5:unit` is an airborne boss with **300** nominal health and a **112×56** logical canvas. Actual destruction awards 6,000 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Each instance has its own serializable attack clock and actual starting-health
snapshot. The 84-tick cycle warns at age 36,
fires staggered outward left/right damage 3 shells at ages
50/58, then below half starting health adds
an aimed damage 4 center shot at age 66. Speeds are 4/3 respectively;
the center aim is bounded to ±2 horizontal velocity. One artwork frame is used;
the attack pattern and warning identify the phase. There is no native default
projectile or legacy movement. Projectiles reference original enemy-shell art.
Skybreaker now uses limited aerial pursuit with staggered flank shells and locked core shot. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
Only actual death increments `iron_coast_stage2_boss_5:defeated`, clears instance state
and plays the destruction cue. The boss never completes a mission, changes
inventory or awards a separate scripted credit bonus. Removed/dead units emit
no ghost shots. Simultaneous instances keep independent clocks; new spawns
remove stale clocks from a previous mission and reset reused IDs safely.
Only actual death increments `iron_coast_stage2_boss_5:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
[`provenance.json`](provenance.json) binds the genuine built-in image generation
request and untouched `sources/raw-v1.png`. Whole-canvas proportional fitting,
resampling and ordinary-palette SD conversion are the only sprite derivatives.
Original reference sheets remain external, recorded by path and hash.
[`tools/build_assets.py`](tools/build_assets.py) reproduces SD/HD art and the
original 22050Hz PCM16 warning, cannon and death cues. It reads only bundled
RustRaptor `data/misc/PALETTE_DAT`. No third-party recording was used.
## Validation and history
Validation from RustRaptor:
```sh
cargo run --release --locked --no-default-features -- --data data --mods mods --validate-mods iron_coast_stage2_boss_5
CARGO_TARGET_DIR=target cargo test --locked --manifest-path mods/iron_coast_stage2_boss_5/validation/Cargo.toml --lib
```
Four portable tests cover warning/attack order and command bounds,
serialized continuation and independent clocks, dead-unit guards, restart
flags and one-time mission bonuses, plus actual World boss persistence through
600 ticks, relative phase transitions at all four difficulties, genuine lethal
damage, one death reward/objective and 120 ticks without ghost fire. The local
real-catalog acceptance runner checks the complete associated mission, full
inventories and replays. Actual indexed SD/HD artwork and all three cues were
checked via Rust decoding/mixing; live aggregate-scene captures are separate.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_5"
name = "Black Tide — Skybreaker"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Independent airborne flying-wing fortress with persistent patrol, telegraphed volleys and generated SD/HD artwork."
content = "content.json"
+107 -46
View File
@@ -1,57 +1,118 @@
// Each real instance has a bounded serializable clock. No native/legacy behavior.
fn alive(event) {
for e in event.entities { if e.entity == event.entity && e.health > 0 { return true; } }
false
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_5:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
if !alive(event) {return #{commands:[],state:state};}
let retained = #{};
// Discard entries from earlier failed/replayed missions; preserve simultaneous instances.
if state.contains("units") {
for e in event.entities {
let key = e.entity.to_string();
if e.definition == "iron_coast_stage2_boss_5:unit" && state.units.contains(key) { retained[key] = state.units[key]; }
}
}
state.units = retained;
let max_health = 1;
for e in event.entities { if e.entity == event.entity { max_health = e.health; } }
state.units[event.entity.to_string()] = #{age:0,direction:1,max_health:max_health};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
if event.health <= 0 || !alive(event) { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
if !state.units.contains(key) { state.units[key] = #{age:0,direction:1,max_health:event.health}; }
let u = state.units[key];
if !u.contains("max_health") { u.max_health = event.health; }
if event.y < 24 { return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}; }
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<24 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*2<=u.max_health {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_5:phase",value:phase});
}
let d=level(event);u.difficulty=d;let periods=[[112,100,90,82],[100,90,80,72]];let warning=[24,22,20,18][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(64).min(152);let y=24;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+56))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning+10;
if locked {x=u.anchor_x;}
let desired=(event.player_x+16-56).max(64).min(152);let vx=if locked || (desired-x).abs()<=16 {0} else if desired<x {-1} else {1};
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_5:locked_aim",value:u.aim});
}
if cycle<warning && cycle%8==0 {
commands=charge(commands,event,x+24,y+46);commands=charge(commands,event,x+76,y+46);if phase==2 {commands=aim_charge(commands,event,x+52,y+46,u.aim,3);}
}
if cycle==warning {commands=shot(commands,"iron_coast_stage2_boss_5:shell",x+28,y+46,-1,4);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:shot"});} if cycle==warning+8 {commands=shot(commands,"iron_coast_stage2_boss_5:shell",x+80,y+46,1,4);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:shot"});} if cycle==warning+10 && phase==2 {commands=shot(commands,"iron_coast_stage2_boss_5:core",x+52,y+46,u.aim,3);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:shot"});}
u.age+=1;
if event.x >= 136 { u.direction = -1; }
if event.x <= 80 { u.direction = 1; }
let commands = [#{op:"move",entity:event.entity,x:event.x,y:24},#{op:"velocity",entity:event.entity,vx:u.direction,vy:0}];
let phase = u.age % 84;
if phase == 36 { commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:warning"}); }
if phase == 50 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_5:shell",x:event.x+28,y:event.y+48,vx:-1,vy:4,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:shot"});
}
if phase == 58 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_5:shell",x:event.x+80,y:event.y+48,vx:1,vy:4,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:shot"});
}
if phase == 66 && event.health <= u.max_health / 2 {
let aim = (event.player_x - event.x - 40) / 48;
let vx = if aim < -2 { -2 } else if aim > 2 { 2 } else { aim };
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_5:core",x:event.x+52,y:event.y+48,vx:vx,vy:3,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_5:shot"});
}
if cycle==warning+10 {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
if state.contains("units") { state.units.remove(event.entity.to_string()); }
let n = if event.objectives.contains("iron_coast_stage2_boss_5:defeated") { event.objectives["iron_coast_stage2_boss_5:defeated"] + 1 } else { 1 };
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_5:defeated",value:n},#{op:"effect",x:event.x+28,y:event.y+40},#{op:"effect",x:event.x+80,y:event.y+40},#{op:"play_sound",sound:"iron_coast_stage2_boss_5:death"}],state:state}
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_5:defeated") {event.objectives["iron_coast_stage2_boss_5:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_5:defeated",value:count},#{op:"effect",x:event.x+28,y:event.y+28},#{op:"effect",x:event.x+84,y:event.y+28},#{op:"play_sound",sound:"iron_coast_stage2_boss_5:death"}],state:state}
}
+7 -45
View File
@@ -1,51 +1,13 @@
# Emberwake — Black Tide boss 6
# Black Tide — Emberwake
Independent local enemy mod `iron_coast_stage2_boss_6:unit`; no dependencies, no item
or mission overrides. Enable it alone in **Mods** for other content to spawn it.
It has no standalone mission; `iron_coast_stage2_mission_6` supplies its encounter.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
360 health at difficulties 2/3, 180 at 0/1; 7,500-credit actual-death reward.
Its logical canvas is 112×72, with a stable padded transparent
SD PNG and a 4× HD PNG. It is a ground battleship.
Both artwork modes use the same collision dimensions. It enters the y 24 band
and patrols horizontally at 1px/tick over x 88..112.
It remains until killed rather than scrolling or timing out.
Enemy `iron_coast_stage2_boss_6:unit` is a ground boss with **360** nominal health and a **112×72** logical canvas. Actual destruction awards 7,500 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Each instance has its own serializable attack clock and actual starting-health
snapshot. The 96-tick cycle warns at age 44,
fires staggered outward left/right damage 3 shells at ages
60/72, then below half starting health adds
an aimed damage 4 center shot at age 84. Speeds are 4/3 respectively;
the center aim is bounded to ±2 horizontal velocity. One artwork frame is used;
the attack pattern and warning identify the phase. There is no native default
projectile or legacy movement. Projectiles reference original enemy-shell art.
Emberwake now uses slow armored rail barge with separated heavy broadsides. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
Only actual death increments `iron_coast_stage2_boss_6:defeated`, clears instance state
and plays the destruction cue. The boss never completes a mission, changes
inventory or awards a separate scripted credit bonus. Removed/dead units emit
no ghost shots. Simultaneous instances keep independent clocks; new spawns
remove stale clocks from a previous mission and reset reused IDs safely.
Only actual death increments `iron_coast_stage2_boss_6:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
[`provenance.json`](provenance.json) binds the genuine built-in image generation
request and untouched `sources/raw-v1.png`. Whole-canvas proportional fitting,
resampling and ordinary-palette SD conversion are the only sprite derivatives.
Original reference sheets remain external, recorded by path and hash.
[`tools/build_assets.py`](tools/build_assets.py) reproduces SD/HD art and the
original 22050Hz PCM16 warning, cannon and death cues. It reads only bundled
RustRaptor `data/misc/PALETTE_DAT`. No third-party recording was used.
## Validation and history
Validation from RustRaptor:
```sh
cargo run --release --locked --no-default-features -- --data data --mods mods --validate-mods iron_coast_stage2_boss_6
CARGO_TARGET_DIR=target cargo test --locked --manifest-path mods/iron_coast_stage2_boss_5/validation/Cargo.toml --lib
```
Four portable tests cover warning/attack order and command bounds,
serialized continuation and independent clocks, dead-unit guards, restart
flags and one-time mission bonuses, plus actual World boss persistence through
600 ticks, relative phase transitions at all four difficulties, genuine lethal
damage, one death reward/objective and 120 ticks without ghost fire. The local
real-catalog acceptance runner checks the complete associated mission, full
inventories and replays. Actual indexed SD/HD artwork and all three cues were
checked via Rust decoding/mixing; live aggregate-scene captures are separate.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_6"
name = "Black Tide — Emberwake"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Independent armored repair-dock battleship with persistent patrol, telegraphed volleys and generated SD/HD artwork."
content = "content.json"
+107 -46
View File
@@ -1,57 +1,118 @@
// Each real instance has a bounded serializable clock. No native/legacy behavior.
fn alive(event) {
for e in event.entities { if e.entity == event.entity && e.health > 0 { return true; } }
false
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_6:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
if !alive(event) {return #{commands:[],state:state};}
let retained = #{};
// Discard entries from earlier failed/replayed missions; preserve simultaneous instances.
if state.contains("units") {
for e in event.entities {
let key = e.entity.to_string();
if e.definition == "iron_coast_stage2_boss_6:unit" && state.units.contains(key) { retained[key] = state.units[key]; }
}
}
state.units = retained;
let max_health = 1;
for e in event.entities { if e.entity == event.entity { max_health = e.health; } }
state.units[event.entity.to_string()] = #{age:0,direction:1,max_health:max_health};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
if event.health <= 0 || !alive(event) { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
if !state.units.contains(key) { state.units[key] = #{age:0,direction:1,max_health:event.health}; }
let u = state.units[key];
if !u.contains("max_health") { u.max_health = event.health; }
if event.y < 24 { return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}; }
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<20 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*2<=u.max_health {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_6:phase",value:phase});
}
let d=level(event);u.difficulty=d;let periods=[[132,118,106,96],[118,106,94,84]];let warning=[28,26,24,22][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(88).min(120);let y=20;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+56))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning+24;
if locked {x=u.anchor_x;}
if x>=120 {u.direction=-1;} if x<=88 {u.direction=1;} let vx=if locked {0} else {u.direction};
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_6:locked_aim",value:u.aim});
}
if cycle<warning && cycle%8==0 {
commands=charge(commands,event,x+24,y+62);commands=charge(commands,event,x+76,y+62);if phase==2 {commands=aim_charge(commands,event,x+52,y+62,u.aim,3);}
}
if cycle==warning {commands=shot(commands,"iron_coast_stage2_boss_6:shell",x+28,y+62,-1,4);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:shot"});} if cycle==warning+12 {commands=shot(commands,"iron_coast_stage2_boss_6:shell",x+80,y+62,1,4);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:shot"});} if cycle==warning+24 && phase==2 {commands=shot(commands,"iron_coast_stage2_boss_6:core",x+52,y+62,u.aim,3);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:shot"});}
u.age+=1;
if event.x >= 112 { u.direction = -1; }
if event.x <= 88 { u.direction = 1; }
let commands = [#{op:"move",entity:event.entity,x:event.x,y:24},#{op:"velocity",entity:event.entity,vx:u.direction,vy:0}];
let phase = u.age % 96;
if phase == 44 { commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:warning"}); }
if phase == 60 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_6:shell",x:event.x+28,y:event.y+64,vx:-1,vy:4,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:shot"});
}
if phase == 72 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_6:shell",x:event.x+80,y:event.y+64,vx:1,vy:4,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:shot"});
}
if phase == 84 && event.health <= u.max_health / 2 {
let aim = (event.player_x - event.x - 40) / 48;
let vx = if aim < -2 { -2 } else if aim > 2 { 2 } else { aim };
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_6:core",x:event.x+52,y:event.y+64,vx:vx,vy:3,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_6:shot"});
}
if cycle==warning+24 {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
if state.contains("units") { state.units.remove(event.entity.to_string()); }
let n = if event.objectives.contains("iron_coast_stage2_boss_6:defeated") { event.objectives["iron_coast_stage2_boss_6:defeated"] + 1 } else { 1 };
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_6:defeated",value:n},#{op:"effect",x:event.x+28,y:event.y+40},#{op:"effect",x:event.x+80,y:event.y+40},#{op:"play_sound",sound:"iron_coast_stage2_boss_6:death"}],state:state}
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_6:defeated") {event.objectives["iron_coast_stage2_boss_6:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_6:defeated",value:count},#{op:"effect",x:event.x+28,y:event.y+36},#{op:"effect",x:event.x+84,y:event.y+36},#{op:"play_sound",sound:"iron_coast_stage2_boss_6:death"}],state:state}
}
+7 -12
View File
@@ -1,18 +1,13 @@
# Iron Coast II: Dreadnought
# Iron Coast II: Dreadnought Boss
Separate API 1/version 1.0.0 boss mod, with no dependencies or overrides.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
- Enemy: `iron_coast_stage2_boss_7:unit`, health 280, reward 5000 credits (difficulty applies the normal engine health rules).
- Actual death objective: `iron_coast_stage2_boss_7:defeated`. Escape/despawn grants nothing.
- Persistent patrol is confined to x 60–116, y 16, so the complete carrier stays reachable.
- Two phases: below half health the covered batteries open; attacks alternate port/starboard volleys and add outward flank shells.
- Every volley has an audible warning 14 simulation ticks ahead and cosmetic arcs at attacking pods. Shots travel 3 pixels/tick; no aiming at the player's instantaneous position, no contact explosion at the muzzle.
- Logical art 112×88, 2 phase frames; HD 4×; shell 6×12. Collision dimensions stay identical in SD/HD.
Enemy `iron_coast_stage2_boss_7:unit` is an airborne boss with **280** nominal health and a **112×88** logical canvas. Actual destruction awards 5,000 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Per-entity cursors isolate simultaneous bosses. Spawn callbacks reset reused IDs; actual deaths retire only that cursor and increment the public death counter once. `animation` pins a declared frame; phase state remains serializable.
Dreadnought now uses alternating air-carrier broadside with an open opposite deck. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
`art/generated-v1.png` is the untouched builtin output. Prompt and provenance bind the exact generator/reference hashes. The companion mission 8 `build_finale_assets.py` performs complete meaningful-alpha crops and proportional fits, then SD reduction; it also reproduces the seeded 22.05 kHz mono 16-bit warning/phase/shot/death WAVs. It does not repaint or stretch sprites. Reference/catalog images and gameplay screenshots remain outside the package.
Only actual death increments `iron_coast_stage2_boss_7:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
Acceptance source: `../iron_coast_stage2_mission_8/validation/`; standalone boss runs and both full sorties use the actual Catalog/World/Rhai and starter guns. Durable simulation/SD-HD captures are external review evidence; final metrics are copied into `validation-summary.json` after balance is frozen.
## Validation and history
Actual starter boss-only runs from 75 shield: 147 ticks at difficulties 0/1 and 287 at 2/3; 75 shield remains. Retained-state spawn-step lethal replays use the actual World and verify the counter and reward once on each new lifecycle.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_7"
name = "Iron Coast II: Dreadnought Boss"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Distinct persistent Dreadnought carrier with telegraphed phase attacks, SD/HD artwork and original synthesized PCM sounds."
content = "content.json"
+103 -62
View File
@@ -1,77 +1,118 @@
// Per-entity cursors prevent simultaneous carriers sharing phase/volley state.
fn alive(event) {
for unit in event.entities { if unit.entity == event.entity && unit.health > 0 { return true; } }
false
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
// A new spawn is a fresh lifecycle even when killed before dispatch.
if state.contains("last_death") && state.last_death == event.entity { state.remove("last_death"); }
if !alive(event) { return #{commands:[],state:state}; }
if !state.contains("units") { state.units = #{}; }
// Prune cursors from aborted/restarted worlds; retain simultaneous live carriers.
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for unit in event.entities { if unit.definition == "iron_coast_stage2_boss_7:unit" { live[unit.entity.to_string()] = true; } }
for old in state.units.keys() { if !live.contains(old) { state.units.remove(old); } }
let spawn_health = 1;
for unit in event.entities { if unit.entity == event.entity { spawn_health = unit.health; } }
state.units[event.entity.to_string()] = #{age:0,volley:0,direction:1,phase:0,max_health:spawn_health};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2},#{op:"animation",entity:event.entity,frame:0}],state:state}
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_7:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
if !alive(event) {return #{commands:[],state:state};}
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
commands.push(#{op:"animation",entity:event.entity,frame:0});
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
if event.health <= 0 || !alive(event) { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let cursor = if state.units.contains(key) { state.units[key] } else { #{age:0,volley:0,direction:1,phase:0,max_health:event.health} };
if event.y < 16 {
state.units[key] = cursor;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<16 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*2<=u.max_health {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_7:phase",value:phase});
commands.push(#{op:"animation",entity:event.entity,frame:phase-1});
}
let phase = if event.health * 2 <= cursor.max_health { 1 } else { 0 };
let commands = [#{op:"move",entity:event.entity,x:event.x,y:16}];
if event.x >= 116 { cursor.direction = -1; }
if event.x <= 60 { cursor.direction = 1; }
commands.push(#{op:"velocity",entity:event.entity,vx:cursor.direction,vy:0});
if phase != cursor.phase {
cursor.phase = phase;
cursor.age = 0;
commands.push(#{op:"animation",entity:event.entity,frame:phase});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_7:phase",value:phase+1});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_7:phase"});
}
cursor.age += 1;
let period = if phase == 0 { 84 } else { 66 };
let warning = 14;
if cursor.age % period == period-warning {
let d=level(event);u.difficulty=d;let periods=[[116,104,94,84],[104,94,84,74]];let warning=[28,26,24,22][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(60).min(116);let y=16;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+56))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning+10;
if locked {x=u.anchor_x;}
if x>=116 {u.direction=-1;} if x<=60 {u.direction=1;} let vx=if locked {0} else {u.direction};
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_7:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_7:safe_lane",value:cursor.volley % 2 + 1});
let side = if cursor.volley % 2 == 0 { 12 } else { 76 };
commands.push(#{op:"arc",x:event.x+side,y:16+88-10,to_x:event.x+side+24,to_y:16+88-10,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_7:safe_lane",value:if u.volley%2==0 {3} else {1}});
}
if cursor.age % period == 0 {
// Fire one broadside at a time. The opposite half remains open.
let side = if cursor.volley % 2 == 0 { 12 } else { 76 };
for i in 0..3 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_7:shell",x:event.x+side+i*10,y:16+88-8,vx:0,vy:3,hostile:true});
if cycle<warning && cycle%8==0 {
let side=if u.volley%2==0 {12} else {76};commands=charge(commands,event,x+side,y+78);if phase==2 {commands=aim_charge(commands,event,x+12,y+78,-2,3);commands=aim_charge(commands,event,x+96,y+78,2,3);}
}
// Below half health, outward diagonal flank shells still leave the middle
// and inactive broadside open; no aimed unavoidable point-blank projectile.
if phase == 1 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_7:shell",x:event.x+12,y:16+88-8,vx:-2,vy:3,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_7:shell",x:event.x+96,y:16+88-8,vx:2,vy:3,hostile:true});
}
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_7:shot"});
cursor.volley += 1;
}
state.units[key] = cursor;
if cycle==warning {let side=if u.volley%2==0 {12} else {76};let count=if d==0 {2} else {3};for i in 0..count {commands=shot(commands,"iron_coast_stage2_boss_7:shell",x+side+i*10,y+78,0,3);}commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_7:shot"});} if cycle==warning+10 && phase==2 {commands=shot(commands,"iron_coast_stage2_boss_7:shell",x+12,y+78,-2,3);commands=shot(commands,"iron_coast_stage2_boss_7:shell",x+96,y+78,2,3);commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_7:shot"});}
u.age+=1;
if cycle==warning+10 {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
if state.contains("last_death") && state.last_death == event.entity { return #{commands:[],state:state}; }
state.last_death = event.entity;
if state.contains("units") { state.units.remove(event.entity.to_string()); }
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_7:defeated") {event.objectives["iron_coast_stage2_boss_7:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_7:defeated",value:count},
#{op:"effect",x:event.x+28,y:event.y+88/2},#{op:"effect",x:event.x+84,y:event.y+88/2},
#{op:"play_sound",sound:"iron_coast_stage2_boss_7:death"}],state:state}
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_7:defeated",value:count},#{op:"effect",x:event.x+28,y:event.y+44},#{op:"effect",x:event.x+84,y:event.y+44},#{op:"play_sound",sound:"iron_coast_stage2_boss_7:death"}],state:state}
}
+7 -12
View File
@@ -1,18 +1,13 @@
# Iron Coast II: Leviathan
# Iron Coast II: Leviathan Boss
Separate API 1/version 1.0.0 boss mod, with no dependencies or overrides.
API 1 package, version 1.1.0. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
- Enemy: `iron_coast_stage2_boss_8:unit`, health 360, reward 9000 credits (difficulty applies the normal engine health rules).
- Actual death objective: `iron_coast_stage2_boss_8:defeated`. Escape/despawn grants nothing.
- Persistent patrol is confined to x 60–116, y 4, so the complete carrier stays reachable.
- Three phases: closed reactor, open amber reactor below two-thirds health, red warning strips and blue ion coils below one-third. The first fan leaves the center open; later attacks rotate a full 32-pixel safe lane and add outward flanks in the final act.
- Every volley has an audible warning 18 simulation ticks ahead and cosmetic arcs at attacking pods. Shots travel 3 pixels/tick; no aiming at the player's instantaneous position, no contact explosion at the muzzle.
- Logical art 112×112, 3 phase frames; HD 4×; shell 6×12. Collision dimensions stay identical in SD/HD.
Enemy `iron_coast_stage2_boss_8:unit` is an airborne boss with **360** nominal health and a **112×112** logical canvas. Actual destruction awards 9,000 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Per-entity cursors isolate simultaneous bosses. Spawn callbacks reset reused IDs; actual deaths retire only that cursor and increment the public death counter once. `animation` pins a declared frame; phase state remains serializable.
Leviathan now uses three-phase lane carrier with long charge and bounded flank pressure. Each charge locks its aim and stops movement until its burst finishes. Difficulty changes cadence and bounded shot count; health, reward, damage and all artwork/collision canvases remain unchanged. On an API without event.difficulty the documented fallback is difficulty1.
`art/generated-v1.png` is the untouched builtin output. Prompt and provenance bind the exact generator/reference hashes. The companion mission 8 `build_finale_assets.py` performs complete meaningful-alpha crops and proportional fits, then SD reduction; it also reproduces the seeded 22.05 kHz mono 16-bit warning/phase/shot/death WAVs. It does not repaint or stretch sprites. Reference/catalog images and gameplay screenshots remain outside the package.
Only actual death increments `iron_coast_stage2_boss_8:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
Acceptance source: `../iron_coast_stage2_mission_8/validation/`; standalone boss runs and both full sorties use the actual Catalog/World/Rhai and starter guns. Durable simulation/SD-HD captures are external review evidence; final metrics are copied into `validation-summary.json` after balance is frozen.
## Validation and history
Actual starter boss-only runs from 75 shield: 186 ticks at difficulties 0/1 and 366 at 2/3; 75/67 shield remains. Retained-state spawn-step lethal replays use the actual World and verify the counter and reward once on each new lifecycle.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_boss_8"
name = "Iron Coast II: Leviathan Boss"
version = "1.0.0"
version = "1.1.0"
api_version = 1
description = "Distinct persistent Leviathan carrier with telegraphed phase attacks, SD/HD artwork and original synthesized PCM sounds."
content = "content.json"
+103 -76
View File
@@ -1,91 +1,118 @@
// Per-entity cursors prevent simultaneous carriers sharing phase/volley state.
fn alive(event) {
for unit in event.entities { if unit.entity == event.entity && unit.health > 0 { return true; } }
false
// Private Stage2 retune. Content IDs, health/rewards/art canvases and death objective stay stable.
// Difficulty is optional until the small general event API is actually applied; v1 falls back to1.
fn level(event) { if event.contains("difficulty") {event.difficulty.max(0).min(3)} else {1} }
// Queued hook-local health/x/y may predate preceding callbacks. Use the live actor.
// A present null actor is retired/nonenemy and MUST NOT fall back to the older entity list.
fn current(event) {
let actor=#{};
if event.contains("actor") {if event.actor!=() {actor=event.actor;}}
else {for e in event.entities {if e.entity==event.entity {actor=e;break;}}}
event.live=false;
if actor.contains("health") {event.health=actor.health;event.x=actor.x;event.y=actor.y;event.live=actor.health>0;}
else {event.health=0;}
if event.contains("player") && event.player.contains("x") {event.player_x=event.player.x;}
event
}
fn alive(event) {event.live}
fn maximum(event) {event.health.max(1)}
fn ready(state) {
if !state.contains("units") {state.units=#{};}
// Migrate original Gatebreaker/Undertow map cursors once. Start a full new warning window.
if state.contains("ages") {
for key in state.ages.keys() {
if !state.units.contains(key) {
let hp=if state.contains("maximums") && state.maximums.contains(key) {state.maximums[key]} else {1};
let dir=if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1};
state.units[key]=#{max_health:hp,direction:dir,volley:0};
}
}
state.remove("ages");state.remove("phases");state.remove("directions");state.remove("maximums");
}
if !state.contains("dead") {state.dead=#{};}
if !state.contains("dead_order") {
let order=[];let kept=#{};
for key in state.dead.keys() {if order.len()>=128 {let old=order.remove(0);kept.remove(old);} kept[key]=true;order.push(key);}
state.dead=kept;state.dead_order=order;
}
// Old carrier last_death is also retained as a bounded duplicate-death guard.
if state.contains("last_death") {
let key=state.last_death.to_string();
if !state.dead.contains(key) {
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);
}
state.remove("last_death");
}
state
}
fn upgrade(u,event) {
if !u.contains("schema") || u.schema!=2 {
let hp=if u.contains("max_health") {u.max_health.max(event.health).max(1)} else {maximum(event)};
let dir=if u.contains("direction") {u.direction} else {1};
let volley=if u.contains("volley") {u.volley.max(0)} else {0};
u=#{schema:2,age:0,phase:0,max_health:hp,direction:dir,volley:volley,aim:0,anchor_x:event.x};
}
u
}
fn shot(commands,weapon,x,y,vx,vy) {commands.push(#{op:"projectile",weapon:weapon,x:x,y:y,vx:vx,vy:vy,hostile:true});commands}
fn charge(commands,event,x,y) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+12,to_y:y,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn aim_charge(commands,event,x,y,vx,vy) {
commands.push(#{op:"arc",x:x,y:y,to_x:x+vx*4,to_y:y+vy*4,ticks:8,anchor:event.entity,to_anchor:event.entity});commands
}
fn on_spawn(event,state,random) {
// A new spawn is a fresh lifecycle even when killed before dispatch.
if state.contains("last_death") && state.last_death == event.entity { state.remove("last_death"); }
if !alive(event) { return #{commands:[],state:state}; }
if !state.contains("units") { state.units = #{}; }
// Prune cursors from aborted/restarted worlds; retain simultaneous live carriers.
event=current(event);state=ready(state);let key=event.entity.to_string();
let live=#{};
for unit in event.entities { if unit.definition == "iron_coast_stage2_boss_8:unit" { live[unit.entity.to_string()] = true; } }
for old in state.units.keys() { if !live.contains(old) { state.units.remove(old); } }
let spawn_health = 1;
for unit in event.entities { if unit.entity == event.entity { spawn_health = unit.health; } }
state.units[event.entity.to_string()] = #{age:0,volley:0,direction:1,phase:0,max_health:spawn_health};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2},#{op:"animation",entity:event.entity,frame:0}],state:state}
for e in event.entities {let k=e.entity.to_string();if e.health>0 && e.definition=="iron_coast_stage2_boss_8:unit" && state.units.contains(k) {live[k]=state.units[k];}}
state.units=live;state.dead.remove(key);
let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;
if !alive(event) {return #{commands:[],state:state};}
state.units[key]=upgrade(#{max_health:maximum(event),direction:1,volley:0},event);
let commands=[#{op:"velocity",entity:event.entity,vx:0,vy:2}];
commands.push(#{op:"animation",entity:event.entity,frame:0});
#{commands:commands,state:state}
}
fn on_tick(event,state,random) {
if event.health <= 0 || !alive(event) { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let cursor = if state.units.contains(key) { state.units[key] } else { #{age:0,volley:0,direction:1,phase:0,max_health:event.health} };
if event.y < 4 {
state.units[key] = cursor;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
event=current(event);if event.health<=0 || !alive(event) {return #{commands:[],state:state};}
state=ready(state);let key=event.entity.to_string();
let u=upgrade(if state.units.contains(key) {state.units[key]} else {#{}},event);
if event.y<4 {state.units[key]=u;return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};}
let phase=if event.health*3<=u.max_health {3} else if event.health*3<=u.max_health*2 {2} else {1};
let commands=[];
if phase!=u.phase {
u.phase=phase;u.age=0;
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_8:phase",value:phase});
commands.push(#{op:"animation",entity:event.entity,frame:phase-1});
}
let phase = if event.health * 3 <= cursor.max_health { 2 } else if event.health * 3 <= cursor.max_health * 2 { 1 } else { 0 };
let commands = [#{op:"move",entity:event.entity,x:event.x,y:4}];
if event.x >= 116 { cursor.direction = -1; }
if event.x <= 60 { cursor.direction = 1; }
commands.push(#{op:"velocity",entity:event.entity,vx:cursor.direction,vy:0});
if phase != cursor.phase {
cursor.phase = phase;
cursor.age = 0;
commands.push(#{op:"animation",entity:event.entity,frame:phase});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_8:phase",value:phase+1});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_8:phase"});
}
cursor.age += 1;
let period = if phase == 0 { 96 } else if phase == 1 { 84 } else { 72 };
let warning = 18;
if cursor.age % period == period-warning {
let d=level(event);u.difficulty=d;let periods=[[140,126,112,100],[126,112,100,90],[114,102,90,80]];let warning=[32,30,28,26][d];let period=periods[phase-1][d];let cycle=u.age%period;
let x=event.x.max(60).min(116);let y=4;
// Track only before charge; aim and deck position are frozen throughout the attack window.
if cycle==0 {u.anchor_x=x;u.aim=((event.player_x+16)-(x+56))/40;u.aim=u.aim.max(-2).min(2);}
let locked=cycle<=warning;
if locked {x=u.anchor_x;}
let goal=if u.volley%2==0 {60} else {116};let vx=if locked || (goal-x).abs()<=2 {0} else if goal<x {-1} else {1};
commands.push(#{op:"move",entity:event.entity,x:x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if cycle==0 {
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_8:warning"});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_8:safe_lane",value:cursor.volley % 3 + 1});
// Blue charge arcs indicate the attacking pods, not an explosion.
let safe = cursor.volley % 3;
for lane in 0..3 {
if lane != safe {
commands.push(#{op:"arc",x:event.x+12+lane*32,y:4+112-14,to_x:event.x+28+lane*32,to_y:4+112-14,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"objective",key:"iron_coast_stage2_boss_8:safe_lane",value:if phase==1 {2} else {u.volley%3+1}});
}
if cycle<warning && cycle%8==0 {
let safe=u.volley%3;if phase==1 {commands=charge(commands,event,x+12,y+100);commands=charge(commands,event,x+88,y+100);} else {for lane in 0..3 {if lane!=safe {commands=charge(commands,event,x+12+lane*32,y+100);}}if phase==3 {commands=aim_charge(commands,event,x+8,y+100,-2,3);commands=aim_charge(commands,event,x+100,y+100,2,3);}}
}
}
if cursor.age % period == 0 {
let safe = cursor.volley % 3;
if phase == 0 {
// Wide outward fan leaves the central column entirely open.
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_8:shell",x:event.x+16,y:4+112-12,vx:-2,vy:3,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_8:shell",x:event.x+32,y:4+112-12,vx:-1,vy:3,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_8:shell",x:event.x+76,y:4+112-12,vx:1,vy:3,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_8:shell",x:event.x+92,y:4+112-12,vx:2,vy:3,hostile:true});
} else {
// Every later volley leaves one full32px deck lane open; the warning names
// it eighteen ticks early. Phase3 adds only outward flank shots.
for lane in 0..3 {
if lane != safe {
for i in 0..2 { commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_8:shell",x:event.x+12+lane*32+i*10,y:4+112-12,vx:0,vy:3,hostile:true}); }
}
}
if phase == 2 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_8:shell",x:event.x+8,y:4+112-12,vx:-2,vy:3,hostile:true});
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_boss_8:shell",x:event.x+100,y:4+112-12,vx:2,vy:3,hostile:true});
}
}
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_8:shot"});
cursor.volley += 1;
}
state.units[key] = cursor;
if cycle==warning {let safe=u.volley%3;if phase==1 {commands=shot(commands,"iron_coast_stage2_boss_8:shell",x+16,y+100,-2,3);commands=shot(commands,"iron_coast_stage2_boss_8:shell",x+32,y+100,-1,3);commands=shot(commands,"iron_coast_stage2_boss_8:shell",x+76,y+100,1,3);commands=shot(commands,"iron_coast_stage2_boss_8:shell",x+92,y+100,2,3);} else {let count=if d==0 {1} else {2};for lane in 0..3 {if lane!=safe {for i in 0..count {commands=shot(commands,"iron_coast_stage2_boss_8:shell",x+12+lane*32+i*10,y+100,0,3);}}}if phase==3 {commands=shot(commands,"iron_coast_stage2_boss_8:shell",x+8,y+100,-2,3);commands=shot(commands,"iron_coast_stage2_boss_8:shell",x+100,y+100,2,3);}}commands.push(#{op:"play_sound",sound:"iron_coast_stage2_boss_8:shot"});}
u.age+=1;
if cycle==warning {u.volley+=1;}
state.units[key]=u;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {#{commands:[],state:state}}
fn on_death(event,state,random) {
if state.contains("last_death") && state.last_death == event.entity { return #{commands:[],state:state}; }
state.last_death = event.entity;
if state.contains("units") { state.units.remove(event.entity.to_string()); }
event=current(event);state=ready(state);let key=event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len()>=128 {let old=state.dead_order.remove(0);state.dead.remove(old);}
state.dead[key]=true;state.dead_order.push(key);state.units.remove(key);
let count=if event.objectives.contains("iron_coast_stage2_boss_8:defeated") {event.objectives["iron_coast_stage2_boss_8:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_8:defeated",value:count},
#{op:"effect",x:event.x+28,y:event.y+112/2},#{op:"effect",x:event.x+84,y:event.y+112/2},
#{op:"play_sound",sound:"iron_coast_stage2_boss_8:death"}],state:state}
#{commands:[#{op:"objective",key:"iron_coast_stage2_boss_8:defeated",value:count},#{op:"effect",x:event.x+28,y:event.y+56},#{op:"effect",x:event.x+84,y:event.y+56},#{op:"play_sound",sound:"iron_coast_stage2_boss_8:death"}],state:state}
}
+9 -24
View File
@@ -1,34 +1,19 @@
# Iron Coast II: Needlewing
An independent enemy package for API version 1. Enable **Iron Coast II: Needlewing** in the game's Mods menu. Enabling a mission that requires it also enables this package through the dependency manager. Enabling this enemy alone registers its content; a mission or another mod must spawn it. There are no dependencies or overrides.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
## Behavior
Fast 4-pixel/tick entry, one 3-pixel lateral attack leg, one reversing break, then a committed exit. Its route is distinct from the scout.
- Enemy ID: `iron_coast_stage2_interceptor:unit`; air target, logical canvas 24×24, health 18, reward 100 credits.
- Fast flanker. Its integer route curves through eight lateral phases, with short straight pauses and a downward speed of one or two logical pixels per tick.
- Three aimed 2-damage projectiles at speed 3, four ticks apart. The next burst starts after 70 engine ticks (about 3 seconds). There is no warning chirp for this quick, low-damage enemy.
- `:gun` is a scripted firing controller; `:projectile` is the distinct hostile projectile definition. These weapons are enemy content, unavailable in the shop; no player equipment or credits are granted.
- `:shot`, `:warning` and `:death` are synthesized local WAV cues. Needlewing's warning is supplied for authors but is not played by its ordinary AI.
The body remains 24×24, health 18, reward 100. SD and HD use the existing aligned PNGs. All PNG/WAV/master/prompt/provenance bytes are unchanged. Nonlegacy hostile projectile collision remains the engine's fixed `abs(dx)<10 && abs(dy)<10` point-to-player-center test, independent of artwork size. The following canvas change only changes visible sampling; it does not shrink that threshold. Projectile logical canvas is 7×7 in both modes; the complete existing square PNG canvas is sampled by the normal renderer, without crop or asset repaint. Its narrow visible core is checked in both modes.
The original forward guns can hit these enemies. Air-only weapons cannot hit ground enemies, and ground-only weapons cannot hit the Needlewing. Collision dimensions are identical in SD and HD; artwork has transparent padding and one aligned frame per sprite. Normal units leave the playfield if not killed. A mission may hold a required target inside the screen while retaining its lateral movement.
All four difficulty profiles are authored in Rhai. Training/Rookie/Veteran/Elite (difficulty0/1/2/3) repeat periods are [66, 56, 48, 42] ticks, short-salvo counts [1, 2, 2, 3], windups [8, 7, 7, 6], projectile maximum-axis speeds [4, 4, 5, 5]; successive salvo shots are 4 ticks apart. These periods are script clocks, not the unused engine auto-fire cooldown. The enemy has no automatic weapon so it cannot fire a second implicit volley. Existing `:gun` definitions in Stage 2 remain compatibility content and do not drive this AI.
## Mission integration
Aim is frozen at the start of a short salvo. Units only fire when their complete body is visible and still safely above the player. Salvos cancel as soon as that condition fails. Shot audio plays once per actual projectile; siege/skimmer warning audio plays once per salvo. Existing large explosion/flare effects are not used as muzzle blooms. Native death feedback remains engine-owned.
Spawn `iron_coast_stage2_interceptor:unit` through normal mission definitions or the documented `spawn` command. Actual deaths increment the objective `iron_coast_stage2_interceptor:defeated` once. Escapes and despawns do not increment it. The engine awards the configured reward and creates normal death feedback; this script adds its local death cue. Objective keys and content IDs are namespaced so all three shared enemy packages coexist.
`event.difficulty` must be integer 0–3; absent or invalid data uses difficulty1 (Rookie). Optional `event.actor` uses the current central-dispatch enemy snapshot, or null for a retired actor. Older engines lacking both new fields use a compatibility lookup through `event.entities`. Current null actors have the integer difficulty field and skip immediately. No full-event native map methods or helper arguments are used in the current path. The dense-wave operation-budget acceptance requires the current actor API; a fallback trace does not prove that new API is installed.
Firing is rejected for dead or invisible units and after they pass the player. Bursts are finite; scripts never spawn enemies, grant equipment, deal direct damage, or complete a mission. Per-entity movement clocks and burst state are serializable and use only supplied deterministic RNG. Up to 128 live units receive custom AI, with periodic pruning and a bounded 128-entry death guard. Excess units retain basic engine movement and do not gain extra attacks. The diagnostics counters saturate at one billion. Missions should use much smaller readable waves.
Per-entity script state is bounded to 64 entries. Birth/death update the bounded lifecycle index; normal ticks never prune or scan the world when actor metadata is present. State, RNG and salvo timing are deterministic and save-continuable. Only actual engine death callbacks increment `iron_coast_stage2_interceptor:defeated` once. Air exits never award kill credit; required ground units never self-clear. The script never deals direct damage, grants equipment, spawns enemy reinforcements or completes a mission.
No new controls are introduced. Fire and weapon selection remain the normal game controls. All prices are inapplicable because the package adds enemies, not purchasable items.
## Validation and history
## Assets and validation
Both artwork modes, unchanged generated masters, prompts, derivation recipe and all sound recipes are included. See [artwork provenance](ART_PROVENANCE.md), [art checks](art-qa.json), [audio recipe](audio-recipe.json) and [validation record](validation/acceptance.json). Decoded original style references and local review screenshots are deliberately kept outside this package.
From the RustRaptor folder:
```sh
cargo test --locked --no-default-features --test stage2_enemies
cargo run --locked --no-default-features -- --data data --mods mods --validate-mods iron_coast_stage2_interceptor
```
Tests exercise actual World callbacks, finite volleys and telegraphs, ground/air filtering, damage behavior, escapes, real death rewards and counters, reused entity IDs, bounded state, full save/RNG continuation and independent/combined activation. Shipped PNG geometry and all nine effects are also checked through the real SoundBank/Mixer. Local catalog validation and indexed SD/HD scale 1–4 galleries are recorded separately. These controlled checks do not establish full campaign balance or a live SDL scene; the mission packages have their own route tests.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
@@ -14,15 +14,15 @@
"id": "iron_coast_stage2_interceptor:projectile",
"name": "Needlewing shell",
"damage": 2,
"speed": 3,
"cooldown": 69,
"speed": 4,
"cooldown": 56,
"behavior": "straight",
"target": "all",
"art": {
"png": "art/projectile-sd.png",
"hd_png": "art/projectile-hd.png",
"logical_width": 8,
"logical_height": 8,
"logical_width": 7,
"logical_height": 7,
"frames": 1
}
}
@@ -36,8 +36,7 @@
"reward": 100,
"ground": false,
"boss": false,
"cooldown": 69,
"weapon": "iron_coast_stage2_interceptor:gun",
"cooldown": 56,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_interceptor"
name = "Iron Coast II: Needlewing"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "Independent Stage 2 interceptor: Needlewing. Scripted movement, readable volleys, SD/HD sprites and synthesized sounds."
content = "content.json"
@@ -1,125 +1,120 @@
// Integer-only per-entity clocks. No spawns, equipment grants or direct damage.
// The engine owns collision, one kill reward, projectile retirement and explosions.
fn alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Authored attack clocks, not a legacy selector. Art, sound and kill rewards are unchanged.
// The central actor snapshot gives O(1) current-entity access. A legacy-only scan
// remains for dispatchers lacking BOTH actor and difficulty; no full event helper.
// Current dispatch supplies BOTH actor and difficulty. Read compact properties
// directly: native map methods recursively validate the entire event payload.
// Only an older dispatch with neither field enters the legacy array fallback.
fn legacy_actor(entities, id) {
for unit in entities { if unit.entity == id { return unit; } }
()
}
false
fn difficulty(value) {
if type_of(value) == "i64" && value >= 0 && value <= 3 { return value; }
1
}
// Firing events use muzzle coordinates; visibility must use the real canvas.
fn ready_to_fire(event) {
for unit in event.entities {
if unit.entity == event.entity {
return unit.health > 0 && unit.x >= 0 && unit.x + unit.width <= 320 && unit.y >= 0 && unit.y + unit.height <= 180 && unit.y + unit.height < event.player.y - 8;
}
}
false
}
fn fresh(random) { #{clock:0, sign: if random[0] % 2 == 0 {1} else {-1}, remaining:0, wait:0, aim_x:0, aim_y:3} }
fn ready(state) {
if !state.contains("units") { state.units = #{}; }
if !state.contains("order") { state.order = []; }
if !state.contains("dead") { state.dead = #{}; }
if !state.contains("dead_order") { state.dead_order = []; }
state
}
fn tidy(event, state) {
state = ready(state);
let retained = #{};
for unit in event.entities {
let key = unit.entity.to_string();
if unit.definition == "iron_coast_stage2_interceptor:unit" && unit.health > 0 && state.units.contains(key) && retained.len() < 128 {
retained[key] = state.units[key];
fn bump(state, name) {
if !state.contains(name) { return 1; }
if state[name] < 1000000000 { state[name] + 1 } else { state[name] }
}
fn visible(a, player) {
a.x >= 0 && a.x + a.width <= 320 && a.y >= 4 && a.y + a.height <= 174 && a.y + a.height < player.y - 8
}
state.units = retained;
state
}
fn counter(state, key) {
if !state.contains(key) {return 1;}
if state[key] >= 1000000000 {return 1000000000;}
state[key] + 1
}
fn clamp_speed(value) { if value < -3 {-3} else if value > 3 {3} else {value} }
fn aimed(event) {
let dx = event.player.x + 16 - event.x;
let dy = event.player.y + 16 - event.y;
fn aimed(a, player, speed) {
let dx = player.x + 16 - (a.x + a.width / 2);
let dy = player.y + 16 - (a.y + a.height);
let ax = if dx < 0 {-dx} else {dx};
let ay = if dy < 0 {-dy} else {dy};
let norm = if ax > ay {ax} else {ay};
if norm == 0 {return [0,3];}
let vy = dy * 3 / norm;
[clamp_speed(dx * 3 / norm), if vy < 1 {1} else {vy}]
let norm = if ax > dy {ax} else {dy};
if norm <= 0 { return [0,speed]; }
let vx = dx * speed / norm;
let vy = dy * speed / norm;
[vx, if vy < 1 {1} else {vy}]
}
fn on_spawn(event, state, random) {
state = tidy(event,state);
let key = event.entity.to_string();
// Entity IDs restart with each mission. Clear the old lifecycle guard even
// when a spawn was already killed before queued callbacks are dispatched.
state.dead.remove(key);
let keep = [];
for old in state.dead_order { if old != key {keep.push(old);} }
state.dead_order = keep;
if !alive(event) {return #{commands:[],state:state};}
if state.units.len() >= 128 {return #{commands:[],state:state};}
let unit = fresh(random);
state.units[key] = unit;
#{commands:[#{op:"velocity",entity:event.entity,vx:unit.sign,vy:1}],state:state}
}
fn on_fire(event, state, random) {
state = ready(state);
if !ready_to_fire(event) || event.x < 0 || event.x >= 320 || event.y < 24 || event.y > 180 || event.y >= event.player.y - 8 {
return #{commands:[],state:state};
}
let key = event.entity.to_string();
if !state.units.contains(key) {return #{commands:[],state:state};}
let unit = state.units[key];
let aim = aimed(event);
unit.remaining = 3; unit.wait = 0;
unit.aim_x = aim[0]; unit.aim_y = aim[1];
state.units[key] = unit;
#{commands:[],state:state}
// IDs restart with missions. Replace this lifecycle even if the callback is stale.
state.units.remove(key); state.dead.remove(key);
let order = []; for old in state.order { if old != key { order.push(old); } }
state.order = order;
let dead_order = []; for old in state.dead_order { if old != key { dead_order.push(old); } }
state.dead_order = dead_order;
let a = event.actor;
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
if a == () || a.health <= 0 { return #{commands:[],state:state}; }
// Constant bound; pruning is at birth/death, never an all-entity tick sweep.
if state.order.len() >= 64 { let old = state.order.remove(0); state.units.remove(old); }
state.order.push(key);
state.units[key] = #{age:0, sign:if a.x < 148 {1} else {-1}, cooldown:18 + event.entity % 19, remaining:0, wait:0, aim_x:0, aim_y:3};
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:4}],state:state}
}
fn on_tick(event, state, random) {
state = ready(state);
// Stagger periodic pruning to keep dense waves within the shared script budget.
if (event.tick + event.entity) % 32 == 0 { state = tidy(event,state); }
if event.health <= 0 || !alive(event) {return #{commands:[],state:state};}
let key = event.entity.to_string();
let a = event.actor;
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
if a == () || a.health <= 0 { state.units.remove(key); return #{commands:[],state:state}; }
if !state.units.contains(key) { return #{commands:[],state:state}; }
let unit = state.units[key];
unit.clock = (unit.clock + 1) % 96;
let phase = unit.clock / 12;
let route = [2,1,0,-1,-2,-1,0,1];
let sideways = route[phase] * unit.sign;
let down = if unit.clock % 48 < 12 {2} else {1};
if event.x <= 8 {sideways = 1;}
if event.x >= 320 - 24 - 8 {sideways = -1;}
let commands = [#{op:"velocity",entity:event.entity,vx:sideways,vy:down}];
// Never fire from an invisible/dead unit, or backwards at a player it passed.
if event.x < 0 || event.x + 24 > 320 || event.y < 0 || event.y + 24 > 180 || event.y + 24 >= event.player.y - 8 {
unit.remaining = 0;
unit.age = if unit.age < 1000000000 {unit.age + 1} else {unit.age};
let commands = [];
if a.y > 224 || a.x < -32 || a.x > 328 {
state.units.remove(key);
return #{commands:[#{op:"despawn",entity:event.entity}],state:state};
}
// Fast entry, one lateral attack leg, then a single reversing break and exit.
let vx = if unit.age < 12 {0} else if unit.age < 48 {unit.sign * 3} else if unit.age < 64 {-unit.sign * 2} else {-unit.sign * 3};
let vy = if unit.age < 12 {4} else if unit.age < 48 {1} else if unit.age < 64 {2} else {3};
if unit.age < 64 && a.x < 8 {vx = 2;}
if unit.age < 64 && a.x > 288 {vx = -2;}
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:vy});
let d = difficulty(event.difficulty);
let period = [66, 56, 48, 42][d];
let burst = [1, 2, 2, 3][d];
let windup = [8, 7, 7, 6][d];
let speed = [4, 4, 5, 5][d];
if unit.cooldown > 0 { unit.cooldown -= 1; }
let can_fire = visible(a,event.player) && unit.age >= 12;
if !can_fire { unit.remaining = 0; unit.wait = 0; }
if can_fire && unit.remaining == 0 && unit.cooldown == 0 {
let aim = aimed(a,event.player,speed);
unit.aim_x = aim[0]; unit.aim_y = aim[1];
// This tick consumes one counter step; retain the full documented warning interval.
unit.remaining = burst; unit.wait = windup + 1; unit.cooldown = period;
// No oversized flare: the visible approach/hold is the light-unit telegraph.
}
if unit.wait > 0 { unit.wait -= 1; }
if unit.remaining > 0 && unit.wait == 0 {
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_interceptor:projectile",x:event.x+8,y:event.y+22,vx:unit.aim_x,vy:unit.aim_y,hostile:true});
unit.remaining -= 1;unit.wait=4;
if can_fire && unit.remaining > 0 && unit.wait == 0 {
// Aim is frozen at the start of this short salvo: no homing correction.
commands.push(#{op:"projectile",weapon:"iron_coast_stage2_interceptor:projectile",x:a.x + a.width / 2 - 7 / 2,y:a.y + a.height - 7 / 2,vx:unit.aim_x,vy:unit.aim_y,hostile:true});
commands.push(#{op:"play_sound",sound:"iron_coast_stage2_interceptor:shot"});
state.shots = counter(state,"shots");
unit.remaining -= 1; unit.wait = 4;
state.shots = bump(state,"shots");
}
state.units[key] = unit;
#{commands:commands,state:state}
}
fn on_hit(event, state, random) {
state.hits = counter(state,"hits");
state.hits = bump(state,"hits");
#{commands:[],state:state}
}
fn on_death(event, state, random) {
state = tidy(event,state);
state = ready(state);
let key = event.entity.to_string();
if alive(event) || state.dead.contains(key) {return #{commands:[],state:state};}
if state.dead_order.len() >= 128 {let oldest=state.dead_order.remove(0);state.dead.remove(oldest);}
// Only the engine's actual death hook advances required-target counters.
// Natural air exits never enter this path and never grant rewards/objectives.
let a = event.actor;
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
if (a != () && a.health > 0) || state.dead.contains(key) { return #{commands:[],state:state}; }
if state.dead_order.len() >= 64 { let old = state.dead_order.remove(0); state.dead.remove(old); }
state.dead[key] = true; state.dead_order.push(key); state.units.remove(key);
state.destroyed = counter(state,"destroyed");
state.destroyed = bump(state,"destroyed");
let defeated = if event.objectives.contains("iron_coast_stage2_interceptor:defeated") {event.objectives["iron_coast_stage2_interceptor:defeated"] + 1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_stage2_interceptor:defeated",value:defeated},#{op:"play_sound",sound:"iron_coast_stage2_interceptor:death"}],state:state}
}
+6 -43
View File
@@ -1,50 +1,13 @@
# Iron Coast II — Mission 1: Nightbreak Inlet
An independently playable Black Tide sortie with native Raptor aircraft paths,
new patrols and its own boss. Enable **Nightbreak Inlet** in **Mods**; installed compatible
required enemy/boss/music dependencies are selected automatically. Select
`iron_coast_stage2_mission_1:mission_01` in the hangar. Installing the optional full Black Tide
campaign places this sortie in its eight-mission progression.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_stage2_boss_1`, `iron_coast_stage2_music`, `iron_coast_stage2_interceptor`, `iron_coast_stage2_siege`.
Destroy 3 actual `siege` units and then the `iron_coast_stage2_boss_1:unit` boss. Required
convoys are held inside the reachable playfield after entry; leaving or despawning
never counts as destruction. The boss arrives at tick 1900. A duration
timeout cannot grant victory. Completion awards 24,000 credits once, in addition
to actual enemy rewards. Replaying resets this sortie's kill counters and phases.
Mission `iron_coast_stage2_mission_1:mission_01`: **Nightbreak Inlet**. Destroy three required siege defenses and the boss. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
Hold **Ctrl/Fire** to use owned weapons; **Alt/Shift** cycles selected weapons;
**Space** retains bombs. Starter forward guns can target all mandatory enemies;
optional purchased gear improves firepower. The mission grants no equipment or
shield/credit cheats, and works with a full 20-slot inventory.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **30 / 43 / 55 / 67**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
New terrain is generated from original Raptor material/palette references, with a
320×512 logical map and a reflected scroll cycle repeated through 4,096 rows.
This preserves terrain beneath delayed boss fights. All mandatory targets appear
on every difficulty; higher difficulties add patrol reinforcements. The briefing crops the same
master. Both SD and 4× HD terrain and generated WAV mission cues are tracked.
`build_assets.py` reproduces all derived art/audio from the untouched raw master.
See [generation provenance](generation-provenance.json) and
[asset recipe/report](asset-report.json). Original reference images and working
review captures stay outside the package.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
```sh
python3 mods/iron_coast_stage2_mission_1/build_assets.py
CARGO_TARGET_DIR="$PWD/target" cargo run --locked --release --manifest-path mods/iron_coast_stage2_boss_1/validation/Cargo.toml
```
## Validation and history
The CLI requires all dependency IDs explicitly; the in-game manager resolves them.
Final real-catalog gameplay, audio, native path and save/RNG observations are
recorded in `acceptance-report.json`. Local single-sortie campaign definition
uses no overrides, so the optional aggregate campaign controls progression.
Author acceptance passed seven complete cases: difficulties 0–3 with starter
guns, default 100-shield, a full 20-slot inventory, and owned optional new gear.
The registered 75-shield pilot completes at tick 2222 with 69 shield and
a 41,010-credit gain using ordinary Fire and a mouse movement cap of
four logical pixels per tick. Serialized script state/RNG continuation, replay
reset, reward-once and actual native path motion checks passed. A separate
health-buffer fixture verifies reachable targets and terrain after 5,000 ticks;
it is not a balance result. The purchased-gear fixture funds shop purchases
before gameplay, rather than claiming a new pilot can afford both weapons.
Asset recipes use Python 3 with Pillow. The game uses the shipped PNG and WAV files.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
@@ -26,6 +26,69 @@
"y": -32,
"difficulty": 0
},
{
"tick": 180,
"enemy": "base:enemy_0_73",
"x": 232,
"y": -32,
"difficulty": 0
},
{
"tick": 180,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -44,
"difficulty": 0
},
{
"tick": 226,
"enemy": "base:enemy_0_79",
"x": 240,
"y": -32,
"difficulty": 2
},
{
"tick": 226,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -44,
"difficulty": 2
},
{
"tick": 290,
"enemy": "base:enemy_0_79",
"x": 48,
"y": -32,
"difficulty": 0
},
{
"tick": 290,
"enemy": "base:enemy_0_73",
"x": 224,
"y": -44,
"difficulty": 0
},
{
"tick": 290,
"enemy": "base:enemy_0_74",
"x": 112,
"y": -56,
"difficulty": 0
},
{
"tick": 326,
"enemy": "base:enemy_0_79",
"x": 240,
"y": -32,
"difficulty": 1
},
{
"tick": 326,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -44,
"difficulty": 1
},
{
"tick": 330,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -40,6 +103,27 @@
"y": -32,
"difficulty": 0
},
{
"tick": 350,
"enemy": "base:enemy_0_74",
"x": 232,
"y": -32,
"difficulty": 3
},
{
"tick": 350,
"enemy": "base:enemy_0_79",
"x": 184,
"y": -44,
"difficulty": 3
},
{
"tick": 350,
"enemy": "base:enemy_0_73",
"x": 136,
"y": -56,
"difficulty": 3
},
{
"tick": 420,
"enemy": "iron_coast_stage2_siege:unit",
@@ -61,6 +145,55 @@
"y": -32,
"difficulty": 0
},
{
"tick": 610,
"enemy": "base:enemy_0_79",
"x": 240,
"y": -32,
"difficulty": 0
},
{
"tick": 610,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -44,
"difficulty": 0
},
{
"tick": 656,
"enemy": "base:enemy_0_74",
"x": 232,
"y": -32,
"difficulty": 2
},
{
"tick": 656,
"enemy": "base:enemy_0_79",
"x": 184,
"y": -44,
"difficulty": 2
},
{
"tick": 720,
"enemy": "base:enemy_0_74",
"x": 32,
"y": -32,
"difficulty": 0
},
{
"tick": 720,
"enemy": "base:enemy_0_79",
"x": 80,
"y": -44,
"difficulty": 0
},
{
"tick": 720,
"enemy": "base:enemy_0_73",
"x": 128,
"y": -56,
"difficulty": 0
},
{
"tick": 750,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -68,6 +201,41 @@
"y": -32,
"difficulty": 1
},
{
"tick": 756,
"enemy": "base:enemy_0_74",
"x": 232,
"y": -32,
"difficulty": 1
},
{
"tick": 756,
"enemy": "base:enemy_0_79",
"x": 184,
"y": -44,
"difficulty": 1
},
{
"tick": 780,
"enemy": "base:enemy_0_73",
"x": 240,
"y": -32,
"difficulty": 3
},
{
"tick": 780,
"enemy": "base:enemy_0_74",
"x": 64,
"y": -44,
"difficulty": 3
},
{
"tick": 780,
"enemy": "base:enemy_0_79",
"x": 184,
"y": -56,
"difficulty": 3
},
{
"tick": 830,
"enemy": "base:enemy_0_20",
@@ -75,6 +243,34 @@
"y": -32,
"difficulty": 1
},
{
"tick": 900,
"enemy": "base:enemy_0_74",
"x": 232,
"y": -32,
"difficulty": 0
},
{
"tick": 900,
"enemy": "base:enemy_0_79",
"x": 184,
"y": -44,
"difficulty": 0
},
{
"tick": 946,
"enemy": "base:enemy_0_73",
"x": 240,
"y": -32,
"difficulty": 2
},
{
"tick": 946,
"enemy": "base:enemy_0_74",
"x": 64,
"y": -44,
"difficulty": 2
},
{
"tick": 960,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -96,6 +292,62 @@
"y": -32,
"difficulty": 1
},
{
"tick": 1100,
"enemy": "base:enemy_0_73",
"x": 48,
"y": -32,
"difficulty": 0
},
{
"tick": 1100,
"enemy": "base:enemy_0_74",
"x": 224,
"y": -44,
"difficulty": 0
},
{
"tick": 1100,
"enemy": "base:enemy_0_79",
"x": 112,
"y": -56,
"difficulty": 0
},
{
"tick": 1136,
"enemy": "base:enemy_0_73",
"x": 240,
"y": -32,
"difficulty": 1
},
{
"tick": 1136,
"enemy": "base:enemy_0_74",
"x": 64,
"y": -44,
"difficulty": 1
},
{
"tick": 1160,
"enemy": "base:enemy_0_79",
"x": 232,
"y": -32,
"difficulty": 3
},
{
"tick": 1160,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -44,
"difficulty": 3
},
{
"tick": 1160,
"enemy": "base:enemy_0_74",
"x": 136,
"y": -56,
"difficulty": 3
},
{
"tick": 1170,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -103,6 +355,20 @@
"y": -32,
"difficulty": 2
},
{
"tick": 1300,
"enemy": "base:enemy_0_73",
"x": 240,
"y": -32,
"difficulty": 0
},
{
"tick": 1300,
"enemy": "base:enemy_0_74",
"x": 64,
"y": -44,
"difficulty": 0
},
{
"tick": 1330,
"enemy": "base:enemy_0_56",
@@ -110,6 +376,20 @@
"y": -32,
"difficulty": 1
},
{
"tick": 1346,
"enemy": "base:enemy_0_79",
"x": 232,
"y": -32,
"difficulty": 2
},
{
"tick": 1346,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -44,
"difficulty": 2
},
{
"tick": 1380,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -138,6 +418,62 @@
"y": -32,
"difficulty": 2
},
{
"tick": 1650,
"enemy": "base:enemy_0_79",
"x": 32,
"y": -32,
"difficulty": 0
},
{
"tick": 1650,
"enemy": "base:enemy_0_73",
"x": 80,
"y": -44,
"difficulty": 0
},
{
"tick": 1650,
"enemy": "base:enemy_0_74",
"x": 128,
"y": -56,
"difficulty": 0
},
{
"tick": 1686,
"enemy": "base:enemy_0_79",
"x": 232,
"y": -32,
"difficulty": 1
},
{
"tick": 1686,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -44,
"difficulty": 1
},
{
"tick": 1710,
"enemy": "base:enemy_0_74",
"x": 240,
"y": -32,
"difficulty": 3
},
{
"tick": 1710,
"enemy": "base:enemy_0_79",
"x": 64,
"y": -44,
"difficulty": 3
},
{
"tick": 1710,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -56,
"difficulty": 3
},
{
"tick": 1900,
"enemy": "iron_coast_stage2_boss_1:unit",
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_mission_1"
name = "Iron Coast II — Mission 1: Nightbreak Inlet"
version = "1.0.0"
version = "1.0.1"
api_version = 1
content = "content.json"
scripts = ["scripts/mission.rhai"]
@@ -1,3 +1,4 @@
// Pacing1.0.1: warning precedes the unchanged real boss spawn by180ticks.
// Only actual shared-enemy and boss death callbacks advance kill goals.
fn on_mission_start(event,state,random) {
#{commands:[#{op:"objective",key:"iron_coast_stage2_mission_1:phase",value:1},#{op:"objective",key:"iron_coast_stage2_mission_1:complete",value:0},#{op:"objective",key:"iron_coast_stage2_mission_1:debrief",value:0},#{op:"objective",key:"iron_coast_stage2_siege:defeated",value:0},#{op:"objective",key:"iron_coast_stage2_boss_1:defeated",value:0},#{op:"play_sound",sound:"iron_coast_stage2_mission_1:approach"}],state:#{complete:false,boss_alarm:false,reported:0}}
@@ -15,7 +16,7 @@ fn on_tick(event,state,random) {
}
let kills=if event.objectives.contains("iron_coast_stage2_siege:defeated") {event.objectives["iron_coast_stage2_siege:defeated"]} else {0};
if kills>state.reported {state.reported=kills;commands.push(#{op:"play_sound",sound:"iron_coast_stage2_mission_1:objective"});}
if event.tick>=1900 && !state.boss_alarm {state.boss_alarm=true;commands.push(#{op:"objective",key:"iron_coast_stage2_mission_1:phase",value:2});commands.push(#{op:"play_sound",sound:"iron_coast_stage2_mission_1:boss_alarm"});}
if event.tick>=1720 && !state.boss_alarm {state.boss_alarm=true;commands.push(#{op:"objective",key:"iron_coast_stage2_mission_1:phase",value:2});commands.push(#{op:"play_sound",sound:"iron_coast_stage2_mission_1:boss_alarm"});}
let defeated=if event.objectives.contains("iron_coast_stage2_boss_1:defeated") {event.objectives["iron_coast_stage2_boss_1:defeated"]} else {0};
if kills>=3 && defeated>=1 && !state.complete {
state.complete=true;
+6 -43
View File
@@ -1,50 +1,13 @@
# Iron Coast II — Mission 2: Undertow Run
An independently playable Black Tide sortie with native Raptor aircraft paths,
new patrols and its own boss. Enable **Undertow Run** in **Mods**; installed compatible
required enemy/boss/music dependencies are selected automatically. Select
`iron_coast_stage2_mission_2:mission_02` in the hangar. Installing the optional full Black Tide
campaign places this sortie in its eight-mission progression.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_stage2_boss_2`, `iron_coast_stage2_music`, `iron_coast_stage2_interceptor`, `iron_coast_stage2_skimmer`.
Destroy 4 actual `skimmer` units and then the `iron_coast_stage2_boss_2:unit` boss. Required
convoys are held inside the reachable playfield after entry; leaving or despawning
never counts as destruction. The boss arrives at tick 2280. A duration
timeout cannot grant victory. Completion awards 32,000 credits once, in addition
to actual enemy rewards. Replaying resets this sortie's kill counters and phases.
Mission `iron_coast_stage2_mission_2:mission_02`: **Undertow Run**. Destroy four required skimmers and the boss. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
Hold **Ctrl/Fire** to use owned weapons; **Alt/Shift** cycles selected weapons;
**Space** retains bombs. Starter forward guns can target all mandatory enemies;
optional purchased gear improves firepower. The mission grants no equipment or
shield/credit cheats, and works with a full 20-slot inventory.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **31 / 44 / 60 / 76**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
New terrain is generated from original Raptor material/palette references, with a
320×512 logical map and a reflected scroll cycle repeated through 4,096 rows.
This preserves terrain beneath delayed boss fights. All mandatory targets appear
on every difficulty; higher difficulties add patrol reinforcements. The briefing crops the same
master. Both SD and 4× HD terrain and generated WAV mission cues are tracked.
`build_assets.py` reproduces all derived art/audio from the untouched raw master.
See [generation provenance](generation-provenance.json) and
[asset recipe/report](asset-report.json). Original reference images and working
review captures stay outside the package.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
```sh
python3 mods/iron_coast_stage2_mission_2/build_assets.py
CARGO_TARGET_DIR="$PWD/target" cargo run --locked --release --manifest-path mods/iron_coast_stage2_boss_1/validation/Cargo.toml
```
## Validation and history
The CLI requires all dependency IDs explicitly; the in-game manager resolves them.
Final real-catalog gameplay, audio, native path and save/RNG observations are
recorded in `acceptance-report.json`. Local single-sortie campaign definition
uses no overrides, so the optional aggregate campaign controls progression.
Author acceptance passed seven complete cases: difficulties 0–3 with starter
guns, default 100-shield, a full 20-slot inventory, and owned optional new gear.
The registered 75-shield pilot completes at tick 2681 with 61 shield and
a 52,260-credit gain using ordinary Fire and a mouse movement cap of
four logical pixels per tick. Serialized script state/RNG continuation, replay
reset, reward-once and actual native path motion checks passed. A separate
health-buffer fixture verifies reachable targets and terrain after 5,000 ticks;
it is not a balance result. The purchased-gear fixture funds shop purchases
before gameplay, rather than claiming a new pilot can afford both weapons.
Asset recipes use Python 3 with Pillow. The game uses the shipped PNG and WAV files.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
@@ -26,6 +26,41 @@
"y": -32,
"difficulty": 0
},
{
"tick": 180,
"enemy": "base:enemy_0_79",
"x": 48,
"y": -32,
"difficulty": 0
},
{
"tick": 180,
"enemy": "base:enemy_0_74",
"x": 224,
"y": -44,
"difficulty": 0
},
{
"tick": 226,
"enemy": "base:enemy_0_73",
"x": 32,
"y": -32,
"difficulty": 2
},
{
"tick": 226,
"enemy": "base:enemy_0_79",
"x": 80,
"y": -44,
"difficulty": 2
},
{
"tick": 226,
"enemy": "base:enemy_0_74",
"x": 128,
"y": -56,
"difficulty": 2
},
{
"tick": 330,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -47,6 +82,69 @@
"y": -32,
"difficulty": 0
},
{
"tick": 430,
"enemy": "base:enemy_0_73",
"x": 240,
"y": -32,
"difficulty": 0
},
{
"tick": 430,
"enemy": "base:enemy_0_79",
"x": 64,
"y": -44,
"difficulty": 0
},
{
"tick": 430,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -56,
"difficulty": 0
},
{
"tick": 466,
"enemy": "base:enemy_0_73",
"x": 32,
"y": -32,
"difficulty": 1
},
{
"tick": 466,
"enemy": "base:enemy_0_79",
"x": 80,
"y": -44,
"difficulty": 1
},
{
"tick": 490,
"enemy": "base:enemy_0_74",
"x": 48,
"y": -32,
"difficulty": 3
},
{
"tick": 490,
"enemy": "base:enemy_0_73",
"x": 224,
"y": -44,
"difficulty": 3
},
{
"tick": 490,
"enemy": "base:enemy_0_79",
"x": 112,
"y": -56,
"difficulty": 3
},
{
"tick": 490,
"enemy": "base:enemy_0_74",
"x": 176,
"y": -68,
"difficulty": 3
},
{
"tick": 540,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -61,6 +159,41 @@
"y": -32,
"difficulty": 0
},
{
"tick": 650,
"enemy": "base:enemy_0_73",
"x": 32,
"y": -32,
"difficulty": 0
},
{
"tick": 650,
"enemy": "base:enemy_0_79",
"x": 80,
"y": -44,
"difficulty": 0
},
{
"tick": 696,
"enemy": "base:enemy_0_74",
"x": 48,
"y": -32,
"difficulty": 2
},
{
"tick": 696,
"enemy": "base:enemy_0_73",
"x": 224,
"y": -44,
"difficulty": 2
},
{
"tick": 696,
"enemy": "base:enemy_0_79",
"x": 112,
"y": -56,
"difficulty": 2
},
{
"tick": 750,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -68,6 +201,69 @@
"y": -32,
"difficulty": 1
},
{
"tick": 760,
"enemy": "base:enemy_0_74",
"x": 232,
"y": -32,
"difficulty": 0
},
{
"tick": 760,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -44,
"difficulty": 0
},
{
"tick": 760,
"enemy": "base:enemy_0_79",
"x": 136,
"y": -56,
"difficulty": 0
},
{
"tick": 796,
"enemy": "base:enemy_0_74",
"x": 48,
"y": -32,
"difficulty": 1
},
{
"tick": 796,
"enemy": "base:enemy_0_73",
"x": 224,
"y": -44,
"difficulty": 1
},
{
"tick": 820,
"enemy": "base:enemy_0_79",
"x": 32,
"y": -32,
"difficulty": 3
},
{
"tick": 820,
"enemy": "base:enemy_0_74",
"x": 80,
"y": -44,
"difficulty": 3
},
{
"tick": 820,
"enemy": "base:enemy_0_73",
"x": 128,
"y": -56,
"difficulty": 3
},
{
"tick": 820,
"enemy": "base:enemy_0_79",
"x": 176,
"y": -68,
"difficulty": 3
},
{
"tick": 830,
"enemy": "base:enemy_0_20",
@@ -89,6 +285,20 @@
"y": -32,
"difficulty": 1
},
{
"tick": 1040,
"enemy": "base:enemy_0_74",
"x": 48,
"y": -32,
"difficulty": 0
},
{
"tick": 1040,
"enemy": "base:enemy_0_73",
"x": 224,
"y": -44,
"difficulty": 0
},
{
"tick": 1080,
"enemy": "base:enemy_0_23",
@@ -96,6 +306,27 @@
"y": -32,
"difficulty": 1
},
{
"tick": 1086,
"enemy": "base:enemy_0_79",
"x": 32,
"y": -32,
"difficulty": 2
},
{
"tick": 1086,
"enemy": "base:enemy_0_74",
"x": 80,
"y": -44,
"difficulty": 2
},
{
"tick": 1086,
"enemy": "base:enemy_0_73",
"x": 128,
"y": -56,
"difficulty": 2
},
{
"tick": 1170,
"enemy": "iron_coast_stage2_interceptor:unit",
@@ -103,6 +334,69 @@
"y": -32,
"difficulty": 2
},
{
"tick": 1240,
"enemy": "base:enemy_0_79",
"x": 240,
"y": -32,
"difficulty": 0
},
{
"tick": 1240,
"enemy": "base:enemy_0_74",
"x": 64,
"y": -44,
"difficulty": 0
},
{
"tick": 1240,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -56,
"difficulty": 0
},
{
"tick": 1276,
"enemy": "base:enemy_0_79",
"x": 32,
"y": -32,
"difficulty": 1
},
{
"tick": 1276,
"enemy": "base:enemy_0_74",
"x": 80,
"y": -44,
"difficulty": 1
},
{
"tick": 1300,
"enemy": "base:enemy_0_73",
"x": 48,
"y": -32,
"difficulty": 3
},
{
"tick": 1300,
"enemy": "base:enemy_0_79",
"x": 224,
"y": -44,
"difficulty": 3
},
{
"tick": 1300,
"enemy": "base:enemy_0_74",
"x": 112,
"y": -56,
"difficulty": 3
},
{
"tick": 1300,
"enemy": "base:enemy_0_73",
"x": 176,
"y": -68,
"difficulty": 3
},
{
"tick": 1330,
"enemy": "base:enemy_0_56",
@@ -138,6 +432,41 @@
"y": -32,
"difficulty": 2
},
{
"tick": 1650,
"enemy": "base:enemy_0_79",
"x": 32,
"y": -32,
"difficulty": 0
},
{
"tick": 1650,
"enemy": "base:enemy_0_74",
"x": 80,
"y": -44,
"difficulty": 0
},
{
"tick": 1696,
"enemy": "base:enemy_0_73",
"x": 48,
"y": -32,
"difficulty": 2
},
{
"tick": 1696,
"enemy": "base:enemy_0_79",
"x": 224,
"y": -44,
"difficulty": 2
},
{
"tick": 1696,
"enemy": "base:enemy_0_74",
"x": 112,
"y": -56,
"difficulty": 2
},
{
"tick": 1940,
"enemy": "iron_coast_stage2_skimmer:unit",
@@ -145,6 +474,69 @@
"y": -32,
"difficulty": 0
},
{
"tick": 2010,
"enemy": "base:enemy_0_73",
"x": 232,
"y": -32,
"difficulty": 0
},
{
"tick": 2010,
"enemy": "base:enemy_0_79",
"x": 184,
"y": -44,
"difficulty": 0
},
{
"tick": 2010,
"enemy": "base:enemy_0_74",
"x": 136,
"y": -56,
"difficulty": 0
},
{
"tick": 2046,
"enemy": "base:enemy_0_73",
"x": 48,
"y": -32,
"difficulty": 1
},
{
"tick": 2046,
"enemy": "base:enemy_0_79",
"x": 224,
"y": -44,
"difficulty": 1
},
{
"tick": 2070,
"enemy": "base:enemy_0_74",
"x": 32,
"y": -32,
"difficulty": 3
},
{
"tick": 2070,
"enemy": "base:enemy_0_73",
"x": 80,
"y": -44,
"difficulty": 3
},
{
"tick": 2070,
"enemy": "base:enemy_0_79",
"x": 128,
"y": -56,
"difficulty": 3
},
{
"tick": 2070,
"enemy": "base:enemy_0_74",
"x": 176,
"y": -68,
"difficulty": 3
},
{
"tick": 2280,
"enemy": "iron_coast_stage2_boss_2:unit",
+1 -1
View File
@@ -1,6 +1,6 @@
id = "iron_coast_stage2_mission_2"
name = "Iron Coast II — Mission 2: Undertow Run"
version = "1.0.0"
version = "1.0.1"
api_version = 1
content = "content.json"
scripts = ["scripts/mission.rhai"]
@@ -1,3 +1,4 @@
// Pacing1.0.1: warning precedes the unchanged real boss spawn by180ticks.
// Only actual shared-enemy and boss death callbacks advance kill goals.
fn on_mission_start(event,state,random) {
#{commands:[#{op:"objective",key:"iron_coast_stage2_mission_2:phase",value:1},#{op:"objective",key:"iron_coast_stage2_mission_2:complete",value:0},#{op:"objective",key:"iron_coast_stage2_mission_2:debrief",value:0},#{op:"objective",key:"iron_coast_stage2_skimmer:defeated",value:0},#{op:"objective",key:"iron_coast_stage2_boss_2:defeated",value:0},#{op:"play_sound",sound:"iron_coast_stage2_mission_2:approach"}],state:#{complete:false,boss_alarm:false,reported:0}}
@@ -15,7 +16,7 @@ fn on_tick(event,state,random) {
}
let kills=if event.objectives.contains("iron_coast_stage2_skimmer:defeated") {event.objectives["iron_coast_stage2_skimmer:defeated"]} else {0};
if kills>state.reported {state.reported=kills;commands.push(#{op:"play_sound",sound:"iron_coast_stage2_mission_2:objective"});}
if event.tick>=2280 && !state.boss_alarm {state.boss_alarm=true;commands.push(#{op:"objective",key:"iron_coast_stage2_mission_2:phase",value:2});commands.push(#{op:"play_sound",sound:"iron_coast_stage2_mission_2:boss_alarm"});}
if event.tick>=2100 && !state.boss_alarm {state.boss_alarm=true;commands.push(#{op:"objective",key:"iron_coast_stage2_mission_2:phase",value:2});commands.push(#{op:"play_sound",sound:"iron_coast_stage2_mission_2:boss_alarm"});}
let defeated=if event.objectives.contains("iron_coast_stage2_boss_2:defeated") {event.objectives["iron_coast_stage2_boss_2:defeated"]} else {0};
if kills>=4 && defeated>=1 && !state.complete {
state.complete=true;
+6 -6
View File
@@ -1,13 +1,13 @@
# Iron Coast II — Mission 3: Stormglass Refinery
Enable **Iron Coast II — Mission 3: Stormglass Refinery** in Mods at the main menu. Its own boss, shared interceptor, shared siege and Stage2 music are required; the dependency check selects them when installed. This single-mission campaign also works alongside the eight-mission Black Tide campaign pack. It has no explicit next mission, overrides, gear grant or equipment loan.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. The normal dependency selection also enables `iron_coast_stage2_boss_3`, `iron_coast_stage2_interceptor`, `iron_coast_stage2_siege`, `iron_coast_stage2_music`.
Storm-season fuelworks combine fuel-battery objectives, classic divebombers and tanks, Needlewing interceptors, Bastion siege units and the Stormglass refinery crawler. Destroy all **four fuel batteries** and the boss. Required defenses stop at y60 and the boss patrols at y24 until destroyed; elapsed time and escaping ordinary enemies never count as kills or finish the mission. Cosmetic storm flashes cause no damage.
Mission `iron_coast_stage2_mission_3:mission_03`: **Iron Coast II 3 — Stormglass Refinery**. Destroy all four fuel batteries and the boss. Required ground objectives remain reachable; escaping optional aircraft never counts as a kill. Elapsed time alone does not finish the mission.
Use ordinary game controls: Fire shoots equipped guns, Space drops owned bombs, Alt or Shift cycles selected weapons. Starter guns can destroy every required target. New campaign equipment is optional and can be purchased separately; this mission never modifies owned equipment or consumes inventory slots. Each objective grants 450 credits and restores12 shield. The boss pays 2400 credits, and a single completion bonus pays 14500. The boss enters at tick2100; expected complete sorties run roughly90–140seconds at70/3Hz.
Scheduled spawn totals for Training / Rookie / Veteran / Elite are **42 / 50 / 62 / 78**. These are enabled scheduled entries, not simultaneous live-enemy counts. Groups can overlap when earlier enemies survive.
Each authored32×32 target has SD and8×HD art. The opaque industrial/river panorama is quantized as one joined image before splitting into288SD/HD terrain tiles; its forward/reflected32-row period repeats eight times across256rows, covering8192scroll pixels. The briefing is a wide crop from the same genuine terrain master. Originals, tracked prompts, exact builtin provenance, `asset-report.json`, `audio-recipe.json` and `build_assets.py` accompany the final assets. The recipe defaults to the bundled `RustRaptor/data/misc/PALETTE_DAT`; decoded original style references remain outside the package. Generated mono16-bit22050Hz WAVs use original seeded synthesis.
Align the forward guns with required targets while moving around incoming shots. If carried, select Field Repair or coolant with Cycle and use Special: they restore 25 or 30 energy respectively and consume a real item. Shop purchases and refills still cost credits.
From `RustRaptor/`, validate the complete dependency set with `target/release/raptor-rust --data data --mods mods --validate-mods iron_coast_stage2_mission_3,iron_coast_stage2_boss_3,iron_coast_stage2_interceptor,iron_coast_stage2_siege,iron_coast_stage2_music`. Run the package-local original-catalog probe with `cargo run --manifest-path mods/iron_coast_stage2_mission_3/validation/Cargo.toml --target-dir target/mod-validation --bin validate --release -- . 3`. It checks actual starter/full-inventory combat, genuine native movement, saved script-state/RNG continuation,4500-tick delayed objectives, replay reset, completion gating and every authored cue through the native PCM mixer. Local render evidence uses actual Rust asset conversion/indexed Canvas from World states; full desktop Host review is separate.
## Validation and history
Retired death guards are bounded to the most recent128 entity IDs, including deterministic migration of older checkpoints. Spawn resets a reused ID before any alive check; duplicate deaths for the current lifecycle emit no commands. The `retained-state` validation binary exercises1,205 actual-catalog/native lethal-collision lives, including1,200 distinct IDs, five reused-ID lives, JSON state/RNG restoration, a960-guard old checkpoint, duplicate callbacks, exact rewards and difficulty0–3. Run it from `RustRaptor/` with `cargo run --manifest-path mods/iron_coast_stage2_mission_3/validation/Cargo.toml --target-dir target/mod-validation --bin retained-state --release -- mods/iron_coast_stage2_mission_3`. The corresponding `validation/retained-state.json` records actual observations; package pins there identify the activation before generated proof files were finalized. The final external freeze ledger binds the complete resulting package.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+400 -8
View File
@@ -2386,6 +2386,41 @@
"y": -80,
"difficulty": 0
},
{
"tick": 100,
"enemy": "base:enemy_0_73",
"x": 240,
"y": -32,
"difficulty": 0
},
{
"tick": 100,
"enemy": "base:enemy_0_74",
"x": 64,
"y": -44,
"difficulty": 0
},
{
"tick": 146,
"enemy": "base:enemy_0_79",
"x": 232,
"y": -32,
"difficulty": 2
},
{
"tick": 146,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -44,
"difficulty": 2
},
{
"tick": 146,
"enemy": "base:enemy_0_74",
"x": 136,
"y": -56,
"difficulty": 2
},
{
"tick": 180,
"enemy": "iron_coast_stage2_mission_3:target",
@@ -2407,6 +2442,27 @@
"y": -40,
"difficulty": 0
},
{
"tick": 370,
"enemy": "base:enemy_0_74",
"x": 32,
"y": -32,
"difficulty": 0
},
{
"tick": 370,
"enemy": "base:enemy_0_79",
"x": 80,
"y": -44,
"difficulty": 0
},
{
"tick": 370,
"enemy": "base:enemy_0_73",
"x": 128,
"y": -56,
"difficulty": 0
},
{
"tick": 390,
"enemy": "iron_coast_stage2_siege:unit",
@@ -2414,6 +2470,48 @@
"y": -40,
"difficulty": 0
},
{
"tick": 406,
"enemy": "base:enemy_0_79",
"x": 232,
"y": -32,
"difficulty": 1
},
{
"tick": 406,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -44,
"difficulty": 1
},
{
"tick": 430,
"enemy": "base:enemy_0_74",
"x": 240,
"y": -32,
"difficulty": 3
},
{
"tick": 430,
"enemy": "base:enemy_0_79",
"x": 64,
"y": -44,
"difficulty": 3
},
{
"tick": 430,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -56,
"difficulty": 3
},
{
"tick": 430,
"enemy": "base:enemy_0_74",
"x": 120,
"y": -68,
"difficulty": 3
},
{
"tick": 470,
"enemy": "base:enemy_0_20",
@@ -2421,6 +2519,41 @@
"y": -88,
"difficulty": 0
},
{
"tick": 560,
"enemy": "base:enemy_0_79",
"x": 232,
"y": -32,
"difficulty": 0
},
{
"tick": 560,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -44,
"difficulty": 0
},
{
"tick": 606,
"enemy": "base:enemy_0_74",
"x": 240,
"y": -32,
"difficulty": 2
},
{
"tick": 606,
"enemy": "base:enemy_0_79",
"x": 64,
"y": -44,
"difficulty": 2
},
{
"tick": 606,
"enemy": "base:enemy_0_73",
"x": 184,
"y": -56,
"difficulty": 2
},
{
"tick": 620,
"enemy": "iron_coast_stage2_mission_3:target",
@@ -2442,6 +2575,69 @@
"y": -32,
"difficulty": 0
},
{
"tick": 850,
"enemy": "base:enemy_0_73",
"x": 48,
"y": -32,
"difficulty": 0
},
{
"tick": 850,
"enemy": "base:enemy_0_74",
"x": 224,
"y": -44,
"difficulty": 0
},
{
"tick": 850,
"enemy": "base:enemy_0_79",
"x": 112,
"y": -56,
"difficulty": 0
},
{
"tick": 886,
"enemy": "base:enemy_0_74",
"x": 240,
"y": -32,
"difficulty": 1
},
{
"tick": 886,
"enemy": "base:enemy_0_79",
"x": 64,
"y": -44,
"difficulty": 1
},
{
"tick": 910,
"enemy": "base:enemy_0_73",
"x": 232,
"y": -32,
"difficulty": 3
},
{
"tick": 910,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -44,
"difficulty": 3
},
{
"tick": 910,
"enemy": "base:enemy_0_79",
"x": 136,
"y": -56,
"difficulty": 3
},
{
"tick": 910,
"enemy": "base:enemy_0_73",
"x": 88,
"y": -68,
"difficulty": 3
},
{
"tick": 970,
"enemy": "iron_coast_stage2_siege:unit",
@@ -2449,6 +2645,20 @@
"y": -40,
"difficulty": 0
},
{
"tick": 1010,
"enemy": "base:enemy_0_74",
"x": 240,
"y": -32,
"difficulty": 0
},
{
"tick": 1010,
"enemy": "base:enemy_0_79",
"x": 64,
"y": -44,
"difficulty": 0
},
{
"tick": 1030,
"enemy": "base:enemy_0_74",
@@ -2456,6 +2666,27 @@
"y": -88,
"difficulty": 0
},
{
"tick": 1056,
"enemy": "base:enemy_0_73",
"x": 232,
"y": -32,
"difficulty": 2
},
{
"tick": 1056,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -44,
"difficulty": 2
},
{
"tick": 1056,
"enemy": "base:enemy_0_79",
"x": 136,
"y": -56,
"difficulty": 2
},
{
"tick": 1100,
"enemy": "iron_coast_stage2_mission_3:target",
@@ -2477,6 +2708,69 @@
"y": -40,
"difficulty": 0
},
{
"tick": 1310,
"enemy": "base:enemy_0_79",
"x": 32,
"y": -32,
"difficulty": 0
},
{
"tick": 1310,
"enemy": "base:enemy_0_73",
"x": 80,
"y": -44,
"difficulty": 0
},
{
"tick": 1310,
"enemy": "base:enemy_0_74",
"x": 128,
"y": -56,
"difficulty": 0
},
{
"tick": 1346,
"enemy": "base:enemy_0_73",
"x": 232,
"y": -32,
"difficulty": 1
},
{
"tick": 1346,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -44,
"difficulty": 1
},
{
"tick": 1370,
"enemy": "base:enemy_0_79",
"x": 240,
"y": -32,
"difficulty": 3
},
{
"tick": 1370,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -44,
"difficulty": 3
},
{
"tick": 1370,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -56,
"difficulty": 3
},
{
"tick": 1370,
"enemy": "base:enemy_0_79",
"x": 120,
"y": -68,
"difficulty": 3
},
{
"tick": 1460,
"enemy": "iron_coast_stage2_siege:unit",
@@ -2484,6 +2778,20 @@
"y": -40,
"difficulty": 0
},
{
"tick": 1510,
"enemy": "base:enemy_0_73",
"x": 232,
"y": -32,
"difficulty": 0
},
{
"tick": 1510,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -44,
"difficulty": 0
},
{
"tick": 1520,
"enemy": "base:enemy_0_21",
@@ -2491,6 +2799,27 @@
"y": -40,
"difficulty": 0
},
{
"tick": 1556,
"enemy": "base:enemy_0_79",
"x": 240,
"y": -32,
"difficulty": 2
},
{
"tick": 1556,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -44,
"difficulty": 2
},
{
"tick": 1556,
"enemy": "base:enemy_0_74",
"x": 184,
"y": -56,
"difficulty": 2
},
{
"tick": 1630,
"enemy": "iron_coast_stage2_mission_3:target",
@@ -2505,6 +2834,34 @@
"y": -40,
"difficulty": 0
},
{
"tick": 1780,
"enemy": "iron_coast_stage2_interceptor:unit",
"x": 224,
"y": -40,
"difficulty": 0
},
{
"tick": 1830,
"enemy": "base:enemy_0_74",
"x": 48,
"y": -32,
"difficulty": 0
},
{
"tick": 1830,
"enemy": "base:enemy_0_79",
"x": 224,
"y": -44,
"difficulty": 0
},
{
"tick": 1830,
"enemy": "base:enemy_0_73",
"x": 112,
"y": -56,
"difficulty": 0
},
{
"tick": 1840,
"enemy": "base:enemy_0_22",
@@ -2513,25 +2870,60 @@
"difficulty": 0
},
{
"tick": 2000,
"tick": 1866,
"enemy": "base:enemy_0_79",
"x": 240,
"y": -32,
"difficulty": 1
},
{
"tick": 1866,
"enemy": "base:enemy_0_73",
"x": 64,
"y": -44,
"difficulty": 1
},
{
"tick": 1880,
"enemy": "iron_coast_stage2_siege:unit",
"x": 80,
"y": -40,
"difficulty": 0
},
{
"tick": 1890,
"enemy": "base:enemy_0_74",
"x": 232,
"y": -32,
"difficulty": 3
},
{
"tick": 1890,
"enemy": "base:enemy_0_79",
"x": 184,
"y": -44,
"difficulty": 3
},
{
"tick": 1890,
"enemy": "base:enemy_0_73",
"x": 136,
"y": -56,
"difficulty": 3
},
{
"tick": 1890,
"enemy": "base:enemy_0_74",
"x": 88,
"y": -68,
"difficulty": 3
},
{
"tick": 2100,
"enemy": "iron_coast_stage2_boss_3:unit",
"x": 108,
"y": -72,
"difficulty": 0
},
{
"tick": 2250,
"enemy": "iron_coast_stage2_interceptor:unit",
"x": 224,
"y": -40,
"difficulty": 0
}
],
"art": {
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more