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

+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.
+64 -14
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 },
"script": "scripts/ai.rhai"
}],
"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"]
+116 -57
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}; }
#{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}; }
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});
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};
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};
}
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};
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"});
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});
fn on_hit(event, state, random) {
state.hits = if state.contains("hits") {state.hits + 1} else {1};
#{commands: [], state: state}
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_death(event, state, random) {
let key = event.entity.to_string();
if !state.contains("dead") { state.dead = #{}; }
if state.dead.contains(key) { return #{commands: [], state: state}; }
state.dead[key] = true;
if state.contains("units") { state.units.remove(key); }
state.destroyed = if state.contains("destroyed") {state.destroyed + 1} else {1};
let defeated = if event.objectives.contains("iron_coast_boss_3:defeated") {event.objectives["iron_coast_boss_3:defeated"] + 1} else {1};
#{commands: [
#{op: "objective", key: "iron_coast_boss_3:defeated", value: defeated},
#{op: "effect", x: event.x + 35, y: event.y + 48},
#{op: "effect", x: event.x + 61, y: event.y + 48},
#{op: "play_sound", sound: "iron_coast_boss_3:death"}
], state: state}
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+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}
}