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
+9242 -2951

No files matched your search

+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.
+63 -46
View File
@@ -1,50 +1,67 @@
{
"weapons": [{
"id": "iron_coast_boss_4:projectile",
"name": "Stormwall Arsenal battery 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
"weapons": [
{
"id": "iron_coast_boss_4:projectile",
"name": "Stormwall Arsenal battery 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_4:unit",
"name": "Stormwall Arsenal",
"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
],
"enemies": [
{
"id": "iron_coast_boss_4:unit",
"name": "Stormwall Arsenal",
"health": 360,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 90,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 96,
"logical_height": 64
},
"script": "scripts/ai.rhai"
}
],
"sounds": [
{
"id": "iron_coast_boss_4:warning",
"name": "Stormwall battery arming warning",
"file": "audio/warning.wav",
"volume": 80,
"pan": 127,
"pitch": 128,
"priority": 2
},
"script": "scripts/ai.rhai"
}],
"sounds": [{
"id": "iron_coast_boss_4:warning",
"name": "Stormwall battery arming warning",
"file": "audio/warning.wav",
"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
}, {
"id": "iron_coast_boss_4:death",
"name": "Stormwall Arsenal destruction",
"file": "audio/death.wav",
"volume": 120, "pan": 127, "pitch": 128, "priority": 4
}]
{
"id": "iron_coast_boss_4:shot",
"name": "Stormwall battery discharge",
"file": "audio/shot.wav",
"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
}
]
}
+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"]
+119 -52
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 };
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], 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 }; }
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 {
return #{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state };
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};
}
unit.age += 1;
state.units[key] = unit;
#{ commands: commands, state: state }
}
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});
fn on_death(event, state, random) {
let key = event.entity.to_string();
if !state.contains("dead") { state.dead = #{}; }
if state.dead.contains(key) { return #{ commands: [], state: state }; }
state.dead[key] = true;
if state.contains("units") { state.units.remove(key); }
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 }
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+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}
}