diff --git a/ChapterMaster.yyp b/ChapterMaster.yyp
index df4dcf6035..140f06810b 100644
--- a/ChapterMaster.yyp
+++ b/ChapterMaster.yyp
@@ -511,6 +511,10 @@
{"$GMIncludedFile":"","%Name":"purge8.png","CopyToMask":-1,"filePath":"datafiles/images/ui","name":"purge8.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
{"$GMIncludedFile":"","%Name":"raid1.png","CopyToMask":-1,"filePath":"datafiles/images/ui","name":"raid1.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
{"$GMIncludedFile":"","%Name":"resources.png","CopyToMask":-1,"filePath":"datafiles/images/ui","name":"resources.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
+ {"$GMIncludedFile":"","%Name":"chimera.png","CopyToMask":-1,"filePath":"datafiles/images/units","name":"chimera.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
+ {"$GMIncludedFile":"","%Name":"Guardsman Test.png","CopyToMask":-1,"filePath":"datafiles/images/units","name":"Guardsman Test.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
+ {"$GMIncludedFile":"","%Name":"Guardsman.png","CopyToMask":-1,"filePath":"datafiles/images/units","name":"Guardsman.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
+ {"$GMIncludedFile":"","%Name":"sarge.png","CopyToMask":-1,"filePath":"datafiles/images/units","name":"sarge.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
{"$GMIncludedFile":"","%Name":"chapter_advantages.json","CopyToMask":-1,"filePath":"datafiles/main","name":"chapter_advantages.json","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
{"$GMIncludedFile":"","%Name":"chapter_disadvantages.json","CopyToMask":-1,"filePath":"datafiles/main","name":"chapter_disadvantages.json","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
{"$GMIncludedFile":"","%Name":"pauldron.png","CopyToMask":-1,"filePath":"datafiles/main/chapter_symbols/darkangels","name":"pauldron.png","resourceType":"GMIncludedFile","resourceVersion":"2.0",},
diff --git a/datafiles/data/armour.json b/datafiles/data/armour.json
index f56706ca38..ccbf88eb04 100644
--- a/datafiles/data/armour.json
+++ b/datafiles/data/armour.json
@@ -172,6 +172,37 @@
},
"description": "This armour is used by T'au fire warriors."
},
+ "Flak Armour": {
+ "abbreviation": "FlkArm",
+ "armour_value": {
+ "artifact": 12,
+ "master_crafted": 11,
+ "standard": 10
+ },
+ "damage_resistance_mod": {
+ "artifact": 0,
+ "master_crafted": 0,
+ "standard": 0
+ },
+ "description": "A simple flak jacket of ablative plates and ballistic weave, the standard protection of the Astra Militarum rank and file. It turns aside shrapnel and glancing hits but offers little against a determined blow.",
+ "hp_mod": {
+ "artifact": 0,
+ "master_crafted": 0,
+ "standard": 0
+ },
+ "melee_mod": {
+ "artifact": 0,
+ "master_crafted": 0,
+ "standard": 0
+ },
+ "ranged_mod": {
+ "artifact": 10,
+ "master_crafted": 8,
+ "standard": 5
+ },
+ "renegade_buy": true,
+ "value": 2
+ },
"Light Power Armour": {
"abbreviation": "LPwrArm",
"armour_value": {
diff --git a/datafiles/data/unit_stats.json b/datafiles/data/unit_stats.json
index a8ad115cf7..b7d3d04977 100644
--- a/datafiles/data/unit_stats.json
+++ b/datafiles/data/unit_stats.json
@@ -581,6 +581,222 @@
1
]
},
+ "guard_squad": {
+ "ballistic_skill": [
+ 25,
+ 1
+ ],
+ "base_group": "human",
+ "charisma": [
+ 15,
+ 1
+ ],
+ "constitution": [
+ 28,
+ 1,
+ "max"
+ ],
+ "dexterity": [
+ 20,
+ 1
+ ],
+ "intelligence": [
+ 15,
+ 1
+ ],
+ "luck": 5,
+ "piety": [
+ 25,
+ 1
+ ],
+ "religion": "imperial_cult",
+ "start_gear": {
+ "armour": "Flak Armour",
+ "gear": "",
+ "mobi": "",
+ "wep1": "Lasgun",
+ "wep2": "Bayonet"
+ },
+ "strength": [
+ 20,
+ 1
+ ],
+ "technology": [
+ 10,
+ 1
+ ],
+ "title": "Guard Squad",
+ "weapon_skill": [
+ 15,
+ 1
+ ],
+ "wisdom": [
+ 15,
+ 1
+ ]
+ },
+ "guardsman": {
+ "ballistic_skill": [
+ 25,
+ 6
+ ],
+ "base_group": "human",
+ "charisma": [
+ 15,
+ 1
+ ],
+ "constitution": [
+ 28,
+ 1,
+ "max"
+ ],
+ "dexterity": [
+ 20,
+ 1
+ ],
+ "intelligence": [
+ 15,
+ 1
+ ],
+ "luck": 5,
+ "piety": [
+ 25,
+ 1
+ ],
+ "religion": "imperial_cult",
+ "start_gear": {
+ "armour": "Flak Armour",
+ "gear": "",
+ "mobi": "",
+ "wep1": "Lasgun",
+ "wep2": "Bayonet"
+ },
+ "strength": [
+ 20,
+ 1
+ ],
+ "technology": [
+ 10,
+ 1
+ ],
+ "title": "Guardsman",
+ "weapon_skill": [
+ 15,
+ 1
+ ],
+ "wisdom": [
+ 15,
+ 1
+ ]
+ },
+ "heavy_weapons_team": {
+ "ballistic_skill": [
+ 28,
+ 4
+ ],
+ "base_group": "human",
+ "charisma": [
+ 15,
+ 1
+ ],
+ "constitution": [
+ 28,
+ 1,
+ "max"
+ ],
+ "dexterity": [
+ 20,
+ 1
+ ],
+ "intelligence": [
+ 15,
+ 1
+ ],
+ "luck": 5,
+ "piety": [
+ 25,
+ 1
+ ],
+ "religion": "imperial_cult",
+ "start_gear": {
+ "armour": "Flak Armour",
+ "gear": "",
+ "mobi": "",
+ "wep1": "Heavy Bolter",
+ "wep2": ""
+ },
+ "strength": [
+ 22,
+ 1
+ ],
+ "technology": [
+ 10,
+ 1
+ ],
+ "title": "Heavy Weapons Team",
+ "weapon_skill": [
+ 15,
+ 1
+ ],
+ "wisdom": [
+ 15,
+ 1
+ ]
+ },
+ "guard_sergeant": {
+ "ballistic_skill": [
+ 20,
+ 6
+ ],
+ "base_group": "human",
+ "charisma": [
+ 18,
+ 1
+ ],
+ "constitution": [
+ 34,
+ 1,
+ "max"
+ ],
+ "dexterity": [
+ 24,
+ 1
+ ],
+ "intelligence": [
+ 18,
+ 1
+ ],
+ "luck": 6,
+ "piety": [
+ 30,
+ 1
+ ],
+ "religion": "imperial_cult",
+ "start_gear": {
+ "armour": "Flak Armour",
+ "gear": "",
+ "mobi": "",
+ "wep1": "Guard Chainsword",
+ "wep2": "Laspistol"
+ },
+ "strength": [
+ 24,
+ 1
+ ],
+ "technology": [
+ 12,
+ 1
+ ],
+ "title": "Guard Sergeant",
+ "weapon_skill": [
+ 18,
+ 1
+ ],
+ "wisdom": [
+ 18,
+ 1
+ ]
+ },
"skitarii": {
"ballistic_skill": [
20,
diff --git a/datafiles/data/weapons.json b/datafiles/data/weapons.json
index b9b961c34d..1fa5e0cee6 100644
--- a/datafiles/data/weapons.json
+++ b/datafiles/data/weapons.json
@@ -121,6 +121,50 @@
],
"value": 30
},
+ "Earthshaker Cannon": {
+ "abbreviation": "Erthshk",
+ "ammo": 8,
+ "arp": 3,
+ "attack": {
+ "artifact": 1050,
+ "master_crafted": 850,
+ "standard": 700
+ },
+ "description": "The Earthshaker is the thunderous main gun of the Basilisk self-propelled artillery. It lobs enormous high-explosive shells in a high arc onto distant formations, shattering massed infantry and light armour from well beyond the firing line.",
+ "melee_hands": 0,
+ "range": 24,
+ "ranged_hands": 0,
+ "spli": 12,
+ "tags": [
+ "vehicle",
+ "heavy_ranged",
+ "explosive",
+ "turret"
+ ],
+ "value": 45
+ },
+ "Battle Cannon": {
+ "abbreviation": "BtlCnn",
+ "ammo": 12,
+ "arp": 4,
+ "attack": {
+ "artifact": 1000,
+ "master_crafted": 800,
+ "standard": 650
+ },
+ "description": "The main armament of the Leman Russ battle tank. A high-calibre cannon firing massive shells that crack armour and scatter massed infantry alike, the versatile mainstay of Astra Militarum armoured companies.",
+ "melee_hands": 0,
+ "range": 18,
+ "ranged_hands": 0,
+ "spli": 8,
+ "tags": [
+ "vehicle",
+ "heavy_ranged",
+ "explosive",
+ "turret"
+ ],
+ "value": 40
+ },
"Boarding Shield": {
"abbreviation": "BrdShld",
"armour_value": {
@@ -288,6 +332,28 @@
],
"value": 4
},
+ "Guard Chainsword": {
+ "abbreviation": "GdChSw",
+ "ammo": 0,
+ "arp": 1,
+ "attack": {
+ "artifact": 190,
+ "master_crafted": 150,
+ "standard": 120
+ },
+ "description": "A mass-produced chainsword issued to Astra Militarum officers and sergeants. Cruder and less potent than an Astartes blade, but a brutal close-quarters weapon in a guardsman's hands.",
+ "melee_hands": 1,
+ "range": 1,
+ "ranged_hands": 0,
+ "spli": 4,
+ "tags": [
+ "chain",
+ "sword",
+ "savage",
+ "boarding 1"
+ ],
+ "value": 3
+ },
"Choppa": {
"abbreviation": "Chop",
"attack": {
@@ -329,6 +395,25 @@
],
"value": 45
},
+ "Bayonet": {
+ "abbreviation": "Bynt",
+ "ammo": 0,
+ "arp": 1,
+ "attack": {
+ "artifact": 22,
+ "master_crafted": 20,
+ "standard": 18
+ },
+ "description": "A short blade clamped to the muzzle of a lasgun. It gives a Guardsman something to thrust with when the foe closes, but against anything tougher than a grot it is little more than a desperate inconvenience.",
+ "melee_hands": 0,
+ "range": 1,
+ "ranged_hands": 0,
+ "spli": 0,
+ "tags": [
+ "knife"
+ ],
+ "value": 1
+ },
"Combat Knife": {
"abbreviation": "CbKnf",
"ammo": 0,
@@ -1239,7 +1324,8 @@
"ranged_hands": 1,
"spli": 3,
"tags": [
- "las"
+ "las",
+ "veteran_guard_only"
]
},
"Hellrifle": {
@@ -1590,6 +1676,25 @@
],
"value": 15
},
+ "Lasgun": {
+ "abbreviation": "Lasgun",
+ "ammo": 40,
+ "arp": 1,
+ "attack": {
+ "artifact": 130,
+ "master_crafted": 110,
+ "standard": 60
+ },
+ "description": "The standard-issue weapon of the Astra Militarum. A rugged two-handed las weapon firing coherent energy beams, prized for its reliability and the ease of recharging its power packs in the field. Common, dependable, and unremarkable on its own.",
+ "melee_hands": 0,
+ "range": 12,
+ "ranged_hands": 1,
+ "spli": 1,
+ "tags": [
+ "las"
+ ],
+ "value": 2
+ },
"Laspistol": {
"abbreviation": "Lpstl",
"ammo": 30,
@@ -1794,6 +1899,28 @@
],
"value": 70
},
+ "Multi-Laser": {
+ "abbreviation": "MultiLas",
+ "ammo": 30,
+ "arp": 2,
+ "attack": {
+ "artifact": 220,
+ "master_crafted": 180,
+ "standard": 150
+ },
+ "description": "A rapid-firing laser array mounted on light Imperial vehicles such as the Chimera. Pours volley after volley of coherent energy into massed infantry and light targets.",
+ "melee_hands": 0,
+ "range": 10,
+ "ranged_hands": 0,
+ "spli": 6,
+ "tags": [
+ "vehicle",
+ "heavy_ranged",
+ "las",
+ "turret"
+ ],
+ "value": 20
+ },
"Multi-Melta": {
"abbreviation": "MltMelt",
"ammo": 8,
@@ -1858,6 +1985,12 @@
},
"range": 1,
"ranged_hands": 0,
+ "requires_to_forge": [
+ "power_1"
+ ],
+ "sellers": [
+ "mechanicus"
+ ],
"specials": {
"combi_tool": 1
},
@@ -1867,7 +2000,8 @@
"axe",
"savage",
"boarding 3"
- ]
+ ],
+ "value": 80
},
"Phaestos Flamer": {
"abbreviation": "PhFlmr",
diff --git a/datafiles/images/units/Guardsman Test.png b/datafiles/images/units/Guardsman Test.png
new file mode 100644
index 0000000000..59ac96dbf5
Binary files /dev/null and b/datafiles/images/units/Guardsman Test.png differ
diff --git a/datafiles/images/units/Sarge.png b/datafiles/images/units/Sarge.png
new file mode 100644
index 0000000000..20ffb0db55
Binary files /dev/null and b/datafiles/images/units/Sarge.png differ
diff --git a/datafiles/images/units/chimera.png b/datafiles/images/units/chimera.png
new file mode 100644
index 0000000000..c76e7ec15e
Binary files /dev/null and b/datafiles/images/units/chimera.png differ
diff --git a/datafiles/images/units/guardsman.png b/datafiles/images/units/guardsman.png
new file mode 100644
index 0000000000..0b9feef753
Binary files /dev/null and b/datafiles/images/units/guardsman.png differ
diff --git a/datafiles/main/chapters/36.json b/datafiles/main/chapters/36.json
index 8ab38f9e81..234df44b14 100644
--- a/datafiles/main/chapters/36.json
+++ b/datafiles/main/chapters/36.json
@@ -119,11 +119,11 @@
"ossmodula": 0,
"membrane": 0,
"zygote": 0,
- "betchers": 0,
+ "betchers": 1,
"catalepsean": 0,
"secretions": 0,
"occulobe": 0,
- "mucranoid": 1
+ "mucranoid": 0
},
"disposition": [
0, // nothing
diff --git a/docs/README.md b/docs/README.md
index f055a9f683..b0629c01f8 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,40 +2,43 @@
-# Chapter Master - Adeptus Dominus
+**NEW RELEASE HERE** https://github.com/KestasV/Chapter-Master-1.5-Adeptus-Indomitus/releases/tag/Exe1.1
-[](https://github.com/Adeptus-Dominus/ChapterMaster/actions/workflows/create_release.yml)
-[](https://discord.gg/zAGpqHzsXQ)
-[](https://github.com/Adeptus-Dominus/ChapterMaster)
+# Chapter Master 1.5 – Adeptus Indomitus
-**Chapter Master** (aka CM) is a strategy/simulation game, written in **Game Maker Language**, originally designed and developed by Duke.\
-This project aims to continue development on the game, fix any bugs, expand and add features.
+**Chapter Master 1.5 – Adeptus Indomitus** is a major continuation fork of *Chapter Master: Adeptus Dominus*, expanding the game from a pure Space Marine Chapter command simulator into a broader Imperial war effort.
-**Adeptus Dominus** is just how the team of primordial collaborators decided to call themselves and the new version of the game.
+The central addition is a fieldable **Imperial Guard / Astra Militarum Auxilia** layer. The player can bargain with Imperial and Mechanicus authorities to raise Guardsmen, Guard Sergeants, Heavy Weapons Teams, Chimeras, Leman Russ tanks, and Basilisk artillery, then deploy them alongside the Chapter as a supporting Imperial force.
-## Links
+This fork adds new Guard unit profiles, Guard weapons, vehicle weapons, armour, Auxilia organization, and dedicated combat behaviour. Guardsmen are not just renamed Marines: they fight as mass infantry screens, split into capped lasgun volleys, shield more valuable forces behind them, and require armour, tanks, artillery, and Astartes support to survive serious threats.
-Our discord server: [Chapter Master Discord](https://discord.gg/zAGpqHzsXQ)
- - Where most of the development talk, feature suggestion and bug-reporting takes place.
- - GitHub issues are not used at the moment, as such we use #bug-report-forum in this server to handle bug reports.
- - If you can code, draw, design, help with the GitHub stuff or just want to help in any way possible - you'll be welcomed with open arms.
- - If you just love WH40K and want to chat - you'll have a great time in the server as well.
+Ground combat has been heavily reworked. Targeting now better distinguishes anti-infantry and anti-tank fire, armour walls no longer create absurd all-or-nothing shooting behaviour, partial volleys can pierce through vehicle screens, splash damage can carry over, vehicle weapons remain relevant at point-blank range, and dead or empty formations are cleaned up more reliably.
-1d6Chan Wiki: [Chapter Master: Adeptus Dominus](https://1d6chan.miraheze.org/wiki/Chapter_Master:_Adeptus_Dominus)
- - With some helpful pages that explain some of the new systems added in the Adeptus Dominus version.
- - Help with expanding it will be welcomed.
+The campaign layer has also been expanded with Auxilia recruitment through diplomacy, Mechanicus armour support, per-ship assault capacity, local assault counters, lost-in-the-Warp ship events, Eldar incursion and craftworld-intel systems, improved fleet handling, better management UI, richer unit profile tooltips, and numerous bug fixes.
-Main repository: [Adeptus-Dominus/ChapterMaster](https://github.com/Adeptus-Dominus/ChapterMaster)
+This is an unofficial fan fork intended to preserve and expand the spirit of *Chapter Master* while pushing it toward a larger, more dynamic Imperial command experience.
-## Playing
+## The biggest concrete changes
-### Windows, Linux, macOS
+*****_Imperial Guard / Auxilia system_. Added real Guard-side content: Guardsmen, Guard Squads, Heavy Weapons Teams, and Guard Sergeants, with human base stats, Flak Armour, Lasguns, Bayonets, Heavy Bolters, Guard Chainswords, and Laspistols.
-Download the [latest release](https://github.com/Adeptus-Dominus/ChapterMaster/releases/latest) or a [development pre-release](https://github.com/Adeptus-Dominus/ChapterMaster/releases), unzip and launch.
+*****_Diplomatic recruitment and attached armour_. Sector Governor trade now raises Guardsmen directly at the homeworld, adds one Guard Sergeant per full squad, one Chimera per 200 Guardsmen, and one Heavy Weapons Team per 100 Guardsmen; Mechanicus trade can now provide Leman Russ tanks and Basilisks for the Auxilia company.
+
+*****_New Astra Militarum wargear and vehicles_. Added or integrated Flak Armour, Lasguns, Hellguns, Earthshaker Cannons, Battle Cannons, Multi-Lasers, Chimeras, Leman Russ loadouts, and Basilisk artillery loadouts.
+
+*****_Combat model overhaul._ Guard screens occupy forward columns, anti-tank weapons now have a dedicated target-preference threshold, volleys pierce armour walls by depth instead of passing through for free or vanishing, and Guardsmen split into capped lasgun stacks instead of forming one giant unrealistic volley.
+
+*****_Better targeting, armour, and casualty logic_. Enemy weapons now prefer vehicles only when their AP role justifies it, Guard screens can actually shield vehicles behind them, column piercing works on both sides, partial volleys scale damage correctly, stale casualty reports are cleared, and vehicle armour penetration was fixed so enemy anti-tank weapons can actually hurt vehicles.
+
+*****_Fleet, assault, and campaign-layer additions._ Ships now have per-turn ground-assault support counters, planets have local assault counters, new ship defaults include Guard-related capacity fields, and lost-in-the-Warp ships can return with damage, corruption, mutiny, Chaos capture, or destruction outcomes.
+
+*****_Eldar campaign restoration and improvement_. Eldar are no longer only a hidden craftworld oddity: added incursion/intel settings, craftworld reveal requirements, first-contact clues, and Eldar ship speed tuning.
+
+*****_Management UI and quality-of-life_. The management screen now recognizes an Auxilia company, includes richer unit profile UI/tooltips, and has buttons for squad view, profile, bio, and capture image.
## Compiling from source
-1. Install the **GameMaker** IDE matching your system (available on the [GameMaker website](https://releases.gamemaker.io/release-notes/2026/0) or [Steam](https://store.steampowered.com/app/1670460/GameMaker/)).
+1. Install the **GameMaker** IDE matching r system (available on the [GameMaker website](https://releases.gamemaker.io/release-notes/2026/0) or [Steam](https://store.steampowered.com/app/1670460/GameMaker/)).
2. Clone or download the repository (find the green **<>Code** button and select **Download ZIP**).
3. Find **ChapterMaster.yyp** in the downloaded folder and open it with **GameMaker**.
4. Select the target platform in the IDE: **Windows, macOS, Ubuntu**.
@@ -44,6 +47,18 @@ Download the [latest release](https://github.com/Adeptus-Dominus/ChapterMaster/r
## Contributing
+**++**BIG thanks to **Tavish** for the combat changes adapted and worked on from his "LW_Beta_1.2" fork**++**
+
+**++**Another big thanks to Tophat#7692 for testing and helping with the design. Half of the errors, bugs and design ideas wouldn't have been possible without you**++**
+
+**++**And shoutouts to the developers at main for upkeeping this old legacy code so i can work on improvements!**++**
+
+This mod was made with assistance from Claude Fable 5 and Opus 4.8 on code consultation
+
+
+
+
+**To contribute to this fork please contact me on Discord, hopefully we can work together. I take ALL input and suggestions in, even if 're not a coder. If there's somethin wanna personally see in the game then please tell me.** https://discord.com/channels/714022226810372107/1520160266120204409
Best bet is to ask about everything in our Discord, because things bellow are probably not very helpful at the moment.
- [CONTRIBUTING.md](https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/docs/CONTRIBUTING.md) (required read before any contribution)
diff --git a/objects/obj_bomb_select/Alarm_1.gml b/objects/obj_bomb_select/Alarm_1.gml
index 9e092cb908..993b83db76 100644
--- a/objects/obj_bomb_select/Alarm_1.gml
+++ b/objects/obj_bomb_select/Alarm_1.gml
@@ -4,6 +4,7 @@ ship_all = [];
ship_use = [];
ship_max = [];
ship_ide = [];
+ship_spent = [];
var _ships = fleet_full_ship_array(sh_target);
max_ships = array_length(_ships);
@@ -19,6 +20,10 @@ for (var i = 0; i < array_length(_ships); i++) {
array_push(ship, obj_ini.ship[_ships[i]]);
array_push(ship_use, 0);
array_push(ship_all, 0);
+ // A ship that has spent any support use this turn (bombarded, or supported a
+ // ground assault or raid) cannot bombard, since bombardment needs a fully
+ // fresh ship. Such ships are listed but locked in the selection grid.
+ array_push(ship_spent, ship_bombards_used(_ships[i]) > 0);
}
}
diff --git a/objects/obj_bomb_select/Create_0.gml b/objects/obj_bomb_select/Create_0.gml
index 8a1ade408e..866ef9ec90 100644
--- a/objects/obj_bomb_select/Create_0.gml
+++ b/objects/obj_bomb_select/Create_0.gml
@@ -1,6 +1,9 @@
// Resets vars and also checks if target can be bombarded
ship_names = "";
p_target = 0;
+// Lazy initial-target validation flag (see Draw_0): honest presence numbers can
+// leave the default bombard target on a zero-strength faction ("Chaos, Strength: 0").
+target_initialized = 0;
max_ships = 0;
ships_selected = 0;
target = 0;
@@ -20,6 +23,7 @@ for (var i = 0; i < 31; i++) {
ship_use[i] = 0;
ship_max[i] = 0;
ship_ide[i] = -1;
+ ship_spent[i] = 0;
}
menu = 0;
diff --git a/objects/obj_bomb_select/Draw_0.gml b/objects/obj_bomb_select/Draw_0.gml
index 60faedc28b..b6cd77b0b4 100644
--- a/objects/obj_bomb_select/Draw_0.gml
+++ b/objects/obj_bomb_select/Draw_0.gml
@@ -95,6 +95,20 @@ if ((max_ships > 0) && instance_exists(obj_star_select)) {
var str_string = "";
// TODO a centralised point to be able to fetch display names from factions identifying number
+ // First-draw validation: if the default target has no force, advance to the
+ // first faction that does, so the screen never opens on a ghost target the
+ // player must cycle past.
+ if (target_initialized != 1) {
+ target_initialized = 1;
+ if (p_data.planet_forces[target] <= 0) {
+ for (var _t0 = 2; _t0 < array_length(p_data.planet_forces); _t0++) {
+ if (p_data.planet_forces[_t0] > 0) {
+ target = _t0;
+ break;
+ }
+ }
+ }
+ }
str = floor(p_data.planet_forces[target]);
if (target == 2.5) {
str = determine_pdf_defence(p_data.pdf,, p_data.fortification_level)[0];
@@ -139,8 +153,9 @@ if ((max_ships > 0) && instance_exists(obj_star_select)) {
var sel_all_button = draw_unit_buttons([bomb_window.x2 - 55, bomb_window.y1 + 150, bomb_window.x2 - 40, bomb_window.y1 + 165], sel_all_label, [1, 1], #34bc75, fa_center, fnt_40k_14b);
if (point_and_click(sel_all_button)) {
for (var i = 0; i < array_length(ship); i++) {
- // Limit to the first 5 ships with buttons
- if (ship[ship_index] != "" && ship_all[i] == all_sel) {
+ // Limit to the first 5 ships with buttons. Spent ships are skipped so
+ // Select All never toggles a ship that has already used its turn.
+ if (ship[ship_index] != "" && ship_all[i] == all_sel && !ship_spent[i]) {
ship_all[i] = !all_sel;
ships_selected += all_sel ? -1 : 1;
}
@@ -180,8 +195,14 @@ if ((max_ships > 0) && instance_exists(obj_star_select)) {
var buttonX = bomb_window.x1 + 24 + col * buttonSpacingX;
var buttonY = bomb_window.y1 + 172 + row * buttonSpacingY;
- // Draw the unit buttons and handle selection
- if (point_and_click(draw_unit_buttons([buttonX, buttonY, buttonX + 105, buttonY + 20], string_truncate(num, 200), [1, 1], ship_all[ship_index] ? #34bc75 : #bf4040, fa_center, fnt_40k_10, ship_all[ship_index] ? 1 : 0.5))) {
+ // Draw the unit buttons and handle selection. A ship that has already
+ // spent its support this turn shows locked in red and rejects clicks,
+ // matching the attack roster. Bombardment needs a fully fresh ship.
+ var _spent = ship_spent[ship_index];
+ var _btn_col = _spent ? c_red : (ship_all[ship_index] ? #34bc75 : #bf4040);
+ var _btn_alpha = _spent ? 0.5 : (ship_all[ship_index] ? 1 : 0.5);
+ var _ship_clicked = point_and_click(draw_unit_buttons([buttonX, buttonY, buttonX + 105, buttonY + 20], string_truncate(num, 200), [1, 1], _btn_col, fa_center, fnt_40k_10, _btn_alpha));
+ if (_ship_clicked && !_spent) {
ship_all[ship_index] = !ship_all[ship_index];
ships_selected += ship_all[ship_index] ? 1 : -1;
@@ -193,6 +214,35 @@ if ((max_ships > 0) && instance_exists(obj_star_select)) {
}
}
+ // Bombardment effect preview. Estimate what a bombard with the currently selected
+ // ships would do to this world and show it at a glance, so a small-population planet
+ // is not wiped out to shave a point off an entrenched enemy without the player
+ // seeing it coming. Population loss is fixed per world (the kill does not scale with
+ // ship count); enemy loss scales with the selected bombard score.
+ var _est_bomb = 0;
+ for (var i = 0; i < array_length(ship_ide); i++) {
+ if (ship_all[i] == 1) {
+ var _c = player_ships_class(ship_ide[i]);
+ if (_c == "capital") {
+ _est_bomb += 3;
+ } else if (_c == "frigate") {
+ _est_bomb += 1;
+ }
+ }
+ }
+ var _est = bombard_effect_estimate(p_data, target, _est_bomb, str);
+ var _rows_used = max(1, ceil(array_length(ship_ide) / 4));
+ var _est_y = min(bomb_window.y1 + 172 + _rows_used * buttonSpacingY + 16, bomb_window.y2 - 74);
+ draw_set_halign(fa_left);
+ draw_set_font(fnt_info);
+ draw_set_color(#34bc75);
+ draw_text_bold(bomb_window.x1 + 20, _est_y, "If bombarded now:");
+ draw_set_color(bombard_effect_bracket_color(_est.population));
+ draw_text_bold(bomb_window.x1 + 20, _est_y + 18, $"Population loss: {_est.population}");
+ draw_set_color(bombard_effect_bracket_color(_est.enemy));
+ draw_text_bold(bomb_window.x1 + 20, _est_y + 36, $"Enemy losses: {_est.enemy}");
+ draw_set_color(#34bc75);
+
// Confirm and Cancel buttons
var button_alpha = 1;
if (!ships_selected) {
diff --git a/objects/obj_controller/Alarm_1.gml b/objects/obj_controller/Alarm_1.gml
index d53191d2f1..fc2cf9e331 100644
--- a/objects/obj_controller/Alarm_1.gml
+++ b/objects/obj_controller/Alarm_1.gml
@@ -18,10 +18,7 @@ if (is_test_map == true) {
good_log = 1;
-var xx = 0;
-var yy = 0;
-var px = 0;
-var py = 0;
+var xx, yy, did = 0, _current_system = 0, px = 0, py = 0;
// Set player set
var _current_system = find_player_spawn_star();
@@ -490,25 +487,48 @@ if (did != 0) {
// Eldar craftworld here
craftworld = 1;
+// Restored: upstream's initialization cleanup removed this declaration in an
+// auto-merged hunk while the placement loop below kept every read, so starting
+// any new campaign crashed instantly at "if (go == 0)" with an unset variable.
+var go = 0;
+
for (var i = 0; i < 100; i++) {
- xx = floor(random(1152 + 600)) + 104;
- yy = floor(random(748 + 440)) + 104;
- if (point_distance(room_width / 2, room_height / 2, xx, yy) >= 50) {
+ if (go == 0) {
+ xx = floor(random(1152 + 600)) + 104;
+ yy = floor(random(748 + 440)) + 104;
+ if (point_distance(room_width / 2, room_height / 2, xx, yy) >= 50) {
+ go = 1;
+ }
var me = instance_nearest(xx, yy, obj_star);
- if ((point_distance(me.x, me.y, xx, yy) >= 150) && (xx < 1690 && yy > 780)) {
- var craft = instance_create(xx, yy, obj_star);
- craft.craftworld = 1;
- array_push(craft.p_feature[1], new NewPlanetFeature(eP_FEATURES.WARLORD6));
-
+ if ((go == 1) && (point_distance(me.x, me.y, xx, yy) >= 150)) {
+ go = 2;
+ }
+ if (go == 1) {
+ go = 0;
+ }
+ if ((xx >= 1050 + 640) || (yy <= 300 + 480)) {
+ go = 0;
+ }
+ }
+ if (go == 2) {
+ var craft = instance_create(xx, yy, obj_star);
+ craft.craftworld = 1;
+ go = 999;
+ array_push(craft.p_feature[1], new NewPlanetFeature(eP_FEATURES.WARLORD6));
+
+ // Eldar naval combat is rough to play against, so the escort fleet is kept
+ // small (vanilla spawned 2-3 capitals, 4-6 frigates, 8-11 escorts) and Eldar
+ // ship speed is toned down via ELDAR_SHIP_SPEED_MULT. Set ELDAR_FLEET_ENABLED
+ // to 0 in macros.gml to remove the fleet entirely.
+ if (ELDAR_FLEET_ENABLED) {
var elforce = instance_create(xx, yy, obj_en_fleet);
elforce.sprite_index = spr_fleet_eldar;
elforce.owner = eFACTION.ELDAR;
- elforce.capital_number = choose(2, 3);
- elforce.frigate_number = choose(4, 5, 6);
- elforce.escort_number = floor(random_range(7, 11)) + 1;
+ elforce.capital_number = 1;
+ elforce.frigate_number = choose(1, 2);
+ elforce.escort_number = choose(2, 3, 4);
elforce.image_alpha = 0;
elforce.orbiting = craft;
- break;
}
}
}
diff --git a/objects/obj_controller/Alarm_8.gml b/objects/obj_controller/Alarm_8.gml
index 563a616bd5..d39d775e6c 100644
--- a/objects/obj_controller/Alarm_8.gml
+++ b/objects/obj_controller/Alarm_8.gml
@@ -15,22 +15,12 @@ with (obj_star) {
heh.show = false;
heh.placing = false;
heh.alarm[1] = -1;
- if (p_owner[1] == 1) {
- p_pdf[1] += p_guardsmen[1];
- p_guardsmen[1] = 0;
- }
- if (p_owner[2] == 1) {
- p_pdf[2] += p_guardsmen[2];
- p_guardsmen[2] = 0;
- }
- if (p_owner[3] == 1) {
- p_pdf[3] += p_guardsmen[3];
- p_guardsmen[3] = 0;
- }
- if (p_owner[4] == 1) {
- p_pdf[4] += p_guardsmen[4];
- p_guardsmen[4] = 0;
- }
+ // The Guard-into-PDF fold was removed here. It used to run, every turn, for
+ // each player world: p_pdf += p_guardsmen; p_guardsmen = 0. That quietly ate the
+ // Imperial Guard you raise and deploy, folding them back into the abstract PDF and
+ // zeroing the deployable pool. Recruited Guard now persist as their own pool until
+ // you embark them, and recruitment pulls bodies the other way (PDF into Guard), so
+ // folding them back would undo it.
}
}
diff --git a/objects/obj_drop_select/Create_0.gml b/objects/obj_drop_select/Create_0.gml
index 3463fbfd6a..401b64c798 100644
--- a/objects/obj_drop_select/Create_0.gml
+++ b/objects/obj_drop_select/Create_0.gml
@@ -132,6 +132,15 @@ if (!instance_exists(obj_saveload)) {
if (formation_current == -1) {
formation_current = 0;
}
+ // Hardening: a stored last_raid_form / last_attack_form that is not among the formations
+ // valid for this drop type, or a value left over across a save/load, would otherwise leave
+ // formation_current as an out-of-range raw value. Clamp it so the selector can never index
+ // past the array, and pin it to 0 when there are no formations to choose from.
+ if (array_length(formation_possible) > 0) {
+ formation_current = clamp(formation_current, 0, array_length(formation_possible) - 1);
+ } else {
+ formation_current = 0;
+ }
fighting = array_create(11, array_create(501));
veh_fighting = array_create(11, array_create(501));
@@ -313,7 +322,7 @@ if (purge == 0) {
assasinate_purge = new PurgeButton(7, 631, 450, eDROP_TYPE.PURGEASSASSINATE);
assasinate_purge.active = _viable_ground_forces;
- assasinate_purge.description = "Often the simplest solution is a single bolt shell or the swift knife the heart. Kill the Leader.";
+ assasinate_purge.description = "Often the simplest solution is a single bolt shell or the swift knife to the heart. Kill the Planetary Governor, then choose the succession: the lawful heir, a governor sympathetic to your chapter, or a Chapter Serf who places the world under your control. Manipulating the succession carries a risk of Inquisition discovery, with consequences that may surface months or years later.";
purge_options = [
bombard_purge,
diff --git a/objects/obj_drop_select/Draw_64.gml b/objects/obj_drop_select/Draw_64.gml
index c1614c7c50..fdad2f1385 100644
--- a/objects/obj_drop_select/Draw_64.gml
+++ b/objects/obj_drop_select/Draw_64.gml
@@ -107,8 +107,8 @@ try {
var _draw_y = _y_center;
if (purge > eDROP_TYPE.PURGESELECT) {
- if (roster_slate.XX < _x_center + w) {
- _draw_x = min(roster_slate.XX + 15, _x_center + w);
+ if (roster_slate.XX < _x_center + 660) {
+ _draw_x = min(roster_slate.XX + 15, _x_center + 660);
} else {
_draw_x = roster_slate.XX;
}
diff --git a/objects/obj_en_fleet/Alarm_1.gml b/objects/obj_en_fleet/Alarm_1.gml
index 18904fbdac..19e9aa7202 100644
--- a/objects/obj_en_fleet/Alarm_1.gml
+++ b/objects/obj_en_fleet/Alarm_1.gml
@@ -374,7 +374,7 @@ try {
var dos = 0;
dos = point_distance(x, y, action_x, action_y);
- orbiting = dos / action_eta;
+ orbiting = dos / max(1, action_eta);
dir = point_direction(x, y, action_x, action_y);
x = x + lengthdir_x(orbiting, dir);
diff --git a/objects/obj_en_ship/Step_0.gml b/objects/obj_en_ship/Step_0.gml
index 04c45c9327..3a27ba529f 100644
--- a/objects/obj_en_ship/Step_0.gml
+++ b/objects/obj_en_ship/Step_0.gml
@@ -559,6 +559,11 @@ if (owner == 6) {
spid = 100;
}
+ // Vanilla Eldar run spid 60-100 against everyone else's 20-45, producing
+ // endless chase loops. Scaled here so the whole class table tunes from one
+ // macro; they stay the fastest ships in the game.
+ spid *= ELDAR_SHIP_SPEED_MULT;
+
if (target != 0) {
if (speed < (spid / 10)) {
speed += 0.02;
diff --git a/objects/obj_enunit/Alarm_0.gml b/objects/obj_enunit/Alarm_0.gml
index 2ca388bb62..bb5678cd0c 100644
--- a/objects/obj_enunit/Alarm_0.gml
+++ b/objects/obj_enunit/Alarm_0.gml
@@ -9,6 +9,12 @@ if (enemy == noone) {
exit;
}
+// enemy2 is used across several sibling blocks below (the two column-piercing
+// passes and the older melee/vehicle chain). Declare it once here at event scope;
+// GML hoists var anyway, so this only makes the shared scope explicit and silences
+// the GM2043 "outside its scope" warnings.
+var enemy2 = noone;
+
//In melee check
engaged = collision_point(x - 10, y, obj_pnunit, 0, 1) || collision_point(x + 10, y, obj_pnunit, 0, 1);
@@ -48,7 +54,16 @@ if (!engaged) {
if (range[i] >= dist) {
// The weapon is in range;
- var _target_vehicles = apa[i] > 0 ? true : false; // AP weapons target vehicles
+ // A weapon's armour pierce doubles as its role. Dedicated anti-tank guns
+ // (apa >= GUARD_ENEMY_ANTITANK_AP, the rokkit / lascannon / melta class) hunt
+ // vehicles. Everything lighter (general-purpose and pure anti-infantry) goes
+ // for the men first. This is preference by weapon role, not per-shot penetration
+ // knowledge: the enemy never checks whether a given shot would crack a specific
+ // target, it just sends tank-hunters at tanks and lighter guns at infantry.
+ // Either type still crosses over as a fallback through the paths below: an
+ // anti-tank gun with no vehicle in reach drops to the men path, and a lighter
+ // gun with no men in reach drops to the vehicle fallback inside the men path.
+ var _target_vehicles = apa[i] >= GUARD_ENEMY_ANTITANK_AP; // role-based target preference
// Weird alpha strike mechanic, that changes target unit index to CM;
if (((wep[i] == "Power Fist") || (wep[i] == "Bolter")) && (obj_ncombat.alpha_strike > 0) && (wep_num[i] > 5)) {
@@ -80,21 +95,48 @@ if (!engaged) {
if (block_has_armour(enemy) || (enemy.veh_type[1] == "Defenses")) {
scr_shoot(i, enemy, target_unit_index, "arp", "ranged");
continue;
- } else if ((instance_number(obj_pnunit) > 1) && (obj_ncombat.enemy != eFACTION.ORK)) {
- var x2 = enemy.x;
- repeat (instance_number(obj_pnunit) - 1) {
- x2 += flank == 0 ? -10 : 10;
- enemy2 = instance_nearest(x2, y, obj_pnunit);
- if (!target_block_is_valid(enemy2, obj_pnunit)) {
- continue;
- }
- if (range[i] < get_block_distance(enemy2)) {
- break;
- }
- if (block_has_armour(enemy2)) {
- scr_shoot(i, enemy2, target_unit_index, "arp", "ranged");
- _shot = true;
- break;
+ } else {
+ // Front block has no armour. If there are other blocks behind,
+ // look for a vehicle to hit. If none is found anywhere (including
+ // a single men-only block, such as a lone Guard rank), fall back
+ // to shooting the men instead of idling. The original code gated
+ // this whole fallback behind a multi-block check, so a lone
+ // men-only block left every AP weapon firing zero shots.
+ if ((instance_number(obj_pnunit) > 1) && (obj_ncombat.enemy != eFACTION.ORK)) {
+ var _column_size_value = enemy.column_size;
+ var x2 = enemy.x;
+ repeat (instance_number(obj_pnunit) - 1) {
+ x2 += flank == 0 ? -10 : 10;
+ enemy2 = instance_nearest(x2, y, obj_pnunit);
+ if (!target_block_is_valid(enemy2, obj_pnunit)) {
+ continue;
+ }
+ if (range[i] < get_block_distance(enemy2)) {
+ break;
+ }
+ if (block_has_armour(enemy2)) {
+ // Screening: a large front block (such as a wall of
+ // Guardsmen) shields the vehicles behind it from AP
+ // fire, the same way the men-targeting path below
+ // shields the men behind it. Without this every apa>0
+ // weapon looks straight past the men-only Guard rank
+ // to the Marines' vehicles, so the Guard screen
+ // nothing. When the back block is shielded the shot
+ // is not taken here; _shot stays false and the weapon
+ // falls back to firing on the front Guard block.
+ var _back_column_size_value = enemy2.column_size;
+ if (_back_column_size_value < _column_size_value) {
+ continue;
+ } else {
+ var _pass_chance = ((_back_column_size_value / _column_size_value) - 1) * 100;
+ if (irandom_range(1, 100) < min(_pass_chance, 80)) {
+ continue;
+ }
+ }
+ scr_shoot(i, enemy2, target_unit_index, "arp", "ranged");
+ _shot = true;
+ break;
+ }
}
}
if (!_shot) {
@@ -118,35 +160,105 @@ if (!engaged) {
continue;
} else if (instance_number(obj_pnunit) > 1) {
// There were no marines in the first column, looking behind;
- var _column_size_value = enemy.column_size;
- var x2 = enemy.x;
-
- repeat (instance_number(obj_pnunit) - 1) {
- x2 += !flank ? 10 : -10;
- enemy2 = instance_nearest(x2, y, obj_pnunit);
+ // Column piercing. This branch only runs when the front block has zero
+ // men, i.e. a tank wall (or similar armour-only block) is screening the
+ // infantry. The old code rolled a per-block "screening" chance that,
+ // behind a small vehicle block, pinned at its 80% cap and skipped the
+ // infantry, so ~4 of 5 anti-infantry volleys fell through to the vehicle
+ // fallback and plinked the armour. One tank became a bullet sponge for a
+ // thousand men behind it. Instead the volley now pierces by depth (see
+ // PIERCE_LINE_SOAK / PIERCE_MAX_DEPTH in macros.gml): each armour line
+ // soaks a third of the original volley as bounced chip fire, whatever
+ // gets through lands on the first men-bearing line, and nothing reaches
+ // past the third line. Men-behind-men screening is unaffected: a front
+ // block with men in it still absorbs the whole volley above.
+ // Finding the ranks: the old probe walked x2 in 10px steps with
+ // instance_nearest, and inherited vanilla's direction bug: the men
+ // look-behind stepped +10 for the main force (toward the enemy),
+ // opposite to the -10 the vehicle look-behinds correctly use, so it
+ // only ever resolved the front block itself and never reached the
+ // ranks behind. Blocks behind the front are instead collected
+ // directly (main force fronts are the rightmost block, so behind is
+ // lower x; flank fronts are leftmost, behind is higher x), sorted
+ // nearest-behind first, and the first two men-bearing blocks in
+ // weapon range become rank 2 and rank 3.
+ var _front_x = enemy.x;
+ var _behind = [];
+ with (obj_pnunit) {
+ if (id == other.enemy) {
+ continue;
+ }
+ if (x <= 0) {
+ continue;
+ }
+ var _is_behind = other.flank ? (x > _front_x) : (x < _front_x);
+ if (_is_behind) {
+ array_push(_behind, id);
+ }
+ }
+ if (flank) {
+ array_sort(_behind, function(_a, _b) {
+ return _a.x - _b.x;
+ });
+ } else {
+ array_sort(_behind, function(_a, _b) {
+ return _b.x - _a.x;
+ });
+ }
+ var _wall_blocks = [];
+ var _wall_shots = [];
+ // Collect the lines the volley can interact with: the front wall plus
+ // blocks behind it, valid and in range, capped at PIERCE_MAX_DEPTH.
+ var _lines = [enemy];
+ for (var b = 0; b < array_length(_behind); b++) {
+ if (array_length(_lines) >= PIERCE_MAX_DEPTH) {
+ break;
+ }
+ enemy2 = _behind[b];
if (!target_block_is_valid(enemy2, obj_pnunit)) {
continue;
}
-
if (range[i] < get_block_distance(enemy2)) {
break;
}
-
- var _back_column_size_value = enemy2.column_size;
- if (_back_column_size_value < _column_size_value) {
- continue;
- } else {
- // Calculate chance of shots passing through to back row
- // Higher ratio of back column size to front column size increases pass-through chance
- // Maximum chance capped at 40% to ensure some protection remains
- var _pass_chance = ((_back_column_size_value / _column_size_value) - 1) * 100;
- if (irandom_range(1, 100) < min(_pass_chance, 80)) {
- continue;
+ array_push(_lines, enemy2);
+ }
+ var _total_shots = wep_num[i];
+ var _soak_shots = max(1, floor(_total_shots * PIERCE_LINE_SOAK));
+ var _remaining = _total_shots;
+ var _rank_block = noone;
+ for (var l = 0; l < array_length(_lines); l++) {
+ var _line = _lines[l];
+ if (_line.men > 0) {
+ _rank_block = _line;
+ break;
+ }
+ if (block_has_armour(_line) || (_line.veh_type[1] == "Defenses")) {
+ var _w_soak = min(_soak_shots, _remaining);
+ if (_w_soak > 0) {
+ array_push(_wall_blocks, _line);
+ array_push(_wall_shots, _w_soak);
+ _remaining -= _w_soak;
+ }
+ if (_remaining <= 0) {
+ break;
}
}
- scr_shoot(i, enemy2, target_unit_index, "att", "ranged");
+ }
+ if ((_rank_block != noone) && (_remaining > 0)) {
+ var _ammo_spent = false;
+ scr_shoot(i, _rank_block, target_unit_index, "att", "ranged", _remaining, !_ammo_spent);
+ _ammo_spent = true;
_shot = true;
- break;
+ // The soaked shots still land on the armour lines they bounced
+ // off, so the walls keep taking chip fire instead of the stopped
+ // shots vanishing.
+ for (var w = 0; w < array_length(_wall_blocks); w++) {
+ scr_shoot(i, _wall_blocks[w], target_unit_index, "att", "ranged", _wall_shots[w], !_ammo_spent);
+ }
+ if (_shot) {
+ continue;
+ }
}
}
diff --git a/objects/obj_enunit/Alarm_1.gml b/objects/obj_enunit/Alarm_1.gml
index 9dd5386ae8..c155f93065 100644
--- a/objects/obj_enunit/Alarm_1.gml
+++ b/objects/obj_enunit/Alarm_1.gml
@@ -1,7 +1,11 @@
men = 0;
veh = 0;
medi = 0;
-for (var i = 1; i <= 20; i++) {
+// From 0: scr_en_weapon fills stacks scanning from index 0, so the block's first-ever
+// weapon lives in slot 0. These resets started at 1, so slot 0's att/apa/wep_num were
+// never cleared and accumulated across every rebuild for the whole battle: its shot
+// count and armour-pierce grew each turn while every other stack rebuilt fresh.
+for (var i = 0; i <= 20; i++) {
att[i] = 0;
apa[i] = 0;
wep_num[i] = 0;
@@ -194,7 +198,7 @@ if (obj_ncombat.enemy == eFACTION.PLAYER) {
}
}
- for (var i = 1; i <= 100; i++) {
+ for (var i = 0; i <= 100; i++) {
att[i] = 0;
apa[i] = 0;
wep_num[i] = 0;
diff --git a/objects/obj_fleet/KeyPress_32.gml b/objects/obj_fleet/KeyPress_32.gml
new file mode 100644
index 0000000000..1e700ee017
--- /dev/null
+++ b/objects/obj_fleet/KeyPress_32.gml
@@ -0,0 +1,2 @@
+// Emptied to match upstream ff9b33eff (space combat zooming glitch fix deleted this
+// event file; obj_fleet.yy still lists the event, so an empty no-op is the safe drop-in).
diff --git a/objects/obj_formation_bar/Draw_64.gml b/objects/obj_formation_bar/Draw_64.gml
index 1287f83c60..9614254c29 100644
--- a/objects/obj_formation_bar/Draw_64.gml
+++ b/objects/obj_formation_bar/Draw_64.gml
@@ -51,6 +51,15 @@ if (dragging == false) {
rel_mousey = 0;
old_x = 0;
old_y = 0;
+ // col_parent deliberately NOT reset here. It is the bar's home bracket, assigned
+ // once by init_combat_bars, and mouse_release needs it on drop: both for the
+ // same-bracket snap-back check and for decrementing the source bracket's
+ // occupancy (temp[4800 + col_parent]). Zeroing it every idle frame (upstream
+ // commit 6730ae794) made every drop read and subtract from temp[4800], an
+ // uninitialized slot outside the per-bracket 4801-4810 range: a hard crash when
+ // that slot holds a string, and silent occupancy-count corruption (source
+ // bracket never decremented) when it holds a number. Bug exists verbatim in
+ // upstream main.
col_target = 0;
above_neighbor = 0;
nearest_col = 0;
diff --git a/objects/obj_ini/Create_0.gml b/objects/obj_ini/Create_0.gml
index 10122c5673..742f805467 100644
--- a/objects/obj_ini/Create_0.gml
+++ b/objects/obj_ini/Create_0.gml
@@ -90,6 +90,8 @@ ship_capacity = [];
ship_carrying = [];
ship_contents = [];
ship_turrets = [];
+ship_guardsmen = []; // Imperial Guard auxilia currently embarked on this ship
+ship_guardsmen_max = []; // Max Guard auxilia this ship hull can carry
ship_lost = [];
// Vehicle Init
@@ -183,12 +185,16 @@ if (global.load == -1) {
serialize = function() {
var _marines = array_create(0);
for (var _coy = 0; _coy <= 10; _coy++) {
- for (var _mar = 0; _mar <= 500; _mar++) {
+ // Iterate the company's full restored length, not a fixed 0..500, so guardsmen in overflow
+ // slots past 500 (HQ grows uncapped once it fills) are serialized too. The old early-break
+ // on consecutive empty slots is dropped because guardsmen can leave gaps, and the break
+ // would skip every unit after the first gap, which is what desynced TTRPG from name[] on
+ // reload.
+ var _coy_len = array_length(obj_ini.name[_coy]);
+ for (var _mar = 0; _mar < _coy_len; _mar++) {
if (obj_ini.name[_coy][_mar] != "") {
var _marine_json = jsonify_marine_struct(_coy, _mar, false);
array_push(_marines, _marine_json);
- } else if (_mar > 0 && _mar <= 499 && obj_ini.name[_coy][_mar + 1] == "") {
- break;
}
}
}
@@ -308,9 +314,16 @@ deserialize = function(save_data) {
obj_ini.TTRPG[company][marine].load_json_data(struct);
}
- obj_ini.TTRPG = array_create(11, array_create(501, {}));
- for (var _coy = 0; _coy <= 10; _coy++) {
- for (var _mar = 0; _mar <= 500; _mar++) {
+ // Size each company's struct array to its restored roster length (minimum 501) so TTRPG can
+ // never be shorter than the flat name[] array. HQ can exceed 501 slots once guardsmen overflow
+ // it, and a fixed 501 here desynced from name[] after a load, sending fetch_unit out of range
+ // (Chapter Management crash). Building each company array separately also avoids the shared
+ // inner-array aliasing of the old array_create(11, array_create(501, [])) form.
+ obj_ini.TTRPG = array_create(11);
+ for (var _coy = 0; _coy < 11; _coy++) {
+ var _coy_len = max(501, array_length(obj_ini.name[_coy]));
+ obj_ini.TTRPG[_coy] = array_create(_coy_len);
+ for (var _mar = 0; _mar < _coy_len; _mar++) {
obj_ini.TTRPG[_coy][_mar] = new TTRPG_stats("chapter", _coy, _mar, "blank");
}
}
diff --git a/objects/obj_managment_panel/Draw_64.gml b/objects/obj_managment_panel/Draw_64.gml
index f49d172dc3..d2b9490c6e 100644
--- a/objects/obj_managment_panel/Draw_64.gml
+++ b/objects/obj_managment_panel/Draw_64.gml
@@ -60,7 +60,7 @@ slate_panel.inside_method = function() {
} else if (title == "LIBRARIUM") {
draw_sprite_ext(spr_lib_area_pad, 0, x + (panel_width / 2) - ((0.3 * 180) / 2), y - 30, 0.3, 0.3, 0, c_white, 1);
} else {
- sprx = x + (wid / 2) - 16;
+ sprx = x + (panel_width / 2) - 16;
spry = y - 16;
sprw = 141 * 0.23;
sprh = 141 * 0.23;
diff --git a/objects/obj_ncombat/Alarm_0.gml b/objects/obj_ncombat/Alarm_0.gml
index a19dadd814..d14e35793a 100644
--- a/objects/obj_ncombat/Alarm_0.gml
+++ b/objects/obj_ncombat/Alarm_0.gml
@@ -119,7 +119,7 @@ try {
if (siege && (fortified > 0) && defending) {
global_attack *= 1.1;
- u.hp = round(u.hp * 1.2);
+ u.hp[1] = round(u.hp[1] * 1.2);
}
u.maxhp = u.hp;
@@ -409,7 +409,11 @@ try {
hulk_forces = make;
}
// * CSM Space Hulk *
- if (enemy == eFACTION.CHAOS) {
+ if ((enemy == eFACTION.CHAOS) || (enemy == eFACTION.HERETICS)) {
+ // Hulk garrisons roll faction CHAOS but store strength in p_traitors
+ // (obj_star Alarm_0), so under the corrected numbering their battles
+ // arrive as HERETICS: without this the hulk spawned no defenders at all
+ // (reported four out of four boardings empty).
modi = random_range(0.80, 1.20) + 1;
make = round(max(3, player_starting_dudes * modi));
@@ -1818,7 +1822,7 @@ try {
u.dudes_num[1] = 40;
u.dudes[2] = "Hormagaunt";
u.dudes_num[2] = 40;
- // Small Genestealer Cult Group
+ // Small Genestealer Group
if (threat == 1) {
u = instance_nearest(xxx, 240, obj_enunit);
enemy_dudes = "100";
@@ -2086,7 +2090,12 @@ try {
}
// ** Chaos Forces **
- if ((enemy == eFACTION.CHAOS) && (battle_special != "ship_demon") && (battle_special != "fallen1") && (battle_special != "fallen2") && (battle_special != "WL10_reveal") && (battle_special != "WL10_later") && (string_count("cs_meeting_battle", battle_special) == 0)) {
+ // Spawn-side half of the chaos/heretics renumbering (upstream 173a6500 flipped
+ // battle RESOLUTION but not these composition gates): the threat-scaled cult and
+ // traitor horde compositions belong to HERETICS worlds (p_traitors), which now
+ // arrive as eFACTION.HERETICS. Before this swap they received the small CSM
+ // warband below (a constant ~5 CSM + 30 cultists at every strength).
+ if ((enemy == eFACTION.HERETICS) && (battle_special != "ship_demon") && (battle_special != "fallen1") && (battle_special != "fallen2") && (battle_special != "WL10_reveal") && (battle_special != "WL10_later") && (string_count("cs_meeting_battle", battle_special) == 0)) {
// Small Chaos Cult Group
if (threat == 1) {
u = instance_nearest(xxx, 240, obj_enunit);
@@ -2295,13 +2304,19 @@ try {
}
}
+ // Hoisted above both Chaos-side composition blocks: the CSM warband block below
+ // reads slaa 39 times but the declaration only executed inside this daemon block,
+ // so any plain Chaos ground battle hit an uninitialized local at its first
+ // if (slaa) case (GM2043 was flagging a real latent crash, present in vanilla's
+ // old numbering too). If we want multiple story events for specific Chaos Gods,
+ // slaa could become a gods value instead (upstream TBD note preserved).
+ var slaa = false;
+ if (battle_special == "ruins_eldar") {
+ slaa = true;
+ }
+
// ** Daemon Forces ** - The faction for the check is the Genestealers but the forces being setup are clearly Daemons.
if (enemy == eFACTION.CHAOS && (battle_special == "ship_demon" || battle_special == "ruins_eldar")) {
- // If we want to have multiple story events regarding specific Chaos Gods, we could name slaa into gods and just check the value? TBD
- var slaa = false;
- if (battle_special == "ruins_eldar") {
- slaa = true;
- }
// Small Daemon Group
if (threat == 1) {
u = instance_nearest(xxx, 240, obj_enunit);
@@ -2525,6 +2540,236 @@ try {
}
}
+ // ** Chaos Space Marines Forces **
+ // The CSM warband compositions belong to true Chaos forces (p_chaos), which now
+ // arrive as eFACTION.CHAOS. Second half of the spawn-side renumbering swap.
+ // The renumbering moved this block to CHAOS, whose battles include the daemon,
+ // fallen, and concealed-warlord specials (each with its own composition above);
+ // without these exclusions those battles would spawn a CSM warband on top of
+ // their special forces. The HERETICS block carries the same list for the same
+ // reason.
+ if ((enemy == eFACTION.CHAOS) && (battle_special != "ChaosWarband") && (battle_special != "ship_demon") && (battle_special != "ruins_eldar") && (battle_special != "fallen1") && (battle_special != "fallen2") && (battle_special != "WL10_reveal") && (battle_special != "WL10_later") && (string_count("cs_meeting_battle", battle_special) == 0)) {
+ // Small CSM Group
+ if (threat == 1) {
+ u = instance_nearest(xxx, 240, obj_enunit);
+ enemy_dudes = "5";
+
+ u.dudes[1] = choose("Bloodletter", "Daemonette", "Plaguebearer", "Pink Horror");
+ if (slaa) {
+ u.dudes[1] = "Daemonette";
+ }
+ u.dudes_num[1] = 5;
+ enemies[1] = u.dudes[1];
+ u.dudes[2] = "Cultist Elite";
+ u.dudes_num[2] = 30;
+ enemies[2] = u.dudes[2];
+ }
+ // Medium Daemon Group
+ if (threat == 2) {
+ u = instance_nearest(xxx, 240, obj_enunit);
+ enemy_dudes = "90";
+
+ u.dudes[1] = choose("Bloodletter", "Daemonette", "Plaguebearer", "Pink Horror");
+ if (slaa) {
+ u.dudes[1] = "Daemonette";
+ }
+ u.dudes_num[1] = 30;
+
+ instance_deactivate_object(u);
+ u = instance_nearest(xxx + 10, 240, obj_enunit);
+ u.dudes[1] = choose("Bloodletter", "Daemonette", "Plaguebearer", "Pink Horror");
+ if (slaa) {
+ u.dudes[1] = "Daemonette";
+ }
+ u.dudes_num[1] = 30;
+ u.dudes[2] = "Defiler";
+ u.dudes_num[2] = 1;
+ }
+ // Large Daemon Group
+ if (threat == 3) {
+ u = instance_nearest(xxx, 240, obj_enunit);
+ enemy_dudes = "240";
+
+ u.dudes[1] = "Greater Daemon of " + choose("Tzeentch", "Slaanesh", "Nurgle", "Khorne");
+ if (slaa) {
+ u.dudes[1] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[1] = 1;
+ u.dudes[2] = "Chaos Sorcerer";
+ u.dudes_num[2] = 1;
+ u.dudes[3] = "Pink Horror";
+ if (slaa) {
+ u.dudes[3] = "Daemonette";
+ }
+ u.dudes_num[3] = 60;
+
+ instance_deactivate_object(u);
+ u = instance_nearest(xxx + 10, 240, obj_enunit);
+ u.dudes[1] = "Defiler";
+ u.dudes_num[1] = 2;
+
+ instance_deactivate_object(u);
+ u = instance_nearest(xxx + 20, 240, obj_enunit);
+ if (slaa) {
+ u.dudes[1] = "Daemonette";
+ u.dudes_num[1] = 240;
+ } else {
+ u.dudes[1] = "Bloodletter";
+ u.dudes_num[1] = 60;
+ u.dudes[2] = "Plaguebearer";
+ u.dudes_num[2] = 60;
+ u.dudes[3] = "Daemonette";
+ u.dudes_num[3] = 60;
+ u.dudes[4] = "Maulerfiend";
+ u.dudes_num[4] = 2;
+ }
+ }
+ // Small Daemon Army
+ if (threat == 4) {
+ u = instance_nearest(xxx + 40, 240, obj_enunit);
+ enemy_dudes = "400";
+ u.neww = 1;
+
+ u.dudes[1] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch"));
+ if (slaa) {
+ u.dudes[1] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[1] = 1;
+ u.dudes[2] = "Chaos Sorcerer";
+ u.dudes_num[2] = 2;
+ u.dudes[3] = "Chaos Chosen";
+ u.dudes_num[3] = 10;
+
+ instance_deactivate_object(u);
+
+ u = instance_nearest(xxx + 20, 240, obj_enunit);
+ if (slaa) {
+ u.dudes[1] = "Daemonette";
+ u.dudes_num[1] = 400;
+ u.dudes[2] = "Maulerfiend";
+ u.dudes_num[2] = 2;
+ } else {
+ u.dudes[1] = "Bloodletter";
+ u.dudes_num[1] = 100;
+ u.dudes[2] = "Daemonette";
+ u.dudes_num[2] = 100;
+ u.dudes[3] = "Plaguebearer";
+ u.dudes_num[3] = 100;
+ u.dudes[4] = "Pink Horror";
+ u.dudes_num[4] = 100;
+ u.dudes[5] = "Maulerfiend";
+ u.dudes_num[5] = 2;
+ }
+ instance_deactivate_object(u);
+ }
+ // Medium Daemon Army
+ if (threat == 5) {
+ u = instance_nearest(xxx + 40, 240, obj_enunit);
+ enemy_dudes = "1000";
+ u.neww = 1;
+
+ u.dudes[1] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[1] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[1] = 1;
+ u.dudes[2] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[2] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[2] = 1;
+ u.dudes[3] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[3] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[3] = 1;
+ u.dudes[4] = "Soul Grinder";
+ u.dudes_num[4] = 2;
+ instance_deactivate_object(u);
+
+ u = instance_nearest(xxx + 20, 240, obj_enunit);
+ if (slaa) {
+ u.dudes[1] = "Daemonette";
+ u.dudes_num[1] = 1000;
+ u.dudes[2] = "Maulerfiend";
+ u.dudes_num[2] = 2;
+ } else {
+ u.dudes[1] = "Bloodletter";
+ u.dudes_num[1] = 250;
+ u.dudes[2] = "Daemonette";
+ u.dudes_num[2] = 250;
+ u.dudes[3] = "Plaguebearer";
+ u.dudes_num[3] = 250;
+ u.dudes[4] = "Pink Horror";
+ u.dudes_num[4] = 250;
+ u.dudes[5] = "Maulerfiend";
+ u.dudes_num[5] = 2;
+ }
+ instance_deactivate_object(u);
+ }
+ // Large Daemon Army
+ if (threat == 6) {
+ u = instance_nearest(xxx + 40, 240, obj_enunit);
+ enemy_dudes = "2000";
+ u.neww = 1;
+
+ u.dudes[1] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[1] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[1] = 1;
+ u.dudes[2] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[2] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[2] = 1;
+ u.dudes[3] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[3] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[3] = 1;
+ u.dudes[4] = "Soul Grinder";
+ u.dudes_num[4] = 1;
+ instance_deactivate_object(u);
+
+ u = instance_nearest(xxx + 30, 240, obj_enunit);
+ u.neww = 1;
+ u.dudes[1] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[1] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[1] = 1;
+ u.dudes[2] = "Greater Daemon of " + string(choose("Slaanesh", "Tzeentch", "Khorne", "Nurgle"));
+ if (slaa) {
+ u.dudes[2] = "Greater Daemon of Slaanesh";
+ }
+ u.dudes_num[2] = 1;
+ u.dudes[3] = "Soul Grinder";
+ u.dudes_num[3] = 1;
+ instance_deactivate_object(u);
+
+ u = instance_nearest(xxx + 20, 240, obj_enunit);
+ if (slaa) {
+ u.dudes[1] = "Daemonette";
+ u.dudes_num[1] = 2000;
+ u.dudes[2] = "Maulerfiend";
+ u.dudes_num[2] = 3;
+ } else {
+ u.dudes[1] = "Bloodletter";
+ u.dudes_num[1] = 500;
+ u.dudes[2] = "Daemonette";
+ u.dudes_num[2] = 500;
+ u.dudes[3] = "Plaguebearer";
+ u.dudes_num[3] = 500;
+ u.dudes[4] = "Pink Horror";
+ u.dudes_num[4] = 500;
+ u.dudes[5] = "Maulerfiend";
+ u.dudes_num[5] = 3;
+ }
+ instance_deactivate_object(u);
+ }
+ }
+
// ** Chaos Space Marines Forces **
if ((enemy == eFACTION.HERETICS) && (battle_special != "ChaosWarband") && (string_count("cs_meeting_battle", battle_special) == 0)) {
// Small CSM Group
@@ -2747,7 +2992,7 @@ try {
}
// ** World Eaters Forces **
- if ((enemy == eFACTION.HERETICS) && (battle_special == "ChaosWarband")) {
+ if ((enemy == eFACTION.CHAOS) && (battle_special == "ChaosWarband")) {
// Small WE Group
if (threat == 1) {
u = instance_nearest(xxx, 240, obj_enunit);
@@ -3201,6 +3446,15 @@ try {
u.sprite_index = spr_weapon_blank;
}
+ // ** Imperial Guard auxilia **
+ // The old p_guardsmen bolt-on block was retired here. Guardsmen are now fielded
+ // through the standard roster: station them on a world (ship_location -1) and the
+ // Local Forces toggle deploys them into the Hirelings block like any other unit,
+ // so the engine handles placement, targeting and casualties. player_guard is kept
+ // at 0 so the dormant firepower-injection path in scr_player_combat_weapon_stacks
+ // stays inert.
+ player_guard = 0;
+
instance_activate_object(obj_enunit);
} catch (_exception) {
ERROR_HANDLER.handle_exception(_exception);
diff --git a/objects/obj_ncombat/Alarm_5.gml b/objects/obj_ncombat/Alarm_5.gml
index a16220d3bf..781b8a10bc 100644
--- a/objects/obj_ncombat/Alarm_5.gml
+++ b/objects/obj_ncombat/Alarm_5.gml
@@ -325,15 +325,27 @@ if ((fortified > 0) && (!instance_exists(obj_nfort)) && (reduce_fortification ==
if ((!defeat) && (battle_special == "space_hulk")) {
var enemy_power = 0, loot = 0, dicey = roll_dice_chapter(1, 100, "low");
- if (enemy == eFACTION.ORK || enemy == eFACTION.TYRANIDS || enemy == eFACTION.HERETICS) {
+ // Reduce the hulk garrison by one and read the strength left behind. Chaos-rolled
+ // hulks store their garrison in p_traitors (obj_star Alarm_0), so under the
+ // corrected Chaos/Heretic numbering (upstream 173a6500) their battles arrive as
+ // eFACTION.HERETICS and the raw enemy id reaches the right slot; the old swap
+ // this fork carried predates the renumbering and would now misroute. This block
+ // also lets hulks lose forces, clear, and roll loot at all, which they
+ // previously never could.
+ if (enemy == eFACTION.ORK || enemy == eFACTION.TYRANIDS || enemy == eFACTION.HERETICS || enemy == eFACTION.CHAOS) {
enemy_power = p_data.add_forces(enemy, -1);
+ // Garrison wiped out: the hulk is cleared. The salvage choice is offered post-battle in
+ // space_hulk_explore_battle_aftermath (scr_post_battle_events).
+ if (enemy_power <= 0) {
+ hulk_cleared = 1;
+ }
}
part10 = "Space Hulk Exploration at ";
var ex = min(100, 100 - ((enemy_power - 1) * 20));
part10 += string(ex) + "%";
- _newline = part10;
- combat_log.push(_newline, eMSG_COLOR.YELLOW);
+ // Ported to the rebuilt combat log (upstream removed scr_newtext).
+ add_battle_log_message(part10, (ex == 100) ? eMSG_COLOR.RED : eMSG_COLOR.DEFAULT);
if (dicey <= (enemy_power * 10)) {
loot = choose(1, 2, 3, 4);
@@ -421,6 +433,7 @@ if (defeat == 0 && _reduce_power) {
part10 = "Necrons";
}
+
if (instance_exists(battle_object) && (enemy_power > 2)) {
if (awake_tomb_world(battle_object.p_feature[battle_id]) != 0) {
scr_gov_disp(battle_object.name, battle_id, floor(enemy_power / 2));
@@ -436,6 +449,15 @@ if (defeat == 0 && _reduce_power) {
new_power = enemy_power - power_reduction;
new_power = max(new_power, 0);
+ // Eldar craftworld hunt: intelligence is recovered only when the warhost is
+ // wiped from the planet entirely (its force reaches zero), never per battle,
+ // so repeated raids against the same warhost cannot farm clues (tester found
+ // three raids on one world yielded three clues). Only while the craftworld
+ // remains hidden.
+ if ((enemy == eFACTION.ELDAR) && (new_power <= 0) && (obj_controller.known[eFACTION.ELDAR] == 0)) {
+ eldar_intel_grant();
+ }
+
// Give some money for killing enemies?
var _quad_factor = 6;
requisition_reward = _quad_factor * sqr(threat);
@@ -522,6 +544,12 @@ if (defeat == 0 && _reduce_power) {
}
if (enemy >= eFACTION.ECCLESIARCHY) {
+ // Upstream commit 173a6500 ("fix: The entire chaos vs heretics standoff")
+ // renumbered the Chaos/Heretic pair to match eFACTION everywhere: enemy
+ // eFACTION.CHAOS now reads p_chaos above and eFACTION.HERETICS reads
+ // p_traitors, the same mapping edit_forces uses. The compensating swap this
+ // fork carried (translating the pair before edit_forces) is therefore
+ // removed; with the renumbering in place it would invert a correct pair.
p_data.edit_forces(enemy, new_power);
}
@@ -550,31 +578,33 @@ if (defeat == 0 && _reduce_power) {
}
if ((enemy == eFACTION.TAU) && (ethereal > 0) && (defeat == 0)) {
- _newline = "Tau Ethereal Captured";
- combat_log.push(_newline, eMSG_COLOR.YELLOW);
+ add_battle_log_message("Tau Ethereal Captured", eMSG_COLOR.YELLOW);
}
if (enemy == eFACTION.NECRONS && p_data.planet_forces[eFACTION.NECRONS] < 3 && awake_tomb_world(p_data.features) == 1) {
if (plasma_bomb > 0) {
- _newline = "Plasma Bomb used to seal the Necron Tomb.";
- combat_log.push(_newline, eMSG_COLOR.YELLOW);
+ add_battle_log_message("Plasma Bomb used to seal the Necron Tomb.", eMSG_COLOR.YELLOW);
seal_tomb_world(p_data.features);
} else if (plasma_bomb <= 0) {
p_data.edit_forces(enemy, 3);
if (dropping) {
- _newline = "Deep Strike Ineffective; Plasma Bomb required";
+ newline = "Deep Strike Ineffective; Plasma Bomb required";
}
if (!dropping) {
- _newline = "Attack Ineffective; Plasma Bomb required";
+ newline = "Attack Ineffective; Plasma Bomb required";
}
- combat_log.push(_newline, eMSG_COLOR.RED);
+ // This message was assigned but never posted (dead newline under the
+ // legacy log too); posted properly on the rebuilt log.
+ add_battle_log_message(newline, eMSG_COLOR.YELLOW);
+ // A blended merge hunk also pushed the stale _newline here in red,
+ // re-posting the previous log line on every plasma-ineffective result;
+ // removed.
}
}
}
if ((defeat == 0) && (enemy == eFACTION.TYRANIDS) && (battle_special == "tyranid_org")) {
- _newline = $"{string_plural_count("Gaunt organism", captured_gaunt)} have been captured.";
- combat_log.push(_newline, eMSG_COLOR.YELLOW);
+ add_battle_log_message($"{string_plural_count("Gaunt organism", captured_gaunt)} have been captured.", eMSG_COLOR.DEFAULT);
if (captured_gaunt > 0) {
var why = 0, thatta = 0;
@@ -797,9 +827,10 @@ if ((inq_eated == false) && (sorcery_seen >= 2)) {
}
if ((exterminatus > 0) && dropping) {
- _newline = "Exterminatus has been succesfully placed.";
endline = 0;
- combat_log.push(_newline, eMSG_COLOR.YELLOW);
+ // Was a legacy newline/newline_color assignment beside a blended push of the
+ // stale _newline, so the wrong text posted; posts its own line now.
+ combat_log.push("Exterminatus has been succesfully placed.", eMSG_COLOR.YELLOW);
}
instance_activate_object(obj_star);
@@ -851,7 +882,7 @@ if ((obj_ini.fleet_type != ePLAYER_BASE.HOME_WORLD) && (defeat == 1) && !droppin
}
var percent = 0;
- _newline = "";
+ newline = "";
if (defenses_lost > 0) {
percent = round((defenses_lost / player_defenses) * 100);
_newline = string(defenses_lost) + " Weapon Emplacements have been lost (" + string(percent) + "%).";
@@ -906,6 +937,14 @@ if (defeat == 1) {
}
}
+// The line "obj_ini.gene_slaves = [];" was removed here. It ran unconditionally at
+// the end of EVERY battle resolution, silently deleting all Test-Slave Incubator
+// batches raw (no gene-seed recovery, no incubator item refund, no event) any time
+// the player fought anything while batches were maturing. The legitimate wipe (the
+// homeworld falling with no underground gene vaults) is already handled above via
+// destroy_all_gene_slaves(false) inside its proper guard. Present verbatim in
+// upstream main (objects\obj_ncombat\Alarm_5.gml).
+
instance_deactivate_object(obj_star);
instance_deactivate_object(obj_ground_mission);
diff --git a/objects/obj_ncombat/Alarm_7.gml b/objects/obj_ncombat/Alarm_7.gml
index 962ebc5405..e85ff9815a 100644
--- a/objects/obj_ncombat/Alarm_7.gml
+++ b/objects/obj_ncombat/Alarm_7.gml
@@ -10,6 +10,21 @@ try {
LOGGER.info($"Ground Combat - {(defeat ? "Defeat" : "Victory")} - Enemy:{enemy} ({battle_special})");
+ // Guardsman veterancy: award battle experience to Guardsmen who fought here and
+ // lived, identified as basic Guardsmen still on the roster at the battle location
+ // (the same star-name match the meeting muster below uses). Only on victory. Around
+ // GUARD_VETERAN_XP / GUARD_BATTLE_XP survived battles makes one eligible for
+ // promotion to Veteran Guard (see promote_auxilia_to_veteran).
+ if (!defeat) {
+ var _grd_survivors = collect_role_group("all", "", false, { roles: ["Guardsman"] });
+ for (var _gs = 0; _gs < array_length(_grd_survivors); _gs++) {
+ var _grd = _grd_survivors[_gs];
+ if (_grd.location_string == obj_ncombat.battle_loc) {
+ _grd.add_experience(GUARD_BATTLE_XP);
+ }
+ }
+ }
+
// If battling own dudes, then remove the loyalists after the fact
if (enemy == eFACTION.PLAYER) {
diff --git a/objects/obj_ncombat/Create_0.gml b/objects/obj_ncombat/Create_0.gml
index 1867d552e1..b9154fe76e 100644
--- a/objects/obj_ncombat/Create_0.gml
+++ b/objects/obj_ncombat/Create_0.gml
@@ -82,6 +82,7 @@ done = 0;
captured_gaunt = 0;
ethereal = 0;
hulk_treasure = 0;
+hulk_cleared = 0;
four_show = 0;
chaos_angry = 0;
@@ -89,6 +90,7 @@ leader = 0;
thirsty = 0;
really_thirsty = 0;
allies = 0;
+player_attack_guard = 0; // embarked Guard committed to an assault from the attacking fleet
present_inquisitor = 0;
sorcery_seen = 0;
inquisitor_ship = 0;
@@ -132,6 +134,7 @@ player_forces = 0;
player_max = 0;
player_defenses = 0;
player_silos = 0;
+player_guard = 0;
enemy_forces = 0;
enemy_max = 0;
@@ -177,6 +180,14 @@ combat_log.log_font = fnt_aldrich_12;
ctally_target = undefined;
ctally_bounce = [];
ctally_injure = [];
+// Grazing flavor: worst wound severity (0..1) and vehicle flag for the current injured
+// tally group, so the consolidated wound line can be graded like the enemy side's.
+ctally_injure_severity = 0;
+ctally_is_vehicle = false;
+ktally_target = undefined;
+ktally_weapons = {};
+ktally_order = [];
+ktally_leaders = [];
world_size = 0;
@@ -238,6 +249,11 @@ en_big_mofo = 10;
defending = true; // 1 is defensive
dropping = false; // 0 is was on ground
+// Basic combat orders: latched true the first time any player block reaches the
+// enemy line to its front (east), which stops the formation-wide auto-advance so
+// the line holds at contact instead of individual blocks surging into gaps as
+// enemies die. Reset per battle here.
+player_front_contact = false;
attacking = 0; // 1 means attacked from space/local
time = floor(random(24)) + 1;
terrain = "";
diff --git a/objects/obj_ncombat/Draw_0.gml b/objects/obj_ncombat/Draw_0.gml
index 17ec7bc662..c110020a92 100644
--- a/objects/obj_ncombat/Draw_0.gml
+++ b/objects/obj_ncombat/Draw_0.gml
@@ -12,5 +12,6 @@ for (var l = 0; l <= 3; l++) {
draw_rectangle(0 + l, 0 + l, 800 - l, 900 - l, 1);
draw_rectangle(818 + l, 235 + l, 1578 - l, 666 - l, 1);
}
+draw_set_alpha(1);
draw_set_alpha(1);
diff --git a/objects/obj_p_fleet/Alarm_1.gml b/objects/obj_p_fleet/Alarm_1.gml
index 3e0b458f26..94144db28f 100644
--- a/objects/obj_p_fleet/Alarm_1.gml
+++ b/objects/obj_p_fleet/Alarm_1.gml
@@ -30,7 +30,7 @@ try {
}
var spid = point_distance(x, y, action_x, action_y);
- spid = spid / action_eta;
+ spid = spid / max(1, action_eta);
var dir = point_direction(x, y, action_x, action_y);
x = x + lengthdir_x(spid, dir);
@@ -94,7 +94,17 @@ try {
}
}
if ((steh.owner == eFACTION.ELDAR) && (obj_controller.faction_defeated[6] == 0) && (obj_controller.known[eFACTION.ELDAR] == 0)) {
- obj_controller.known[eFACTION.ELDAR] = 1;
+ // Encountering an Eldar-held system yields first-contact
+ // intelligence, once per campaign (hopping in and out of the system
+ // must not farm clues); the reveal stays gated behind
+ // ELDAR_INTEL_REQUIRED clues.
+ if (!variable_instance_exists(obj_controller, "eldar_space_clue_granted")) {
+ obj_controller.eldar_space_clue_granted = false;
+ }
+ if (!obj_controller.eldar_space_clue_granted) {
+ obj_controller.eldar_space_clue_granted = true;
+ eldar_intel_grant();
+ }
}
if ((steh.owner == eFACTION.TAU) && (obj_controller.faction_defeated[8] == 0) && (obj_controller.known[eFACTION.TAU] == 0)) {
obj_controller.known[eFACTION.TAU] = 1;
@@ -142,11 +152,18 @@ try {
dist = point_distance(x, y, steh.old_x, steh.old_y);
if ((rando >= 95) && (dist <= 300)) {
- obj_controller.known[eFACTION.ELDAR] = 1;
- scr_alert("green", "elfs", "Eldar Craftworld discovered.", steh.old_x, steh.old_y);
+ // A lucky close pass grants the same once-per-campaign
+ // first-contact clue, never a full reveal.
+ if (!variable_instance_exists(obj_controller, "eldar_space_clue_granted")) {
+ obj_controller.eldar_space_clue_granted = false;
+ }
+ if (!obj_controller.eldar_space_clue_granted) {
+ obj_controller.eldar_space_clue_granted = true;
+ eldar_intel_grant();
+ }
with (obj_en_fleet) {
if (owner == eFACTION.ELDAR) {
- image_alpha = 1;
+ // Un-hiding now happens in the intel reveal path only.
}
}
}
diff --git a/objects/obj_p_fleet/Alarm_3.gml b/objects/obj_p_fleet/Alarm_3.gml
index d88b008cad..2a23b39138 100644
--- a/objects/obj_p_fleet/Alarm_3.gml
+++ b/objects/obj_p_fleet/Alarm_3.gml
@@ -1,4 +1,4 @@
-if (obj_controller.zoomed == 1) {
+if (obj_controller.zoomed == 1 && !is_nan(self.x) && !is_nan(self.y)) {
obj_controller.x = self.x;
obj_controller.y = self.y;
}
diff --git a/objects/obj_p_fleet/Step_0.gml b/objects/obj_p_fleet/Step_0.gml
index 13cafe81f7..50d27d4002 100644
--- a/objects/obj_p_fleet/Step_0.gml
+++ b/objects/obj_p_fleet/Step_0.gml
@@ -10,8 +10,15 @@ if ((action != "") && (orbiting != noone)) {
action_spd = calculate_action_speed();
-if (ii_check == 0) {
+if (ii_check <= 0) {
+ // Refresh the fleet's map sprite size, then rearm the timer. Previously this fired
+ // exactly once (ii_check hit 0 then went negative and never reset), so a fleet
+ // whose serialized ii_check was already negative never recomputed its size after a
+ // load and rendered as a single tiny blip regardless of its real strength. The
+ // enemy fleet (obj_en_fleet) already rearms this timer; the player fleet did not.
+ // Using <= 0 also lets a loaded fleet with a negative timer heal on the next step.
set_player_fleet_image();
+ ii_check = 10;
}
if ((global.load >= 0) && (sprite_index != spr_fleet_tiny)) {
diff --git a/objects/obj_pnunit/Alarm_0.gml b/objects/obj_pnunit/Alarm_0.gml
index 1c43c0829e..a537f90959 100644
--- a/objects/obj_pnunit/Alarm_0.gml
+++ b/objects/obj_pnunit/Alarm_0.gml
@@ -1,9 +1,12 @@
try {
- enemy = instance_nearest(0, y, obj_enunit); // Left most enemy
-
- if (!instance_exists(enemy)) {
- engaged = false;
- exit;
+ // Basic combat orders: seed each block's move_order on its first tick from the
+ // vanilla movement rule (raids and attacks advance to contact, defense and the
+ // static formation hold), toggled by clicking the block's bar (obj_pnunit
+ // Step_0). Seeded here, above the dead-enemy early-exit below, so a block whose
+ // turn comes after the enemy is already destroyed still shows its order marker
+ // instead of a blank column.
+ if (move_order == "") {
+ move_order = (obj_ncombat.dropping || (!obj_ncombat.defending && obj_ncombat.formation_set != 2)) ? "advance" : "hold";
}
if (instance_number(obj_enunit) != 1) {
@@ -15,12 +18,45 @@ try {
}
}
- if (obj_ncombat.dropping || (!obj_ncombat.defending && obj_ncombat.formation_set != 2)) {
- move_unit_block("east");
+ enemy = instance_nearest(0, y, obj_enunit); // Left most enemy
+
+ // Ported from upstream: instance_nearest returns noone when the last enemy
+ // block dies between the turn driver pulsing alarm[0] and this block's tick,
+ // and reading enemy.x below then throws "Unable to find instance for object
+ // index -4". Exit early; set_up_player_blocks_turn re-arms this alarm next turn.
+ if (!instance_exists(enemy)) {
+ engaged = false;
+ exit;
}
+ // Auto-advance to contact: the enemy never moves (its own movement is commented
+ // out below), so an advancing formation has to close the gap itself. It advances
+ // as a body, one column per tick, and the WHOLE formation stops the moment any
+ // block reaches the enemy line (obj_ncombat.player_front_contact latches once and
+ // is not reset during the battle). This replaces the per-block auto-advance that
+ // let the formation drift apart: when a block's own front enemy died it surged
+ // east into the vacated space while blocks still engaged held, opening gaps even
+ // with the line in correct order, and blocks whose turn came after the enemy was
+ // gone froze mid-field with no order marker. A block the player has personally
+ // ordered (order_manual) still advances and leapfrogs freely for manual maneuver,
+ // ignoring the latch.
+ // Advance-to-contact movement now runs once per turn in move_player_blocks (called
+ // from set_up_player_blocks_turn) as a front-first ordered sweep, so a rear block no
+ // longer stalls behind a front block that has not moved yet. By the time this alarm
+ // fires the line has already advanced; this event only resolves fire. The front-contact
+ // latch is still refreshed below from this block's post-move position.
+
engaged = collision_point(x - 14, y, obj_enunit, 0, 1) != noone || collision_point(x + 14, y, obj_enunit, 0, 1) != noone;
+ // Latch formation contact the first time any block reaches the enemy line to its
+ // east (the front). Flankers spawn behind the formation to the west, so a west
+ // side (x - 14) touch must not trip the latch or the main body would stop closing
+ // with the enemy ahead. Never reset during the battle: once the line is met the
+ // formation holds instead of surging into gaps as enemies die.
+ if (collision_point(x + 14, y, obj_enunit, 0, 1)) {
+ obj_ncombat.player_front_contact = true;
+ }
+
var once_only = 0;
var range_shoot = "";
var dist = point_distance(x, y, enemy.x, enemy.y) / 10;
@@ -54,6 +90,10 @@ try {
obj_ncombat.ctally_target = undefined;
obj_ncombat.ctally_bounce = [];
obj_ncombat.ctally_injure = [];
+ obj_ncombat.ktally_target = undefined;
+ obj_ncombat.ktally_weapons = {};
+ obj_ncombat.ktally_order = [];
+ obj_ncombat.ktally_leaders = [];
for (var i = 0; i < array_length(wep); i++) {
// Enemies wiped before every weapon got to fire (e.g. spill-over cleared the line).
// Report who held fire and stop, rather than swinging at empty air.
@@ -61,9 +101,7 @@ try {
var _held_fire = [];
for (var hf = i; hf < array_length(wep); hf++) {
// Only ranged weapons "hold fire"; melee (range 1) never shoots, so skip it.
- // Mirror the firing ammo gate (ammo != 0) so out-of-ammo weapons that could not
- // have fired aren't reported as having held fire.
- if (wep[hf] != "" && wep_num[hf] > 0 && range[hf] > 1 && ammo[hf] != 0) {
+ if (wep[hf] != "" && wep_num[hf] > 0 && range[hf] > 1) {
array_push(_held_fire, wep[hf]);
}
}
@@ -82,9 +120,33 @@ try {
}
enemy = instance_nearest(0, y, obj_enunit);
}
+ // That destroy may have removed the last active enemy block, leaving the
+ // reacquire with noone; and reads on noone throw. Skip the weapon; the
+ // held-fire check at the top of the next iteration reports and stops.
+ if (!instance_exists(enemy)) {
+ continue;
+ }
+
+ // Speed Force sweeps the whole field - bypass normal targeting/range.
+ if (wep[i] == "Speed Force" || wep[i] == "Speed Force (Ranged)") {
+ scr_shoot_spread(i);
+ continue;
+ }
if ((range[i] >= dist) && (ammo[i] != 0 || range[i] == 1)) {
- if ((range[i] != 1) && (engaged == 0)) {
+ // Guard blocks (guard > 0) keep firing their ranged weapons even when
+ // engaged: Guardsmen empty lasguns and heavy bolters into the enemy at
+ // point-blank, and the tank line keeps its main guns firing once a unit
+ // reaches it instead of standing mute. Marines keep the vanilla rule of
+ // ranged only while not engaged. The bayonet (range 1) still resolves on
+ // the melee line below, so engaged Guard both shoot and stab.
+ // Vehicle-only blocks (men <= 0: Whirlwinds, Land Speeders, tank
+ // remnants) also keep firing when engaged: they carry no melee weapons
+ // at all, so the vanilla rule left them sitting mute in contact while
+ // enemy knives chip uselessly at their hulls, the last-survivors
+ // stalemate the battle timer exists to paper over. A vehicle firing
+ // its guns point-blank is no stranger than the Guard doing it.
+ if ((range[i] != 1) && ((engaged == 0) || (guard > 0) || (men <= 0))) {
range_shoot = "ranged";
}
if ((range[i] != floor(range[i]) || floor(range[i]) == 1) && engaged == 1) {
@@ -177,22 +239,112 @@ try {
good = 0;
if (enemy.men + enemy.medi > 0) {
- good = scr_target(enemy, "men"); // First target has vehicles, blow it to hell
+ good = scr_target(enemy, "men"); // First target has infantry, engage it
scr_shoot(i, enemy, good, "att", "ranged");
}
if ((good == 0) && (instance_number(obj_enunit) > 1)) {
- // First target does not have vehicles, cycle through objects to find one that has vehicles
- var x2 = enemy.x;
- repeat (instance_number(obj_enunit) - 1) {
- if (good == 0) {
- x2 += 10;
- var enemy2 = instance_nearest(x2, y, obj_enunit);
- if ((enemy2.men > 0) && (good == 0)) {
- good = scr_target(enemy2, "men"); // This target has vehicles, blow it to hell
- scr_shoot(i, enemy2, good, "att", "ranged");
- once_only = 1;
+ // Column piercing (player side), mirroring the enemy mechanic. The
+ // front enemy block has no infantry (a tank wall). Vanilla walked a
+ // 10px instance_nearest probe and fired the FULL volley at the first
+ // men-bearing block found (a free 100% pass through armour), or
+ // silently wasted the whole volley when the probe found nothing.
+ // Instead: blocks beyond the front are collected along the
+ // shooter-to-front axis (so main and flank fronts both walk the right
+ // way), sorted nearest-first; the volley pierces by depth (see
+ // PIERCE_LINE_SOAK / PIERCE_MAX_DEPTH in macros.gml), each armour
+ // line soaking a third of the original volley, the rest landing on
+ // the first infantry rank, nothing reaching past the third line.
+ var _front_x = enemy.x;
+ var _dir = sign(_front_x - x);
+ if (_dir == 0) {
+ _dir = 1;
+ }
+ var _beyond = [];
+ with (obj_enunit) {
+ if (id == other.enemy) {
+ continue;
+ }
+ if (((x - _front_x) * _dir) <= 0) {
+ continue;
+ }
+ array_push(_beyond, id);
+ }
+ if (_dir > 0) {
+ array_sort(_beyond, function(_a, _b) {
+ return _a.x - _b.x;
+ });
+ } else {
+ array_sort(_beyond, function(_a, _b) {
+ return _b.x - _a.x;
+ });
+ }
+ var _wall_blocks = [];
+ var _wall_shots = [];
+ // Lines the volley can interact with: front wall plus blocks
+ // beyond, valid and in range, capped at PIERCE_MAX_DEPTH.
+ var _lines = [enemy];
+ for (var b = 0; b < array_length(_beyond); b++) {
+ if (array_length(_lines) >= PIERCE_MAX_DEPTH) {
+ break;
+ }
+ var enemy2 = _beyond[b];
+ if (!target_block_is_valid(enemy2, obj_enunit)) {
+ continue;
+ }
+ if (range[i] < get_block_distance(enemy2)) {
+ break;
+ }
+ array_push(_lines, enemy2);
+ }
+ var _total_shots = wep_num[i];
+ var _soak_shots = max(1, floor(_total_shots * PIERCE_LINE_SOAK));
+ var _remaining = _total_shots;
+ var _rank_block = noone;
+ for (var l = 0; l < array_length(_lines); l++) {
+ var _line = _lines[l];
+ if (_line.men > 0) {
+ _rank_block = _line;
+ break;
+ }
+ if (block_has_armour(_line) > 0) {
+ var _w_soak = min(_soak_shots, _remaining);
+ if (_w_soak > 0) {
+ array_push(_wall_blocks, _line);
+ array_push(_wall_shots, _w_soak);
+ _remaining -= _w_soak;
+ }
+ if (_remaining <= 0) {
+ break;
+ }
+ }
+ }
+ if ((_rank_block != noone) && (_remaining > 0)) {
+ var _ammo_spent = false;
+ var _rank_target = scr_target(_rank_block, "men");
+ if (_rank_target != 0) {
+ scr_shoot(i, _rank_block, _rank_target, "att", "ranged", _remaining, !_ammo_spent);
+ _ammo_spent = true;
+ }
+ // Soaked shots chip the armour lines they bounced off.
+ for (var w = 0; w < array_length(_wall_blocks); w++) {
+ var _w_target = scr_target(_wall_blocks[w], "veh");
+ if (_w_target != 0) {
+ scr_shoot(i, _wall_blocks[w], _w_target, "att", "ranged", _wall_shots[w], !_ammo_spent);
+ _ammo_spent = true;
}
}
+ if (_ammo_spent) {
+ once_only = 1;
+ }
+ } else if (block_has_armour(enemy) > 0) {
+ // No infantry in range beyond the wall: the whole volley chips
+ // the wall instead of vanishing silently (vanilla fired nothing
+ // at all here and the shots were simply lost).
+ var _w_target = scr_target(enemy, "veh");
+ if (_w_target != 0) {
+ scr_shoot(i, enemy, _w_target, "att", "ranged");
+ once_only = 1;
+ }
}
}
}
@@ -227,7 +379,11 @@ try {
} // Fuck it, shoot at infantry
}
- if ((enemy.veh == 0) && (enemy.medi > 0) && (once_only == 0)) {
+ // instance_exists guard: the vehicle swing above can destroy the block
+ // (emptied enemy formations are destroyed immediately), and reading a
+ // destroyed instance throws. The ranged chain above already carries the
+ // same guards between its sub-branches; the melee chain was missing them.
+ if (instance_exists(enemy) && (enemy.veh == 0) && (enemy.medi > 0) && (once_only == 0)) {
// Check for vehicles
var g = 0, good = 0;
@@ -245,7 +401,7 @@ try {
} // Fuck it, shoot at infantry
}
- if ((ap == 0) && (once_only == 0)) {
+ if ((ap == 0) && (once_only == 0) && instance_exists(enemy)) {
// Check for men
var g = 0, good = 0;
@@ -266,9 +422,7 @@ try {
var _skipped_fire = [];
for (var s = 0; s < array_length(wep); s++) {
// Only ranged weapons "hold fire"; melee (range 1) never shoots, so skip it.
- // Mirror the firing ammo gate (ammo != 0) so out-of-ammo weapons that could not have
- // fired aren't reported as having held fire.
- if (wep[s] != "" && wep_num[s] > 0 && range[s] > 1 && ammo[s] != 0) {
+ if (wep[s] != "" && wep_num[s] > 0 && range[s] > 1) {
array_push(_skipped_fire, wep[s]);
}
}
@@ -276,6 +430,7 @@ try {
}
combat_tally_flush();
+ combat_kill_tally_flush();
instance_activate_object(obj_enunit);
@@ -283,7 +438,7 @@ try {
// can't keep the battle alive.
with (obj_enunit) {
var _alive = 0;
- for (var _rr = 1; _rr < array_length(dudes_num); _rr++) {
+ for (var _rr = 1; _rr <= 30; _rr++) {
if (dudes_num[_rr] > 0 && dudes_hp[_rr] > 0) {
_alive += dudes_num[_rr];
}
diff --git a/objects/obj_pnunit/Alarm_3.gml b/objects/obj_pnunit/Alarm_3.gml
index 8fbaf9dab5..1ca5469015 100644
--- a/objects/obj_pnunit/Alarm_3.gml
+++ b/objects/obj_pnunit/Alarm_3.gml
@@ -3,8 +3,11 @@ try {
if (men + dreads + veh <= 0) {
instance_destroy();
}
- obj_ncombat.player_forces += self.men + self.veh + self.dreads;
- obj_ncombat.player_max += self.men + self.veh + self.dreads;
+ // if (veh+dreads>0) then instance_destroy();
+ if (guard == 0) {
+ obj_ncombat.player_forces += self.men + self.veh + self.dreads;
+ obj_ncombat.player_max += self.men + self.veh + self.dreads;
+ }
//TODO centralise a method for moving units between columns
/*if (men<=4) and (veh=0) and (dreads=0){// Squish leftt
@@ -20,7 +23,7 @@ try {
var r_lost = 0;
for (var raar = 0; raar < (men + dreads); raar++) {
- var _r_roll = floor(random(1000)) + 1;
+ r_roll = floor(random(1000)) + 1;
if (obj_ncombat.player_forces < (obj_ncombat.player_max * 0.75)) {
_r_roll -= 8;
}
@@ -105,7 +108,15 @@ try {
}
}
- if (norun == 0) {
+ // Guard blocks never retreat. The vanilla rule backs a block away from a
+ // pure-vehicle enemy it cannot hurt, but for the Guard that walks the
+ // whole rank out of the line and off to the rear, where the enemy ignores
+ // it (the Marines are now the front block) and turns on the Marines it was
+ // meant to screen. This is why the Guard only behaved when they were the
+ // last force left: alone there is nothing to fall back behind and no
+ // Marine front for the enemy to switch to. The Guard are meant to hold and
+ // die in place, so only non-guard blocks fall back here.
+ if (norun == 0 && guard == 0) {
x -= 10;
engaged = 0;
}
diff --git a/objects/obj_pnunit/Create_0.gml b/objects/obj_pnunit/Create_0.gml
index f484862cf1..aacc03229f 100644
--- a/objects/obj_pnunit/Create_0.gml
+++ b/objects/obj_pnunit/Create_0.gml
@@ -9,7 +9,7 @@ attacked_dudes = 0;
dreads = 0;
jetpack_destroy = 0;
defenses = 0;
-enemy = instance_nearest(x, y, obj_enunit);
+guard = 0; // OBSOLETE (iteration 1): flag for the dead planetary Guard men-block. Never set to 1 anywhere, so the guard==1 branches in scr_clean and scr_player_combat_weapon_stacks are dead. Live guardsmen are role "Guardsman" unit_struct units.
unit_count = 0;
unit_count_old = 0;
@@ -17,6 +17,15 @@ composition_string = "";
column_size = 0;
+// Basic combat orders: "" until seeded from the battle type on the block's first
+// Alarm_0 tick, then "advance" or "hold", toggled by clicking the block's bar.
+move_order = "";
+// True only once the player has clicked this block. Leapfrogging is reserved for
+// manually ordered blocks: the seeded auto-advance must keep vanilla stall behavior
+// or formations scramble on their own (tester's raid reordered itself with no
+// orders given, tacticals vaulting the engaged front line).
+order_manual = false;
+
centerline_offset = 0;
pos = 880;
draw_size = 0;
diff --git a/objects/obj_pnunit/Draw_0.gml b/objects/obj_pnunit/Draw_0.gml
index 755c8bd33a..08ede57674 100644
--- a/objects/obj_pnunit/Draw_0.gml
+++ b/objects/obj_pnunit/Draw_0.gml
@@ -28,6 +28,21 @@ if (draw_size > 0) {
draw_rectangle(x1, y1, x2, y2, 0);
+ // Order indicator (basic combat orders): a green chevron above advancing blocks,
+ // an orange bar above holding ones, so each column's order reads at a glance.
+ // The Defenses pseudo-block takes no orders and gets no marker.
+ if ((move_order != "") && (veh_type[1] != "Defenses")) {
+ draw_set_font(fnt_40k_14b);
+ if (move_order == "advance") {
+ draw_set_color(c_lime);
+ draw_text(x1, y1 - 20, ">");
+ } else {
+ draw_set_color(c_orange);
+ draw_text(x1, y1 - 20, "=");
+ }
+ draw_set_color(c_red);
+ }
+
if (hit()) {
if (unit_count != unit_count_old) {
unit_count_old = unit_count;
diff --git a/objects/obj_pnunit/Step_0.gml b/objects/obj_pnunit/Step_0.gml
index a515dacaa7..2a6f612780 100644
--- a/objects/obj_pnunit/Step_0.gml
+++ b/objects/obj_pnunit/Step_0.gml
@@ -5,3 +5,15 @@ array_resize(lost_num, 0);
update_block_size();
update_block_unit_count();
+
+// Basic combat orders: left-clicking a block's bar toggles it between advancing and
+// holding. hit() is the drawn bar's own hover box (fadein-guarded), the same hitbox
+// the Row Composition panel keys off, so clicks land exactly where the player is
+// pointing rather than on the instance's sprite mask. Orders only exist once seeded
+// (move_order != ""), and the Defenses pseudo-block is not orderable.
+if (mouse_check_button_pressed(mb_left) && (move_order != "") && (veh_type[1] != "Defenses") && hit()) {
+ move_order = (move_order == "advance") ? "hold" : "advance";
+ // Player-issued orders unlock leapfrogging for this block; untouched blocks
+ // keep vanilla movement (stall behind friendlies) so formations hold shape.
+ order_manual = true;
+}
diff --git a/objects/obj_star/Create_0.gml b/objects/obj_star/Create_0.gml
index 85cc1d2c8b..66a43a4f86 100644
--- a/objects/obj_star/Create_0.gml
+++ b/objects/obj_star/Create_0.gml
@@ -83,7 +83,16 @@ get_garrison = function(planet){
_gar.star = self;
_gar.planet = planet;
} else {
- _gar.update();
+ try {
+ _gar.update();
+ } catch (_garrison_reload_error) {
+ // A garrison restored from a save keeps its data but loses its struct
+ // methods, so rebuild it from the planet's operatives, which persist.
+ system_garrison[planet] = new GarrisonForce(self, planet);
+ _gar = system_garrison[planet];
+ _gar.star = self;
+ _gar.planet = planet;
+ }
}
return _gar;
}
@@ -96,7 +105,14 @@ get_sabatours = function(planet){
_gar.star = self;
_gar.planet = planet;
} else {
- _gar.update();
+ try {
+ _gar.update();
+ } catch (_sabotage_reload_error) {
+ system_sabatours[planet] = new GarrisonForce(self, planet, "sabotage");
+ _gar = system_sabatours[planet];
+ _gar.star = self;
+ _gar.planet = planet;
+ }
}
return _gar;
}
@@ -108,7 +124,12 @@ get_planet_data = function(planet){
system_datas[planet] = new PlanetData(planet, self);
_gar = system_datas[planet];
} else {
- _gar.refresh_data();
+ try {
+ _gar.refresh_data();
+ } catch (_planetdata_reload_error) {
+ system_datas[planet] = new PlanetData(planet, self);
+ _gar = system_datas[planet];
+ }
}
return _gar;
}
diff --git a/objects/obj_star/Mouse_50.gml b/objects/obj_star/Mouse_50.gml
index 827c27f6db..8e8a6f7652 100644
--- a/objects/obj_star/Mouse_50.gml
+++ b/objects/obj_star/Mouse_50.gml
@@ -12,8 +12,18 @@ if (!global.ui_click_lock) {
if (obj_controller.location_viewer.is_entered) {
exit;
}
- if ((p_type[1] == "Craftworld") && (obj_controller.known[eFACTION.ELDAR] == 0)) {
- exit;
+ if (p_type[1] == "Craftworld") {
+ if (obj_controller.known[eFACTION.ELDAR] == 0) {
+ exit;
+ }
+ // A revealed craftworld is fully visible and clickable regardless of the
+ // fog-of-war vision flag. It spawns unseen at the galaxy's edge and nothing
+ // ever granted it vision, so the star drew after the reveal but rejected
+ // every click: it could not be selected, targeted for travel, or attacked
+ // (tester arrived "next to" it because move orders fell to the neighboring
+ // system). Setting vision here also self-heals saves where the reveal
+ // already happened.
+ vision = 1;
}
if (vision == 0) {
exit;
diff --git a/objects/obj_star_select/Create_0.gml b/objects/obj_star_select/Create_0.gml
index 99db0584e6..45f412b0b4 100644
--- a/objects/obj_star_select/Create_0.gml
+++ b/objects/obj_star_select/Create_0.gml
@@ -42,6 +42,26 @@ colonist_button.bind_method = function() {
new_colony_fleet(doner[0], doner[1], target.id, obj_controller.selecting_planet, "bolster_population");
};
+// Recruit Guard: levy Imperial Guard from a world's Planetary Defense Force. Player
+// worlds only (you can only mobilise your own PDF), drawn in fixed elements of 1000 and
+// bounded by the PDF on hand so it cannot be spammed. Costs 50 requisition per 1000.
+// Each PDF trooper raised joins the chapter as an individual Guardsman unit mustering at
+// the home planet, the same singular Guardsmen the Sector Governor supplies. Guardsmen
+// only: no Sergeants, Heavy Weapons Teams or vehicles.
+guard_recruit_button = new PurchaseButton(100);
+guard_recruit_button.update({tooltip: "Levy 1000 Imperial Guard from this world's PDF. Each joins your chapter as an individual Guardsman, mustering at your home planet. Drawn in fixed elements of 1000. /n Costs 50 requisition, requires personal control of the planet and at least 1000 PDF", label: "Recruit Guard", target: target});
+guard_recruit_button.bind_method = function() {
+ var _p = obj_controller.selecting_planet;
+ if (target.p_owner[_p] == eFACTION.PLAYER && target.p_pdf[_p] >= 1000) {
+ target.p_pdf[_p] -= 1000;
+ with (obj_controller) {
+ repeat (1000) {
+ scr_add_man("Guardsman", 0, "", "", 0, true, "home_planet", {skip_company_order: true});
+ }
+ }
+ }
+};
+
recruiting_button = new PurchaseButton(0);
recruiting_button.update({tooltip: "Enable recruiting", label: "Recruiting", target: target});
recruiting_button.bind_method = function() {
diff --git a/objects/obj_star_select/Draw_64.gml b/objects/obj_star_select/Draw_64.gml
index 8cfeb13674..4537c76763 100644
--- a/objects/obj_star_select/Draw_64.gml
+++ b/objects/obj_star_select/Draw_64.gml
@@ -176,9 +176,25 @@ try {
button3 = "Bombard";
} else {
button1 = "Attack";
- button2 = "Raid";
- if (p_data.population) {
+ // Raid while an enemy force is present, so invaders on an
+ // imperial or unowned world can be struck proactively. Purge
+ // used to overwrite Raid the moment the world had any
+ // population, which made a landed-but-not-yet-winning enemy
+ // unraidable. Purge returns once the world is clear of enemies.
+ if (p_data.xenos_and_heretics() > 0) {
+ button2 = "Raid";
+ // Dead worlds have no friendly population to shield, so
+ // orbital bombardment of the enemy force is offered here.
+ // Dead worlds never register as enemy-owned, so this else
+ // branch is the only one they reach and it never set
+ // Bombard, which is why it was missing on dead enemy worlds.
+ if (p_data.planet_type == "Dead") {
+ button3 = "Bombard";
+ }
+ } else if (p_data.population) {
button2 = "Purge";
+ } else {
+ button2 = "Raid";
}
}
@@ -293,10 +309,20 @@ try {
if (obj_controller.selecting_planet > 0) {
main_data_slate.draw(344, 160, slate_draw_scale, slate_draw_scale + 0.1);
}
+ // Deploy Guard auxilia: offer the 4th slot when guard-carrying ships orbit this world.
+ if ((button4 == "") && (obj_controller.selecting_planet > 0) && (player_guardsmen_at(target.name) > 0)) {
+ button4 = "Deploy Guard";
+ }
var current_button = "";
var shutter_x = main_data_slate.XX - 165;
var shutter_y = 296 + 165;
if (!debug) {
+ // Every planet action explains itself on hover (the shutter class has
+ // always rendered tooltips; they were simply never set).
+ shutter_1.tooltip = planet_action_tooltip(button1);
+ shutter_2.tooltip = planet_action_tooltip(button2);
+ shutter_3.tooltip = planet_action_tooltip(button3);
+ shutter_4.tooltip = planet_action_tooltip(button4);
if (shutter_1.draw_shutter(shutter_x, shutter_y, button1, 0.5, true)) {
current_button = button1;
}
@@ -327,17 +353,46 @@ try {
with (obj_star_select) {
instance_destroy();
}
- } else if (current_button == "Raid" && instance_nearest(x, y, obj_p_fleet).acted <= 1) {
- // feather ignore once GM2064
- instance_create_layer(x, y, layer_get_all()[0], obj_drop_select, {p_target: target, planet_number: obj_controller.selecting_planet, sh_target: instance_nearest(x, y, obj_p_fleet), purge: 0});
+ } else if (current_button == "Raid") {
+ // Per-ship gate, matching Attack: a raid deploys troops from ships just
+ // like an assault, so it is allowed while any carrying ship at this star
+ // still has support uses left this turn (SHIP_ASSAULTS_PER_TURN, i.e. 2),
+ // or while local forces do. Replaces the old fleet-wide gate
+ // (fleet.acted <= 1) that blocked the whole fleet after two raids
+ // regardless of how many ships were involved.
+ if (can_ground_deploy(target, obj_controller.selecting_planet)) {
+ // feather ignore once GM2064
+ instance_create_layer(x, y, layer_get_all()[0], obj_drop_select, {p_target: target, planet_number: obj_controller.selecting_planet, sh_target: instance_nearest(x, y, obj_p_fleet), purge: 0});
+ } else {
+ // Blocked actions say why instead of silently doing nothing (players
+ // assumed the game broke when clicks had no effect).
+ scr_popup("Raid", "Every ship and local force at this world has already supported the maximum number of raids and assaults this turn.", "");
+ }
} else if (current_button == "Attack") {
var _allow_attack = true;
var _targ = !target.present_fleet[1] ? noone : instance_nearest(x, y, obj_p_fleet);
+ // Ship assault economy: the old fleet-wide gate (acted >= 2) is
+ // replaced for ground assaults. An attack is possible while any
+ // carrying ship at this star still has support uses left this turn,
+ // or while planetside forces do and can fight without ship support.
+ // Local-only attacks (no fleet present) were previously ungated
+ // entirely; they now respect the planet's local exhaustion. Raid,
+ // Purge, and Bombard keep their fleet-level gates.
+ var _ship_available = false;
if (instance_exists(_targ)) {
- if (_targ.acted >= 2) {
- _allow_attack = false;
+ var _gate_ships = get_player_ships(target.name);
+ for (var _gs = 0; _gs < array_length(_gate_ships); _gs++) {
+ if ((obj_ini.ship_carrying[_gate_ships[_gs]] > 0) && (ship_assaults_used(_gate_ships[_gs]) < SHIP_ASSAULTS_PER_TURN)) {
+ _ship_available = true;
+ break;
+ }
}
}
+ var _local_available = (p_data.player_forces > 0) && (local_assaults_used(target, obj_controller.selecting_planet) < SHIP_ASSAULTS_PER_TURN);
+ if (!_ship_available && !_local_available) {
+ _allow_attack = false;
+ scr_popup("Ground Assault", "Your forces at this world have already supported the maximum number of ground assaults this turn.", "");
+ }
if (_allow_attack) {
// feather ignore once GM2064
instance_create_layer(x, y, layer_get_all()[0], obj_drop_select, {p_target: target, planet_number: obj_controller.selecting_planet, attack: true, sh_target: _targ, purge: 0});
@@ -348,6 +403,8 @@ try {
if (instance_exists(_targ)) {
if (_targ.acted >= 2) {
_allow_attack = false;
+ // Blocked actions say why instead of silently doing nothing.
+ scr_popup("Purge", "This fleet has already expended its actions this turn and cannot conduct a purge.", "");
}
}
if (_allow_attack) {
@@ -355,17 +412,25 @@ try {
instance_create_layer(x, y, layer_get_all()[0], obj_drop_select, {p_target: target, planet_number: obj_controller.selecting_planet, sh_target: _targ, purge: 1});
}
} else if (current_button == "Bombard") {
- instance_create(x, y, obj_bomb_select);
- if (instance_exists(obj_bomb_select)) {
- obj_bomb_select.p_target = target;
- obj_bomb_select.sh_target = instance_nearest(x, y, obj_p_fleet);
- obj_bomb_select.p_data = p_data;
- if (instance_nearest(x, y, obj_p_fleet).acted > 0) {
- with (obj_bomb_select) {
- instance_destroy();
- }
+ // Per-ship gate: orbital bombardment is one per ship per turn and
+ // consumes that ship's whole support allowance, so it needs a ship at
+ // this star that has done nothing else this turn. Replaces the old
+ // fleet-wide gate (fleet.acted > 0) that let the whole fleet bombard
+ // only once regardless of ship count.
+ if (get_fresh_bombard_ship(target.name) == -1) {
+ // Blocked actions say why instead of a dead click.
+ scr_popup("Bombardment", "No ship in orbit is free to bombard this turn. Each ship can bombard once per turn, and a ship that has already raided or supported an assault cannot.", "");
+ } else {
+ instance_create(x, y, obj_bomb_select);
+ if (instance_exists(obj_bomb_select)) {
+ obj_bomb_select.p_target = target;
+ obj_bomb_select.sh_target = instance_nearest(x, y, obj_p_fleet);
+ obj_bomb_select.p_data = p_data;
}
}
+ } else if (current_button == "Deploy Guard") {
+ var _n = deploy_guardsmen(target.name, obj_controller.selecting_planet);
+ scr_popup("Imperial Guard", "Deployed " + string(_n) + " Guard onto " + planet_numeral_name(obj_controller.selecting_planet, target) + ".", "");
} else if (current_button == "+Recruiting") {
if (obj_controller.recruiting_worlds_bought > 0 && p_data.current_owner <= 5 && !p_data.at_war()) {
if (!p_data.has_feature(eP_FEATURES.RECRUITING_WORLD)) {
diff --git a/objects/obj_star_select/Mouse_50.gml b/objects/obj_star_select/Mouse_50.gml
index ee6a23ade0..46dd3e500d 100644
--- a/objects/obj_star_select/Mouse_50.gml
+++ b/objects/obj_star_select/Mouse_50.gml
@@ -1,4 +1,4 @@
-if (!instance_exists(obj_saveload) && !instance_exists(obj_drop_select)) {
+if (!instance_exists(obj_saveload) && !instance_exists(obj_drop_select) && !instance_exists(obj_bomb_select)) {
if (obj_controller.diplomacy == 0) {
draw_set_font(fnt_fancy);
draw_set_halign(fa_center);
diff --git a/options/mac/options_mac.yy b/options/mac/options_mac.yy
index a92ce6ebe4..b94080f074 100644
--- a/options/mac/options_mac.yy
+++ b/options/mac/options_mac.yy
@@ -35,4 +35,4 @@
"option_mac_x86_64":true,
"resourceType":"GMMacOptions",
"resourceVersion":"2.0",
-}
+}
\ No newline at end of file
diff --git a/scripts/Armamentarium/Armamentarium.gml b/scripts/Armamentarium/Armamentarium.gml
index f19de360ff..b6d0b4c71c 100644
--- a/scripts/Armamentarium/Armamentarium.gml
+++ b/scripts/Armamentarium/Armamentarium.gml
@@ -793,7 +793,13 @@ function Armamentarium(_controller) constructor {
}
break;
case "ships":
- discount_stc = controller.stc_ships * 5;
+ // Tiered discount matching the STC panel descriptions and the real
+ // per-level feature unlocks. 8% at L1, 16% at L3, 25% at L5, held
+ // across the feature levels (L2 Hull, L4 Armour, L6 Warp). The old
+ // stc_ships * 5 gave 5/10/15/20/25/30, which mismatched every label
+ // and overshot the 25% cap at L6.
+ var _ship_tiers = [0, 8, 8, 16, 16, 25, 25];
+ discount_stc = _ship_tiers[clamp(controller.stc_ships, 0, STC_MAX_LEVEL)];
if (discount_stc > 0) {
global_cost_tooltip += $"Ship STC: -{discount_stc}%\n";
}
diff --git a/scripts/CombatLog/CombatLog.gml b/scripts/CombatLog/CombatLog.gml
index a089af865e..e8427808e8 100644
--- a/scripts/CombatLog/CombatLog.gml
+++ b/scripts/CombatLog/CombatLog.gml
@@ -4,6 +4,16 @@ enum eMSG_COLOR {
AQUA = 5,
RED = 6,
YELLOW = 8,
+ // Light green tier ported from Tavish's CM-Poligon color coding (CptMacTavish2224,
+ // tag LW_Beta_1.2): hits that wounded without killing.
+ LIGHTGREEN = 9,
+ // Bright red: the enemy's mirror of LIGHTGREEN. Enemy fire that wounds or pierces
+ // armour without killing, so incoming damage reads as a threat (red family) instead
+ // of borrowing the player's green. Kills stay the muted RED; this is the brighter
+ // "it hurt but nobody died yet" tier.
+ BRIGHT_RED = 10,
+ // Bright blue: reserved for continuous / ongoing-narration log lines.
+ BRIGHT_BLUE = 11,
}
/// @param {Id.Instance} _owner The optional owning instance or controller.
@@ -120,6 +130,15 @@ function CombatLog(_owner = undefined) constructor {
case eMSG_COLOR.YELLOW:
_final_color = 3055825;
break;
+ case eMSG_COLOR.LIGHTGREEN:
+ _final_color = c_lime;
+ break;
+ case eMSG_COLOR.BRIGHT_RED:
+ _final_color = make_color_rgb(255, 60, 60);
+ break;
+ case eMSG_COLOR.BRIGHT_BLUE:
+ _final_color = make_color_rgb(70, 150, 255);
+ break;
}
// Wrap individual line segment if width limits are set
diff --git a/scripts/macros/macros.gml b/scripts/macros/macros.gml
index f293b5c665..1c20baea33 100644
--- a/scripts/macros/macros.gml
+++ b/scripts/macros/macros.gml
@@ -1,3 +1,151 @@
+// Imperial Guard squad: how many guardsmen one Guard Squad unit represents.
+// RESERVED (iteration 2): the Guard Squad system (this macro, the guard_squad template,
+// scr_add_man, scr_marine_struct max_health(), scr_cheatcode, scr_roster, and the combat
+// hook in scr_player_combat_weapon_stacks) is not used in normal play. Kept for planned
+// reuse as heavy weapons teams. Do not delete.
+#macro GUARD_SQUAD_SIZE 10
+
+// Imperial Guard heavy weapons team: how many guardsmen one Heavy Weapons Team unit represents.
+// The team is a single pooled-HP entity (role "Heavy Weapons Team") crewing one heavy weapon, with
+// the health of this many guardsmen (see scr_marine_struct max_health()). 3 = a 3-man weapons team.
+#macro GUARD_HEAVY_WEAPONS_TEAM_SIZE 3
+
+// Ground-combat cover save: fraction of would-be casualties treated as missed, standing
+// in for spacing, terrain use and a low profile the combat model does not simulate.
+// Rolled per incoming shot in damage_infantry, after armour, so it also blunts
+// armour-piercing weapons that ignore Flak. A successful save posts a combat-log line.
+// Astartes are bulky and hide poorly, so their save is much weaker than the Guard's.
+#macro GUARD_COVER_SAVE 0.4
+#macro MARINE_COVER_SAVE 0.15
+
+// Cover fades as the enemy closes: the save is scaled by shooter distance (block units,
+// point_distance / 10). At or beyond COVER_SAVE_FULL_RANGE the full save applies; point
+// blank it drops to COVER_SAVE_MIN_FACTOR of it, so hugging the line strips cover.
+#macro COVER_SAVE_FULL_RANGE 10
+#macro COVER_SAVE_MIN_FACTOR 0.25
+
+// Anti-tank penetration is now a per-vehicle, cost-tiered weak-spot chance defined in
+// vehicle_penetration_chance (scr_clean), not a weapon-AP formula: a capable shot rolls
+// that chance to get through, so an armour-ignoring volley cannot brute-force a heavy hull
+// (a Land Raider sits at 5%). Tune the chances there, per vehicle type.
+
+// Range accuracy/damage falloff for ranged fire. Damage is scaled by how far the target
+// is relative to the weapon's range: at point blank it gets RANGE_POINT_BLANK_BONUS, and
+// it falls by up to RANGE_FALLOFF at maximum range, floored at RANGE_MIN_MULT. Short-range
+// weapons (Shotgun, Flamer) can only ever fire close, so they live in the bonus band and
+// hit hard; long-range weapons soften at the edge of their reach. Melee and wall fire are
+// exempt. Applied to dealt damage in scr_shoot.
+#macro RANGE_POINT_BLANK_BONUS 1.25
+#macro RANGE_FALLOFF 0.5
+#macro RANGE_MIN_MULT 0.6
+
+// Imperial Guard auxilia screen: the front-most battle columns guardsmen are dealt across.
+// Ten obj_pnunit columns exist (1 back to 10 front, higher column = nearer the enemy); the
+// Marine and vehicle roles only use columns 1-7, so 8-10 are free front-most positions.
+// Guardsmen are spread across these as separate positional blocks so the screen sits ahead of
+// the Marines and engages the enemy in waves, instead of merging the whole regiment into one
+// lasgun volley in the hire column. FIRST is the rear-most screen column, COUNT how many
+// front columns the screen occupies (FIRST + COUNT - 1 must stay within the 10 columns).
+#macro GUARD_SCREEN_COLUMN_FIRST 8
+#macro GUARD_SCREEN_COLUMN_COUNT 3
+
+// Enemy target preference: the minimum weapon armour pierce (apa, scale 0-4) that counts as
+// "anti-tank" and so hunts vehicles in obj_enunit\Alarm_0. Weapons below this prefer infantry
+// and only turn to vehicles as a fallback. 3 splits dedicated anti-tank (rokkit / lascannon /
+// melta tier) from general-purpose and anti-infantry guns. Raise toward 4 to make only the
+// heaviest guns chase tanks; drop toward 1 for the old behaviour where almost everything did.
+#macro GUARD_ENEMY_ANTITANK_AP 3
+
+// Column piercing (both sides): when a front block has no men (an armour wall), an
+// anti-infantry volley pushes through by depth instead of dumping into the wall. The
+// volley reaches at most PIERCE_MAX_DEPTH lines, front included. Every armour line it
+// passes soaks PIERCE_LINE_SOAK of the ORIGINAL volley as bounced chip fire, and
+// everything still travelling lands on the first men-bearing line. Through one wall
+// ~66% of the shots reach the infantry, through two walls ~33%, and infantry behind
+// three or more lines cannot be reached at all. Men-behind-men screening is unchanged:
+// a front block with men in it still absorbs the whole volley.
+#macro PIERCE_LINE_SOAK 0.33
+#macro PIERCE_MAX_DEPTH 3
+
+// Basic combat orders: an advancing block that finds a friendly block directly
+// ahead may leapfrog over it, landing on the first free slot beyond, probing at
+// most this many columns. It never lands on or vaults past an enemy block.
+#macro PLAYER_LEAPFROG_MAX_COLUMNS 6
+
+// Ship assault economy: how many ground assaults each ship can support per turn. The old
+// rule capped the whole fleet at 2 attacks per turn regardless of size, so deploying
+// everything on every assault cost nothing. Now each carrying ship supports this many
+// assaults per turn, one use spent per assault it contributes units to; bigger fleets
+// can clear a system in one turn, but every launch spends real capacity. Raids, purges,
+// and bombardment keep their old fleet-level rules.
+#macro SHIP_ASSAULTS_PER_TURN 2
+// Disposition drop a full indiscriminate fire purge (100% of the population burned)
+// inflicts on a world's regard for the Chapter. Scaled down by the actual share killed
+// per purge, so a light burn costs a little and a total one costs this much. Selective
+// purges (targeted heretics) and governor assassinations carry no penalty. Tune down
+// toward 0 to soften, up for harsher consequences.
+#macro PURGE_FIRE_DISPO_PENALTY 40
+
+// Eldar craftworld hunt. The hidden craftworld and the full Eldar battle roster have
+// always been in the game; what was missing is any way for Eldar to appear (nothing
+// ever raised p_eldar on normal worlds) and any realistic way to find the craftworld
+// (a 5% roll when parking a fleet within 300px of an invisible star). Now an Eldar
+// warhost strikes an inhabited world on a random cadence between ELDAR_INTERVAL_MIN
+// and ELDAR_INTERVAL_MAX turns; each ground
+// victory against them yields one piece of intelligence, and at ELDAR_INTEL_REQUIRED
+// pieces the craftworld is revealed for invasion. Warhost strength starts at
+// FORCE_BASE and ramps by one per clue collected up to FORCE_MAX, keeping max-tier
+// Eldar for the craftworld itself (its garrison is 6). ELDAR_FLEET_ENABLED gates the
+// craftworld's orbiting fleet, disabled for now so the reveal never forces Eldar
+// naval combat; flip to 1 to restore it. Gathered intelligence goes stale after
+// ELDAR_CLUE_EXPIRY turns: the clues are lost and the craftworld slips away to a new
+// hidden location, so it must be located, reached and assaulted within that window.
+#macro ELDAR_INTERVAL_MIN 30
+#macro ELDAR_INTERVAL_MAX 60
+#macro ELDAR_CLUE_EXPIRY 400
+#macro ELDAR_INCURSION_FORCE_BASE 3
+#macro ELDAR_INCURSION_FORCE_MAX 5
+#macro ELDAR_INTEL_REQUIRED 3
+#macro ELDAR_FLEET_ENABLED 1
+// Warhosts prefer worlds touched by the Great Enemy: a planet with heresy, chaos or
+// traitor presence is this many times likelier to be struck than a clean one. The
+// Eldar do not do proportionality: on a tainted world the warhost stays and scours
+// it each incursion tick, culling ELDAR_PURGE_POP_FRACTION of the population and
+// ELDAR_PURGE_DEFENSE_FRACTION of the PDF and Guard while purging the taint, so
+// leaving them to "clean up chaos for you" costs the world its people and clearing
+// them off is a real choice. On clean worlds the warhost withdraws after one
+// interval (its "secret mission" done) instead of garrisoning the sector forever.
+#macro ELDAR_TAINT_SPAWN_WEIGHT 5
+#macro ELDAR_PURGE_POP_FRACTION 0.25
+#macro ELDAR_PURGE_DEFENSE_FRACTION 0.5
+
+// Eldar naval combat tuning. Vanilla Eldar ships move at spid 60-100 while every other
+// faction's ships run 20-45, which is why fights against them degenerate into endless
+// chase loops. This multiplier scales the whole Eldar speed table; at 0.65 they run
+// 39-65, still comfortably the fastest ships in the game but catchable. 1.0 restores
+// vanilla darting.
+#macro ELDAR_SHIP_SPEED_MULT 0.65
+
+// Guard volley size: how many rank-and-file guardsmen share one firing stack in combat. The
+// regiment splits into capped stacks of this size instead of merging into one giant lasgun
+// volley, so each chunk fires and targets independently like an enemy obj_enunit block (those
+// run ~32-40 strong). They still deploy as one movable hireling line; this only affects firing.
+// Lower for more, smaller volleys; raise toward one big stack. Keep it from making too many
+// stacks: a block has 71 stack slots shared with every other weapon.
+#macro GUARD_VOLLEY_SIZE 100
+
+
+// Imperial Guard accuracy ("doom"): mirrors the enemy's per-faction doom in scr_shoot (the
+// owner == eFACTION.IMPERIUM branch, e.g. Orks 0.2, Tyranids 0.4). Massed lasgun fire from raw
+// conscripts connects far less than disciplined Astartes fire, so the guard's ranged lasgun
+// volleys have their effective shots scaled by this fraction before damage. The player branch
+// divides damage_per_weapon by wep_num rather than the scaled count, so per-shot damage is
+// untouched and the cut is linear: the volley still fires in full but only this share lands.
+// 1 = no reduction (marine-grade, also what Elite Cultists fire at), 0.35 = roughly a third of
+// the lasguns connect. Kills scale about linearly with this value, so 0.7 is roughly double the
+// effectiveness of 0.35 with no change to damage or penetration.
+#macro GUARD_DOOM 0.7
+
#macro MAX_STC_PER_SUBCATEGORY 6
#macro DEFAULT_TOOLTIP_VIEW_OFFSET 32
#macro DEFAULT_LINE_GAP -1
@@ -15,8 +163,13 @@
#macro MANAGE_MAN_MAX array_length(obj_controller.display_unit) + 7
#macro LARGE_PLANET_MOD 1000000000 // Population threshold for large planet classification
-// Ground combat message log: per-stage frame timeout before force-advancing.
+// Ground combat message log: lines the display fully drains per turn (so the end-of-turn status
+// line shows even on long battles), and the per-stage frame timeout before force-advancing.
+#macro COMBAT_LOG_CAPACITY 500
#macro COMBAT_STAGE_TIMEOUT_FRAMES 1200
+// Battle-log message_priority colour codes (extends the existing 134/135/137 set).
+#macro MSG_COLOR_WHITE 140
+#macro MSG_COLOR_LIGHTGREEN 141
#macro STR_ANY_POWER_ARMOUR "Any Power Armour"
#macro STR_ANY_TERMINATOR_ARMOUR "Any Terminator Armour"
@@ -73,12 +226,14 @@ enum eROLE {
ASSAULT = 10,
ANCIENT = 11,
SCOUT = 12,
+ BIKER = 13,
CHAPLAIN = 14,
APOTHECARY = 15,
TECHMARINE = 16,
LIBRARIAN = 17,
SERGEANT = 18,
VETERANSERGEANT = 19,
+ ATTACK_BIKER = 20,
LANDRAIDER = 50,
RHINO = 51,
PREDATOR = 52,
@@ -126,7 +281,7 @@ enum eINQUISITION_MISSION {
TYRANID_ORGANISM,
ETHEREAL,
DEMON_WORLD,
- RANDOM = 100
+ RANDOM = 100,
}
enum eEVENT {
@@ -169,3 +324,9 @@ enum eIN_GAME_MENU_EFFECT {
BACK_FROM_SETTINGS = 25,
CLOSE_SAVELOAD = 30
}
+
+// Overkill spill from a wiped enemy formation only reaches a formation standing
+// directly behind it (touching columns, 10px apart; 15 tolerates float jitter). An
+// air gap stops the spill: neither hammer blows nor the torrent of fire leap across
+// open ground to a formation two rows back.
+#macro OVERKILL_SPILL_MAX_GAP 15
diff --git a/scripts/scr_DataSlate/scr_DataSlate.gml b/scripts/scr_DataSlate/scr_DataSlate.gml
index 4b94a6a912..ad47be75ea 100644
--- a/scripts/scr_DataSlate/scr_DataSlate.gml
+++ b/scripts/scr_DataSlate/scr_DataSlate.gml
@@ -543,3 +543,33 @@ function ShutterButton() constructor {
}
};
}
+
+/// @desc Short hover tooltip for the planet action shutter buttons (obj_star_select).
+/// ShutterButton.draw_shutter has always rendered its tooltip field on hover; nothing
+/// ever set one. Unknown actions return "" (no tooltip), so new buttons are safe.
+function planet_action_tooltip(_action) {
+ switch (_action) {
+ case "Attack":
+ return "Launch a ground assault to destroy enemy forces on this world. Each ship carrying troops can support up to 2 assaults per turn; planetside forces fight without ship support.";
+ case "Raid":
+ return "A limited strike that chips away at enemy forces without committing to a full battle. Requires a fleet that has spent at most one action this turn.";
+ case "Bombard":
+ return "Orbital bombardment: devastating to enemy forces, the population, and the world itself. Must be the fleet's first action of the turn.";
+ case "Purge":
+ return "Cleanse this world's population of heresy and xenos taint. Opens a choice of methods, from orbital purging to assassinating the Planetary Governor. Ends the fleet's actions for the turn.";
+ case "Cyclonic Torpedo":
+ return "Exterminatus. Destroys the planet and everything on it, permanently. There is no taking this back.";
+ case "Deploy Guard":
+ return "Land every Guardsman aboard your orbiting ships onto this world. They join its local forces and can fight in battles here without ship support.";
+ case "+Recruiting":
+ return "Designate this world as a recruiting world, letting your chapter draw aspirants from its population. Requires an available recruiting-world right and a world not at war.";
+ case "Build":
+ return "Begin construction of a chapter facility on this world.";
+ case "Base":
+ case "Arsenal":
+ case "Gene-Vault":
+ return "Manage your chapter facilities on this world.";
+ default:
+ return "";
+ }
+}
diff --git a/scripts/scr_PlanetData/scr_PlanetData.gml b/scripts/scr_PlanetData/scr_PlanetData.gml
index 053569d7b4..485f7e891d 100644
--- a/scripts/scr_PlanetData/scr_PlanetData.gml
+++ b/scripts/scr_PlanetData/scr_PlanetData.gml
@@ -30,6 +30,7 @@ function PlanetData(_planet, _system) constructor {
is_hulk = system.space_hulk;
x = system.x;
y = system.y;
+ player_disposition = system.dispo[planet];
planet_type = system.p_type[planet];
operatives = system.p_operatives[planet];
pdf = system.p_pdf[planet];
@@ -73,6 +74,10 @@ function PlanetData(_planet, _system) constructor {
heretic_timer = system.p_hurssy_time[planet];
+ secret_corruption = system.p_heresy_secret[planet];
+
+ corruption = system.p_heresy[planet];
+
population_influences = system.p_influence[planet];
raided_this_turn = system.p_raided[planet];
@@ -97,7 +102,6 @@ function PlanetData(_planet, _system) constructor {
system.dispo[planet] = 100;
}
- player_disposition = system.dispo[planet];
garrisons = system.system_garrison[planet];
if (garrisons == 0) {
garrisons = system.get_garrison(planet);
@@ -475,21 +479,30 @@ function PlanetData(_planet, _system) constructor {
var pip = instance_create(0, 0, obj_popup);
pip.title = "Planetary Governor Assassinated";
- pip._text = txt;
+ pip.text = txt;
pip.planet = planet;
pip.p_data = self;
var options = [
{
str1: "Allow the official successor to become Planetary Governor.",
choice_func: allow_governor_successor,
+ hover: function() {
+ tooltip_draw("The lawful heir takes power. Relations usually carry over unchanged, with a small chance the new governor arrives loving or loathing your chapter. No risk of discovery.");
+ },
},
{
str1: "Ensure that a sympathetic successor will be the one to rule.",
choice_func: install_sympathetic_successor,
+ hover: function() {
+ tooltip_draw("A governor friendly to your chapter is quietly maneuvered into power. Disposition rises to a strong ally's. Small risk of Inquisition discovery, and discovered manipulation angers the Imperium, Inquisition, and Ecclesiarchy, sometimes years later.");
+ },
},
{
str1: "Remove all successors and install a loyal Chapter Serf.",
choice_func: install_chapter_surf,
+ hover: function() {
+ tooltip_draw("The line of succession is erased and your serf takes the throne: the world comes under your chapter's direct control with maximum disposition (worlds originally of the Mechanicus keep their owner). The boldest option and the likeliest to be discovered by the Inquisition.");
+ },
},
];
pip.add_option(options);
@@ -1346,6 +1359,7 @@ function PlanetData(_planet, _system) constructor {
var presence_text = "";
var faction_names = [
"Adeptas",
+ "Eldar",
"Orks",
"Tau",
"Tyranids",
@@ -1356,9 +1370,16 @@ function PlanetData(_planet, _system) constructor {
];
var faction_ids = [
"p_sisters",
+ "p_eldar",
"p_orks",
"p_tau",
"p_tyranids",
+ // "Chaos" must read p_chaos and "Traitors" p_traitors. These two were
+ // crossed (a merge casualty; upstream is aligned), so the panel showed
+ // Traitor Guard strength under the "Chaos" label and vice versa:
+ // bombarding the "Chaos" target then annihilated the real p_chaos while
+ // the mislabeled traitor 6 sat untouched on the panel, reported as
+ // "bombardment fails to damage chaos forces".
"p_chaos",
"p_traitors",
"p_demons",
@@ -1520,6 +1541,8 @@ function PlanetData(_planet, _system) constructor {
strength = strength > 2 ? 2 : 0;
if (system.p_chaos[planet] > 0) {
+ // Was max(0, p_traitors - 1): read the wrong array, so a world with more
+ // traitors than chaos had its chaos force RAISED by navy bombardment.
system.p_chaos[planet] = max(0, system.p_chaos[planet] - 1);
} else if (system.p_traitors[planet] > 0) {
system.p_traitors[planet] = max(0, system.p_traitors[planet] - 2);
@@ -1676,6 +1699,17 @@ function PlanetData(_planet, _system) constructor {
_col_button.draw(array_length(obj_star_select.potential_donors));
+ // Recruit Guard sits directly below Request Colonists (same column, one
+ // row down). Player worlds only, since you can only mobilise your own PDF.
+ // Clicking is gated on the PDF holding at least 1000 (here) and on having
+ // the 50 requisition (the PurchaseButton's own check), so a click can never
+ // spend the 50 without actually raising the 1000.
+ if (current_owner == eFACTION.PLAYER) {
+ var _guard_button = obj_star_select.guard_recruit_button;
+ _guard_button.update({x1: xx + 35, y1: _half_way + spacing_y, allow_click: true});
+ _guard_button.draw(pdf >= 1000);
+ }
+
var _recruit_button = obj_star_select.recruiting_button;
_recruit_button.update({x1: xx + (spacing_x * 2) + 15, y1: _half_way, allow_click: true});
diff --git a/scripts/scr_add_man/scr_add_man.gml b/scripts/scr_add_man/scr_add_man.gml
index 828e951a0a..a798f53cb3 100644
--- a/scripts/scr_add_man/scr_add_man.gml
+++ b/scripts/scr_add_man/scr_add_man.gml
@@ -10,7 +10,11 @@ function scr_add_man(man_role, target_company, spawn_exp, spawn_name, corruption
"Sister Hospitaler",
"Ranger",
"Ork Sniper",
- "Flash Git"
+ "Flash Git",
+ "Guardsman",
+ "Guard Squad",
+ "Guard Sergeant",
+ "Heavy Weapons Team"
];
var _gear = {};
var _company_slot = 0;
@@ -26,6 +30,26 @@ function scr_add_man(man_role, target_company, spawn_exp, spawn_name, corruption
// TODO: Implement logic for Auxiliary Soldier (Race 2.5, Renegade stats)
switch (man_role) {
+ case "Guardsman":
+ spawn_exp = 10;
+ obj_ini.race[target_company][_company_slot] = eFACTION.IMPERIUM;
+ _unit = new TTRPG_stats("imperial_guard", target_company, _company_slot, "guardsman");
+ break;
+ case "Guard Squad":
+ spawn_exp = 10;
+ obj_ini.race[target_company][_company_slot] = eFACTION.IMPERIUM;
+ _unit = new TTRPG_stats("imperial_guard", target_company, _company_slot, "guard_squad");
+ break;
+ case "Guard Sergeant":
+ spawn_exp = 10;
+ obj_ini.race[target_company][_company_slot] = eFACTION.IMPERIUM;
+ _unit = new TTRPG_stats("imperial_guard", target_company, _company_slot, "guard_sergeant");
+ break;
+ case "Heavy Weapons Team":
+ spawn_exp = 10;
+ obj_ini.race[target_company][_company_slot] = eFACTION.IMPERIUM;
+ _unit = new TTRPG_stats("imperial_guard", target_company, _company_slot, "heavy_weapons_team");
+ break;
case "Skitarii":
spawn_exp = 10;
obj_ini.race[target_company][_company_slot] = 3;
@@ -101,6 +125,22 @@ function scr_add_man(man_role, target_company, spawn_exp, spawn_name, corruption
case "Sister Hospitaler":
obj_ini.name[target_company][_company_slot] = global.name_generator.GenerateFromSet("imperial_female");
break;
+
+ case "Guardsman":
+ obj_ini.name[target_company][_company_slot] = global.name_generator.GenerateFromSet("imperial_male");
+ break;
+
+ case "Guard Squad":
+ obj_ini.name[target_company][_company_slot] = global.name_generator.GenerateFromSet("imperial_male");
+ break;
+
+ case "Guard Sergeant":
+ obj_ini.name[target_company][_company_slot] = global.name_generator.GenerateFromSet("imperial_male");
+ break;
+
+ case "Heavy Weapons Team":
+ obj_ini.name[target_company][_company_slot] = global.name_generator.GenerateFromSet("imperial_male");
+ break;
}
if (!array_contains(non_marine_roles, man_role)) {
@@ -125,8 +165,14 @@ function scr_add_man(man_role, target_company, spawn_exp, spawn_name, corruption
_unit.add_exp(spawn_exp);
_unit.allocate_unit_to_fresh_spawn(home_spot);
_unit.update_role(man_role);
- with (obj_ini) {
- scr_company_order(target_company);
+ // Re-sorting the whole company after every spawn is O(n) per call, which makes a
+ // bulk spawn O(n^2) and freezes at high counts. Callers doing a batch can pass
+ // other_data.skip_company_order and run scr_company_order once when finished.
+ var _skip_order = is_struct(other_data) && variable_struct_exists(other_data, "skip_company_order") && other_data.skip_company_order;
+ if (!_skip_order) {
+ with (obj_ini) {
+ scr_company_order(target_company);
+ }
}
_unit.update_health(_unit.max_health());
return _unit;
diff --git a/scripts/scr_add_vehicle/scr_add_vehicle.gml b/scripts/scr_add_vehicle/scr_add_vehicle.gml
index 832b3e9a43..77cc7b5e71 100644
--- a/scripts/scr_add_vehicle/scr_add_vehicle.gml
+++ b/scripts/scr_add_vehicle/scr_add_vehicle.gml
@@ -133,6 +133,22 @@ function scr_add_vehicle(vehicle_type, target_company, otherdata = {}, weapon1 =
obj_ini.veh_wep2[target_company][good] = "";
obj_ini.veh_upgrade[target_company][good] = "";
}
+ if (vehicle_type == "Chimera") {
+ obj_ini.veh_wep1[target_company][good] = "Multi-Laser";
+ obj_ini.veh_wep2[target_company][good] = "Heavy Bolter";
+ }
+ if (vehicle_type == "Leman Russ") {
+ // Battle Cannon turret (anti-armour and anti-infantry blast) with a hull
+ // Heavy Bolter, the standard Astra Militarum main battle tank loadout.
+ obj_ini.veh_wep1[target_company][good] = "Battle Cannon";
+ obj_ini.veh_wep2[target_company][good] = "Heavy Bolter";
+ }
+ if (vehicle_type == "Basilisk") {
+ // Earthshaker Cannon (long-range artillery blast) with a Storm Bolter for
+ // close defence, mirroring the enemy Basilisk loadout.
+ obj_ini.veh_wep1[target_company][good] = "Earthshaker Cannon";
+ obj_ini.veh_wep2[target_company][good] = "Storm Bolter";
+ }
}
obj_ini.veh_hp[target_company][good] = 100;
diff --git a/scripts/scr_after_combat/scr_after_combat.gml b/scripts/scr_after_combat/scr_after_combat.gml
index b4668a3b90..cf4b78b28b 100644
--- a/scripts/scr_after_combat/scr_after_combat.gml
+++ b/scripts/scr_after_combat/scr_after_combat.gml
@@ -70,6 +70,8 @@ function add_vehicles_to_recovery() {
"Whirlwind": 4,
"Rhino": 3,
"Land Speeder": 3,
+ "Leman Russ": 3,
+ "Chimera": 2,
"Bike": 1,
};
@@ -240,7 +242,7 @@ function after_battle_part1() {
marine_dead[i] = 1;
marine_hp[i] = -50;
}
- if ((veh_type[i] != "") && (obj_ncombat.defeat == 1)) {
+ if ((i < array_length(veh_type)) && (veh_type[i] != "") && (obj_ncombat.defeat == 1)) {
veh_dead[i] = 1;
veh_hp[i] = -200;
}
diff --git a/scripts/scr_bomb_world/scr_bomb_world.gml b/scripts/scr_bomb_world/scr_bomb_world.gml
index ceaf568f1e..9d8a4435df 100644
--- a/scripts/scr_bomb_world/scr_bomb_world.gml
+++ b/scripts/scr_bomb_world/scr_bomb_world.gml
@@ -1,3 +1,98 @@
+// ---------------------------------------------------------------------------------
+// Bombardment tuning. These govern how much of a world a single bombardment kills.
+// Casualties are a share of the world's population CAPACITY (its max population) so a
+// world is depopulated down to zero over repeated strikes rather than a fixed absolute
+// figure that wipes small worlds in one shot. The share scales with how much fleet
+// firepower is committed and is shaped by world type, enemy size, how far the enemy has
+// overrun the world, and the world's fortifications. Bombardment never turns a world
+// Dead; the world keeps its type at zero population and can regrow once its enemies are
+// cleared. All values are macros so balance can be tuned without touching logic.
+// ---------------------------------------------------------------------------------
+
+// Share of world capacity killed per point of bombard score, before the factor
+// multipliers. Bombard score is Battle Barge 3, Strike Cruiser 1, escorts 0, so more
+// ships (and bigger ships) do more population damage. Zero ships do nothing.
+#macro BOMBARD_POP_PER_POWER 0.06
+// Hard ceiling on a single bombard's share of capacity. At 1.0 an overwhelming fleet can
+// depopulate a world in one strike; a lone ship takes many.
+#macro BOMBARD_MAX_FRACTION 1.0
+// Each fortification level (0 none .. 5 fully fortified) cuts population casualties by
+// this much, down to the floor, since bunkers and shelters protect civilians.
+#macro BOMBARD_DEFENSE_REDUCTION 0.15
+#macro BOMBARD_DEFENSE_FLOOR 0.20
+
+/// @desc World-type multiplier on bombardment population casualties. Dense hive cities
+/// burn hardest; spread-out agri and feudal worlds lose fewer people per bombardment.
+function bombard_planet_type_pop_mult(_type) {
+ switch (_type) {
+ case "Hive": return 1.5;
+ case "Desert": return 0.8;
+ case "Agri": return 0.6;
+ case "Feudal": return 0.5;
+ }
+ return 1.0; // Temperate / civilised and everything else
+}
+
+/// @desc Enemy-presence multiplier on bombardment population casualties. A larger, more
+/// deeply embedded enemy (strength tier 0 none .. 6 Overwhelming) means the bombardment
+/// has to hit more of the world to root it out, so more civilians die alongside it.
+function bombard_presence_pop_mult(_tier) {
+ var _mults = [0.6, 0.7, 0.85, 1.0, 1.2, 1.4, 1.6];
+ return _mults[clamp(_tier, 0, 6)];
+}
+
+/// @desc Ratio multiplier: how far the enemy has overrun the world. A large enemy on a
+/// lightly populated world drives collateral up; a small enemy on a teeming world keeps
+/// it down. Compares the enemy strength tier to a coarse population bucket.
+function bombard_ratio_pop_mult(_planet, _tier) {
+ var _pop = _planet.population_as_small();
+ var _pop_tier = 0;
+ if (_pop >= 1000000000) {
+ _pop_tier = 5;
+ } else if (_pop >= 100000000) {
+ _pop_tier = 4;
+ } else if (_pop >= 10000000) {
+ _pop_tier = 3;
+ } else if (_pop >= 1000000) {
+ _pop_tier = 2;
+ } else if (_pop >= 100000) {
+ _pop_tier = 1;
+ }
+ var _diff = clamp(_tier - _pop_tier, -3, 3);
+ return clamp(1 + _diff * 0.15, 0.6, 1.5);
+}
+
+/// @desc Fortification multiplier on bombardment population casualties. Planet defenses
+/// (fortification_level 0 none .. 5 fully fortified) shelter civilians in bunkers, so a
+/// well-defended world loses far fewer people to orbital fire.
+function bombard_defense_pop_mult(_planet) {
+ var _level = clamp(_planet.fortification_level, 0, 5);
+ return clamp(1 - _level * BOMBARD_DEFENSE_REDUCTION, BOMBARD_DEFENSE_FLOOR, 1);
+}
+
+/// @desc Share of a world's population CAPACITY killed by one bombardment. Driven by the
+/// committed bombard score (ship count and type), then shaped by world type, enemy size,
+/// enemy-to-population ratio, and fortifications, capped at BOMBARD_MAX_FRACTION.
+function bombard_pop_kill_fraction(_planet, _enemy_tier, _bomb_power) {
+ var _frac = _bomb_power * BOMBARD_POP_PER_POWER;
+ _frac *= bombard_planet_type_pop_mult(_planet.planet_type);
+ _frac *= bombard_presence_pop_mult(_enemy_tier);
+ _frac *= bombard_ratio_pop_mult(_planet, _enemy_tier);
+ _frac *= bombard_defense_pop_mult(_planet);
+ return clamp(_frac, 0, BOMBARD_MAX_FRACTION);
+}
+
+/// @desc Population killed by one bombardment, in the planet's own population scale. A
+/// bombard removes a share of the world's capacity (max population) that grows with the
+/// fleet firepower committed, so a small strike chips away while an overwhelming fleet can
+/// depopulate a world in one blow, but never more than the population actually present.
+/// Used by both scr_bomb_world and the dialog preview so estimate and outcome agree.
+function bombard_population_kill(_planet, _enemy_tier, _bomb_power) {
+ var _capacity = max(_planet.max_population, _planet.population);
+ var _kill = _capacity * bombard_pop_kill_fraction(_planet, _enemy_tier, _bomb_power);
+ return min(_planet.population, _kill);
+}
+
function scr_bomb_world(bombard_target_faction, bombard_ment_power, target_strength) {
var pop_after = 0, reduced_bombard_score = 0, strength_reduction = 0, txt2 = "", txt3 = "", txt4 = "", max_kill, overkill, roll, kill;
@@ -15,7 +110,7 @@ function scr_bomb_world(bombard_target_faction, bombard_ment_power, target_stren
}
txt1 += $" annihilation upon {name()}. Even from space the explosions can be seen, {choose("tearing ground", "hammering", "battering", "thundering")} across the planet's surface.";
- kill = population_small_conversion(0.15);
+ kill = bombard_population_kill(self, target_strength, bombard_ment_power);
var pop_before = population;
@@ -104,9 +199,15 @@ function scr_bomb_world(bombard_target_faction, bombard_ment_power, target_stren
}
break;
case 11:
+ // Traitor Guard: renegade IG, "competent protection" tier like loyalist IG
+ // and standard chaos forces per the bombard_protect_scores comment below.
txt2 = "##The Traitor forces are suitably fortified; ";
- bombard_protection = 3;
+ bombard_protection = 2;
break;
+ // case 12:
+ // txt2="##The Daemonic forces are incredibly difficult to bombard; ";
+ // bombard_protection=4;
+ // break;
case 13:
txt2 = "##The Necron forces are incredibly difficult to bombard; ";
bombard_protection = 4; // They are a hi-tech faction, so bombing them should be difficult
@@ -166,19 +267,21 @@ function scr_bomb_world(bombard_target_faction, bombard_ment_power, target_stren
// if (rel>0 && rel<=20 && (target_strength-strength_reduction)>0){
// txt2+=" minor losses from the bombardment, decreasing "+string(strength_reduction)+" stages.";
// ?
+ // Only describe losses when there was a reduction; otherwise the "no losses" line
+ // above already covered it and this used to append a second, contradictory
+ // "some losses ... decreased by 0" sentence.
if (strength_reduction > 0) {
if ((target_strength - strength_reduction) <= 0) {
txt2 += " total annihilation from the bombardment and are wiped clean from the planet.";
} else {
var _losses_text = "";
- var _damage_pct = 100 - rel;
- if (_damage_pct > 0 && _damage_pct <= 20) {
+ if (rel > 0 && rel <= 20) {
_losses_text = "minor losses";
- } else if (_damage_pct > 20 && _damage_pct <= 40) {
+ } else if (rel > 20 && rel <= 40) {
_losses_text = "moderate losses";
- } else if (_damage_pct > 40 && _damage_pct <= 60) {
+ } else if (rel > 40 && rel <= 60) {
_losses_text = "heavy losses";
- } else if (_damage_pct > 60) {
+ } else if (rel > 60 && (target_strength - strength_reduction) > 0) {
_losses_text = "devastating losses";
} else {
_losses_text = "some losses";
@@ -253,7 +356,12 @@ function scr_bomb_world(bombard_target_faction, bombard_ment_power, target_stren
case 9:
system.p_tyranids[planet] -= strength_reduction;
break;
- case 10:
+ case 10: {
+ // planet_forces[eFACTION.CHAOS] (the strength this bombardment was aimed
+ // and reported against) is p_chaos + p_demons, but this wrote p_traitors,
+ // the eFACTION.HERETICS force. The bombarded Chaos force never shrank (and
+ // p_traitors could go negative) while the report could claim total
+ // annihilation. Reduce p_chaos first, spill any remainder into p_demons.
var _chaos_cut = min(system.p_chaos[planet], strength_reduction);
system.p_chaos[planet] -= _chaos_cut;
var _demon_spill = strength_reduction - _chaos_cut;
@@ -261,9 +369,13 @@ function scr_bomb_world(bombard_target_faction, bombard_ment_power, target_stren
system.p_demons[planet] = max(0, system.p_demons[planet] - _demon_spill);
}
break;
+ }
case 11:
system.p_traitors[planet] = max(0, system.p_traitors[planet] - strength_reduction);
break;
+ // case 12:
+ // system.p_demons[planet]-=strength_reduction;
+ // break;
case 13:
system.p_necrons[planet] -= strength_reduction;
break;
@@ -395,9 +507,109 @@ function scr_bomb_world(bombard_target_faction, bombard_ment_power, target_stren
pip.text = txt1 + txt2 + txt3;
}
+ // Fleet movement lock preserved (bombarding ends the fleet's turn for movement).
+ // Every ship the player selected spends its whole support allowance, so each ship
+ // bombards at most once per turn. Previously only the first fresh ship was spent
+ // regardless of how many were selected, so the remaining selected ships stayed
+ // fresh and could be re-selected to bombard the same world again on the next
+ // Confirm, letting one fleet fire several times over.
obj_bomb_select.sh_target.acted = 5;
+ with (obj_bomb_select) {
+ for (var _bspend = 0; _bspend < array_length(ship_ide); _bspend++) {
+ if ((ship_all[_bspend] == 1) && (ship_ide[_bspend] >= 0)) {
+ ship_bombard_spend(ship_ide[_bspend]);
+ }
+ }
+ }
with (obj_bomb_select) {
instance_destroy();
}
// show_message("Pop: "+string(pop_before)+" -> "+string(pop_after)+"#killed: "+string(kill)+"#Heresy: "+string(heres_before)+" -> "+string(heres_after));
}
+
+/// @desc Rough, bracketed preview of a bombardment's effect for the selection dialog,
+/// so the player can see roughly what a bombard will do before committing. Mirrors the
+/// kill and strength_reduction math in scr_bomb_world above. Returns a struct with two
+/// labels, population and enemy, each one of None / Negligible / Low / Medium / High /
+/// Massive. It is an estimate: it ignores the sub-1 random reduction roll and the Ork
+/// Stronghold / Daemon-world specials.
+function bombard_effect_estimate(_planet, _target_faction, _bomb_power, _target_strength) {
+ // Population: scr_bomb_world kills a fixed slice (0.15) of the planet's own scale,
+ // subtracted straight from population, so it does not scale with ship count. On a
+ // small world that fixed slice dwarfs the whole population and wipes it out, which
+ // is the surprise this preview exists to warn about.
+ var _pop_pct = 0;
+ var _pop_before = _planet.population;
+ if ((_pop_before > 0) && (_planet.planet_type != "Space Hulk")) {
+ var _kill = bombard_population_kill(_planet, _target_strength, _bomb_power);
+ _pop_pct = (min(_pop_before, _kill) / _pop_before) * 100;
+ }
+
+ // Enemy: same reduction math as scr_bomb_world. The reduced score, scaled by the
+ // target's bombard protection, is how many strength stages come off.
+ var _en_pct = 0;
+ if (_target_strength > 0) {
+ var _reduced = _bomb_power / 3;
+ var _protection = clamp(bombard_protection_estimate(_target_faction), 0, 4);
+ var _protect_scores = [4, 0.9, 0.75, 0.5, 0.34];
+ var _stages = floor(_reduced * _protect_scores[_protection]);
+ _en_pct = (min(_stages, _target_strength) / _target_strength) * 100;
+ }
+
+ return {
+ population: bombard_effect_bracket(_pop_pct),
+ enemy: bombard_effect_bracket(_en_pct),
+ };
+}
+
+/// @desc Base bombard protection tier per target faction, matching the switch in
+/// scr_bomb_world (Ork Stronghold and Daemon-world bonuses are left out of the estimate).
+function bombard_protection_estimate(_faction) {
+ switch (_faction) {
+ case 2: return 2;
+ case 2.5: return 1;
+ case 3: return 3;
+ case 5: return 1;
+ case 6: return 4;
+ case 7: return 2;
+ case 8: return 3;
+ case 9: return 0;
+ case 10: return 2;
+ case 11: return 2;
+ case 13: return 4;
+ }
+ return 1;
+}
+
+/// @desc Map a 0-100 percentage to a coarse severity label for the bombard preview.
+function bombard_effect_bracket(_pct) {
+ if (_pct <= 0) {
+ return "None";
+ }
+ if (_pct < 5) {
+ return "Negligible";
+ }
+ if (_pct < 20) {
+ return "Low";
+ }
+ if (_pct < 45) {
+ return "Medium";
+ }
+ if (_pct < 80) {
+ return "High";
+ }
+ return "Massive";
+}
+
+/// @desc Colour for a bombard severity label so the worst outcomes read at a glance.
+function bombard_effect_bracket_color(_label) {
+ switch (_label) {
+ case "None": return c_gray;
+ case "Negligible": return #34bc75;
+ case "Low": return #34bc75;
+ case "Medium": return c_yellow;
+ case "High": return c_orange;
+ case "Massive": return c_red;
+ }
+ return c_white;
+}
diff --git a/scripts/scr_cheatcode/scr_cheatcode.gml b/scripts/scr_cheatcode/scr_cheatcode.gml
index b16dfa2df1..a198d084da 100644
--- a/scripts/scr_cheatcode/scr_cheatcode.gml
+++ b/scripts/scr_cheatcode/scr_cheatcode.gml
@@ -103,6 +103,49 @@ function scr_cheatcode(argument0) {
scr_add_man("Flash Git", 0, "", "", 0, true, "default");
}
break;
+ case "guardsman":
+ repeat (real(cheat_arguments[0])) {
+ scr_add_man("Guardsman", 0, "", "", 0, true, "home_planet", {skip_company_order: true});
+ }
+ with (obj_ini) {
+ scr_company_order(0);
+ }
+ break;
+ case "guardsquad":
+ repeat (real(cheat_arguments[0])) {
+ scr_add_man("Guard Squad", 0, "", "", 0, true, "home_planet", {skip_company_order: true});
+ }
+ with (obj_ini) {
+ scr_company_order(0);
+ }
+ break;
+ case "veteranguard":
+ // Promote every basic Guardsman to Veteran Guard (role swap, veteran
+ // stat buff, Hellgun equip unlocked). promote_auxilia_to_veteran was
+ // built but never wired to anything, so this is the test trigger; the
+ // Auxilia "Promote All" button can call the same function.
+ var _vet_promoted = promote_auxilia_to_veteran();
+ with (obj_ini) {
+ scr_company_order(0);
+ }
+ scr_popup("Veteran Guard", $"Promoted {_vet_promoted} Guardsmen to Veteran Guard.", "");
+ break;
+ case "guardxp":
+ // Grant experience to every basic Guardsman for testing the veterancy
+ // gate without fighting several battles. "guardxp" alone grants exactly
+ // enough to promote (GUARD_VETERAN_XP); "guardxp N" grants N. Follow with
+ // the veteranguard cheat (or the Promote All button) to promote the now
+ // eligible troopers.
+ var _xp_amt = real(cheat_arguments[0]);
+ if (_xp_amt <= 1) {
+ _xp_amt = GUARD_VETERAN_XP;
+ }
+ var _grd_all = collect_role_group("all", "", false, { roles: ["Guardsman"] });
+ for (var _gx = 0; _gx < array_length(_grd_all); _gx++) {
+ _grd_all[_gx].add_experience(_xp_amt);
+ }
+ scr_popup("Guard XP", $"Granted {_xp_amt} experience to {array_length(_grd_all)} Guardsmen.", "");
+ break;
case "chaosfleetspawn":
spawn_chaos_warlord();
break;
@@ -356,8 +399,28 @@ function scr_cheatcode(argument0) {
array_insert(obj_controller.recruit_exp, i, 20);
array_insert(obj_controller.recruit_data, i, {});
array_insert(obj_controller.recruit_name, i, global.name_generator.GenerateFromSet("space_marine"));
- scr_alert("green", "recruitment", (string(obj_controller.recruit_name[i]) + "has started training."), 0, 0);
}
+ // One summary alert instead of one per recruit. Firing hundreds of
+ // alerts in a single frame (recruit 500) floods the notification
+ // system and hangs the game.
+ scr_alert("green", "recruitment", (string(real(cheat_arguments[0])) + " recruits have started training."), 0, 0);
+ break;
+ case "spawnmarines":
+ // Spawn N fully-formed marines straight into a company (1st by
+ // default), bypassing the training queue, for testing large rosters.
+ // Usage: "spawnmarines" (500 into company 1), "spawnmarines 800", or
+ // "spawnmarines 500 3" for company 3. Uses the same default recruit
+ // role the game grants on recruitment completion, and posts one
+ // summary alert rather than one per marine, so a big count does not
+ // flood the notification system the way the old recruit loop did.
+ var _spawn_count = (array_length(cheat_arguments) > 0) ? real(cheat_arguments[0]) : 500;
+ var _spawn_company = (array_length(cheat_arguments) > 1) ? real(cheat_arguments[1]) : 1;
+ with (obj_controller) {
+ repeat (_spawn_count) {
+ scr_add_man(obj_ini.role[100][12], _spawn_company, 20, global.name_generator.GenerateFromSet("space_marine"), 0, false, "default", {});
+ }
+ }
+ scr_alert("green", "recruitment", (string(_spawn_count) + " marines spawned in company " + string(_spawn_company) + "."), 0, 0);
break;
case "shiplostevent":
loose_ship_to_warp_event();
@@ -406,16 +469,12 @@ function draw_planet_debug_options() {
if (debug) {
debug_slate.inside_method = function() {
debug_options.draw();
- switch(debug_options.current_selection){
- case 0:
- draw_planet_debug_forces();
- break;
- case 1:
- draw_planet_debug_problems();
- break;
- case 2:
- draw_planet_debug_features();
- break;
+ if (debug_options.current_selection == 0) {
+ draw_planet_debug_forces();
+ } else if (debug_options.current_selection == 1) {
+ draw_planet_debug_problems();
+ } else if (debug_options.current_selection == 2) {
+ draw_planet_debug_features();
}
};
debug_slate.draw();
@@ -496,7 +555,6 @@ function draw_planet_debug_problems() {
if (scr_hit(38, _y, 337, _y + 20)) {
tooltip_draw(mission_name_key(_keys[i]));
if (mouse_button_clicked()) {
- var _p_data = obj_star_select.p_data;
switch (_keys[i]) {
case "inquisitor":
mission_inquistion_hunt_inquisitor(target.id);
@@ -513,12 +571,7 @@ function draw_planet_debug_problems() {
case "mech_bionics":
spawn_mechanicus_mission("mech_bionics");
break;
- case "succession":
- _p_data.init_war_of_succession();
- break;
- case "fallen":
- _p_data.init_fallen_marines();
- break
+
default:
scr_popup("error", "no specific debug action created please consider helping to make one", "");
break;
diff --git a/scripts/scr_clean/scr_clean.gml b/scripts/scr_clean/scr_clean.gml
index 0182271ecf..62f1c90a70 100644
--- a/scripts/scr_clean/scr_clean.gml
+++ b/scripts/scr_clean/scr_clean.gml
@@ -128,9 +128,52 @@ function check_dead_marines(unit_struct, unit_index) {
return unit_lost;
}
+/// @desc True if a unit role is Guard auxilia rather than Astartes. Matches the guard
+/// role grouping in scr_marine_struct. Used to pick the cover-save rate (Guardsmen use
+/// cover well; bulky Astartes do not).
+function unit_role_is_guard(_role) {
+ return (_role == "Guardsman")
+ || (_role == "Guard Sergeant")
+ || (_role == "Veteran Guard")
+ || (_role == "Heavy Weapons Team");
+}
+
+/// @desc Penetration (critical-hit) chance for a capable anti-tank shot, keyed to the
+/// vehicle's cost tier rather than the weapon's AP. Expensive war machines present almost
+/// no weak spot, so only a rare lucky hit to the right area gets through: a Land Raider
+/// (value 500) sits at 5%, cheaper hulls climb toward the light APCs. This is what stops a
+/// handful of Rokkits from deleting a Land Raider line: an arp-4 shot ignores armour and
+/// one-shots on a hit, so the CHANCE is the gate, not the damage. Values track
+/// vehicles.json costs (Land Raider 500, Predator 240, Whirlwind 180, Rhino/Land Speeder
+/// 120) and are the balance knob.
+function vehicle_penetration_chance(_veh_type) {
+ switch (_veh_type) {
+ case "Land Raider": return 0.05; // value 500, tankiest hull
+ case "Leman Russ": return 0.10; // Guard heavy battle tank
+ case "Predator": return 0.15; // value 240
+ case "Whirlwind": return 0.22; // value 180, armoured artillery
+ case "Basilisk": return 0.28; // Guard artillery, thin armour
+ case "Chimera": return 0.40; // Guard APC
+ case "Rhino": return 0.45; // value 120, light APC
+ case "Land Speeder": return 0.55; // value 120, fast and lightly armoured
+ }
+ return 0.35; // any unlisted vehicle
+}
+
+/// @desc Anti-tank penetration roll for one capable shot. The caller only invokes this
+/// once the shot would deal damage, so this decides whether it actually gets through.
+/// Chance is the vehicle's cost-tiered weak-spot chance (see vehicle_penetration_chance),
+/// deliberately independent of the weapon's AP so a big AP volley cannot brute-force a
+/// heavy hull. Returns a damage multiplier: binary today (1 penetrate, 0 bounce); a future
+/// tiered result (little / medium / severe, or a disable band) can come from the same roll
+/// without touching the caller.
+function at_penetration_multiplier(_veh_type) {
+ return (random(1) < vehicle_penetration_chance(_veh_type)) ? 1 : 0;
+}
+
/// @self Id.Instance.obj_pnunit
/// @param {Id.Instance.obj_pnunit} target_object
-function scr_clean(target_object, target_is_infantry, hostile_shots, hostile_damage, hostile_weapon, hostile_range, hostile_splash, weapon_index_position) {
+function scr_clean(target_object, target_is_infantry, hostile_shots, hostile_damage, hostile_weapon, hostile_range, hostile_splash, weapon_index_position, hostile_arp = 0, hostile_dist = 0) {
// Converts enemy scr_shoot damage into player marine or vehicle casualties.
//
// Parameters:
@@ -152,31 +195,50 @@ function scr_clean(target_object, target_is_infantry, hostile_shots, hostile_dam
"units_lost": 0,
"unit_type": "",
"hits": 0,
+ "severity": 0,
+ "is_vehicle": false,
};
+ // Scope the casualty breakdown to THIS attack. lost[]/lost_num[] are the column's
+ // per-attack scratch tally that scr_flavor2 reads to build its "X Foo lost" text.
+ // scr_flavor2 clears them at its end, but fork-added early-exits (suppressed empty or
+ // zero-shot attacks, a destroyed wall) can return before that reset, leaving a previous
+ // attack's casualties in place. A later attack that inflicts nothing of its own (e.g.
+ // autoguns pinging off a Predator's AC40 armour for zero damage) would then read and
+ // mis-report those stale kills as "8 Autoguns strike at Predator. 4 Predators lost."
+ // Clearing here guarantees the breakdown reflects only what this attack actually kills.
+ lost = [];
+ lost_num = [];
+
// ### Vehicle Damage Processing ###
if (!target_is_infantry && veh > 0) {
- damage_vehicles(damage_data, hostile_shots, hostile_damage, weapon_index_position);
+ damage_vehicles(damage_data, hostile_shots, hostile_damage, weapon_index_position, hostile_arp);
}
// ### Marine + Dreadnought Processing ###
if (target_is_infantry && (men + dreads > 0)) {
- damage_infantry(damage_data, hostile_shots, hostile_damage, weapon_index_position);
+ damage_infantry(damage_data, hostile_shots, hostile_damage, weapon_index_position, hostile_splash, hostile_arp, hostile_dist);
}
if (damage_data.hits < hostile_shots) {
+ // Spillover: only the shots the primary path did not spend carry over.
+ // This used to pass the full hostile_shots again, so a volley that had
+ // already resolved against the primary target fired its entire count a
+ // second time at the secondary one.
+ var _remaining_shots = hostile_shots - damage_data.hits;
+
// ### Vehicle Damage Processing ###
if (target_is_infantry && veh > 0) {
- damage_vehicles(damage_data, hostile_shots, hostile_damage, weapon_index_position);
+ damage_vehicles(damage_data, _remaining_shots, hostile_damage, weapon_index_position, hostile_arp);
}
// ### Marine + Dreadnought Processing ###
if (!target_is_infantry && (men + dreads > 0)) {
- damage_infantry(damage_data, hostile_shots, hostile_damage, weapon_index_position);
+ damage_infantry(damage_data, _remaining_shots, hostile_damage, weapon_index_position, hostile_splash, hostile_arp, hostile_dist);
}
}
- scr_flavor2(damage_data.units_lost, damage_data.unit_type, hostile_range, hostile_weapon, damage_data.hits, hostile_splash);
+ scr_flavor2(damage_data.units_lost, damage_data.unit_type, hostile_range, hostile_weapon, damage_data.hits, hostile_splash, damage_data.severity, damage_data.is_vehicle);
// ### Cleanup ###
// If the target_object got wiped out, move it off-screen
@@ -191,8 +253,14 @@ function scr_clean(target_object, target_is_infantry, hostile_shots, hostile_dam
}
/// @self Asset.GMObject.obj_pnunit
-function damage_infantry(_damage_data, _shots, _damage, _weapon_index) {
- var _armour_pierce = apa[_weapon_index];
+function damage_infantry(_damage_data, _shots, _damage, _weapon_index, _splash, _arp = 0, _dist = 0) {
+ // _arp is the ATTACKER's armour pierce, passed down from scr_shoot. This used to read
+ // apa[_weapon_index], but this function runs in the TARGET obj_pnunit's context, so
+ // that indexed the PLAYER's own weapon-stack arp table with the ENEMY's stack number.
+ // Whatever player stack shared the index decided the enemy's armour penetration: land
+ // on a stack with apa 4 and enemy lasguns ignored armour entirely (dreadnoughts and
+ // vehicles melting to small arms); land elsewhere and real anti-tank bounced off.
+ var _armour_pierce = _arp;
var _armour_mod = 0;
switch (_armour_pierce) {
case 4:
@@ -221,6 +289,89 @@ function damage_infantry(_damage_data, _shots, _damage, _weapon_index) {
}
}
+ // Bulk man-block with no individual model structs (Guard auxilia): take losses
+ // straight off the men count, the way the enemy's ranks do, since there are no
+ // marine structs for the normal path to kill. Scoped to guard blocks only.
+ // ===== OBSOLETE: planetary Guard (iteration 1) =====
+ // Casualty math for the dead first-iteration men-block. The `guard` flag is never set
+ // to 1, so this never runs, and the cover-save inside it never applies. Live guardsmen
+ // take casualties through the normal marine path below. Left for reference only.
+ if (guard == 1 && array_length(valid_marines) == 0 && men > 0) {
+ // Identical to the enemy Guardsman casualty math in scr_shoot. Reduce each
+ // shot by armour, pool the survivable damage across all shots, convert it to
+ // dead men at dudes_hp each, and cap the kills at the shot count. With
+ // dudes_ac 40 the rank shrugs off low-AP fire (basic Choppaz, Shootas) the
+ // same way the enemy Guardsmen you fight do, and only armour-piercing weapons
+ // cut them down in numbers. Armour is what gives them their staying power, so
+ // there is no separate cohesion cap here.
+ var _g_ac = (array_length(dudes_ac) > 1) ? dudes_ac[1] : 40;
+ var _g_hp = (array_length(dudes_hp) > 1) ? dudes_hp[1] : 5;
+ if (_g_hp <= 0) {
+ _g_hp = 5;
+ }
+ var _g_after = _damage - (_g_ac * _armour_mod);
+ if (_g_after < 0) {
+ _g_after = 0;
+ }
+ var _g_pool = _shots * _g_after;
+ var _g_total = min(floor(_g_pool / _g_hp), _shots);
+ _g_total = min(_g_total, men);
+ if (_g_total < 0) {
+ _g_total = 0;
+ }
+ // Cover / dispersion save (see GUARD_COVER_SAVE in macros.gml). A flat fraction
+ // of would-be casualties are treated as missed, standing in for spacing, terrain
+ // and a small profile. Applied after armour so it also blunts armour-piercing
+ // weapons (choppaz, power klawz) that ignore Flak entirely.
+ if (_g_total > 0 && GUARD_COVER_SAVE > 0) {
+ _g_total = floor(_g_total * (1 - GUARD_COVER_SAVE));
+ }
+ _damage_data.hits += _shots;
+ // Always name the block, even on a zero-casualty hit, or the enemy attack
+ // flavor prints "fire at ." with a blank target whenever armour soaks the shot.
+ _damage_data.unit_type = "Imperial Guardsman";
+ if (_g_total > 0) {
+ men -= _g_total;
+ if (array_length(dudes_num) > 1) {
+ dudes_num[1] = max(0, dudes_num[1] - _g_total);
+ }
+ // Report through the same lost/lost_num summary the marines use.
+ _damage_data.units_lost += _g_total;
+ _damage_data.unit_type = "Imperial Guardsman";
+ var _g_idx = -1;
+ for (var gk = 0, gl = array_length(lost); gk < gl; gk++) {
+ if (lost[gk] == "Imperial Guardsman") {
+ _g_idx = gk;
+ break;
+ }
+ }
+ if (_g_idx >= 0) {
+ lost_num[_g_idx] += _g_total;
+ } else {
+ array_push(lost, "Imperial Guardsman");
+ array_push(lost_num, _g_total);
+ }
+ }
+ return;
+ }
+
+ // Per-role tally of kills this volley, used to relabel the attack after the loop.
+ var _killed_roles = [];
+ var _killed_counts = [];
+
+ // Cover save. A fraction of incoming shots are treated as deflected by spacing,
+ // terrain and a low profile the model does not otherwise simulate. Guard auxilia are
+ // trained to use cover and get the full GUARD_COVER_SAVE; Astartes are bulky and hide
+ // poorly, so they get the weaker MARINE_COVER_SAVE. The rate is chosen per unit from
+ // its role, since the old block-level guard flag is dead and live guardsmen are just
+ // unit_struct units. The save fades as the enemy closes: it is scaled by shooter
+ // distance so hugging the line strips it (see COVER_SAVE_FULL_RANGE /
+ // COVER_SAVE_MIN_FACTOR). Rolled per shot below, after armour, so it also blunts
+ // armour-piercing weapons that ignore Flak entirely.
+ var _cover_dist_factor = clamp(_dist / COVER_SAVE_FULL_RANGE, COVER_SAVE_MIN_FACTOR, 1);
+ var _cover_saved = 0;
+ var _cover_role = "";
+
// Apply damage for each shot
for (var shot = 0; shot < _shots; shot++) {
if (array_length(valid_marines) == 0) {
@@ -234,6 +385,16 @@ function damage_infantry(_damage_data, _shots, _damage, _weapon_index) {
var marine = unit_struct[marine_index];
_damage_data.unit_type = marine.role();
+ // Cover save: the shot is spent (counts as a hit above) but deflected, so it does
+ // no damage. Only whole shots are saved, so a save never partially wounds. Rate is
+ // the unit's own (Guard auxilia get more cover than bulky Astartes).
+ var _cover_save = (unit_role_is_guard(marine.role()) ? GUARD_COVER_SAVE : MARINE_COVER_SAVE) * _cover_dist_factor;
+ if ((_cover_save > 0) && (random(1) < _cover_save)) {
+ _cover_saved++;
+ _cover_role = marine.role();
+ continue;
+ }
+
// Apply damage
var _shot_luck = roll_dice_chapter(1, 100, "low");
var _modified_damage = 0;
@@ -280,22 +441,107 @@ function damage_infantry(_damage_data, _shots, _damage, _weapon_index) {
var chunk = max(10, 62 - (marine_ac[marine_index] * 2));
_modified_damage = (webr <= chunk) ? 5000 : 0;
} */
+ var _hp_before = marine.hp();
marine.add_or_sub_health(-_modified_damage);
+ if ((_hp_before > 0) && (_modified_damage > 0)) {
+ _damage_data.severity = max(_damage_data.severity, clamp(_modified_damage / _hp_before, 0, 1));
+ }
// Check if marine is dead
if (check_dead_marines(marine, marine_index)) {
// Remove dead infantry from further hits
valid_marines = array_delete_value(valid_marines, marine_index);
_damage_data.units_lost++;
+
+ // Record the fallen role so the headline can name who actually died.
+ var _arole = marine.role();
+ var _aidx = -1;
+ for (var _ai = 0; _ai < array_length(_killed_roles); _ai++) {
+ if (_killed_roles[_ai] == _arole) { _aidx = _ai; break; }
+ }
+ if (_aidx == -1) {
+ array_push(_killed_roles, _arole);
+ array_push(_killed_counts, 1);
+ } else {
+ _killed_counts[_aidx] += 1;
+ }
+
+ // ===== Splash carry-over =====
+ // Port of the enemy men-block math in scr_shoot onto the player's individual
+ // units. A blast weapon's lethal overkill spills onto adjacent units, capped at
+ // the weapon's splash, and every further kill is gated by that unit's own armour
+ // and HP. Low-HP ranks (guardsmen) get torn apart by a Kannon or Rokkit; Marines
+ // and bosses soak the leftover through armour and HP, so it cannot wipe them, and
+ // ordinary attrition does not rise because only overkill carries.
+ if (_splash > 1) {
+ var _carry = _modified_damage - _hp_before; // damage left after this kill
+ var _spread_left = _splash - 1;
+ while (_spread_left > 0 && _carry > 0 && array_length(valid_marines) > 0) {
+ var _next_index = array_random_element(valid_marines);
+ var _next = unit_struct[_next_index];
+ var _next_net = _carry - (marine_ac[_next_index] * _armour_mod);
+ if (_next_net > 0) {
+ _next_net = round(_next_net * (1 - (_next.damage_resistance() / 100)));
+ }
+ if (_next_net <= 0) {
+ break; // armour soaked the leftover; the blast is spent
+ }
+ var _next_hp_before = _next.hp();
+ _next.add_or_sub_health(-_next_net);
+ if (check_dead_marines(_next, _next_index)) {
+ valid_marines = array_delete_value(valid_marines, _next_index);
+ _damage_data.units_lost++;
+ // Record the fallen role (splash victims count too).
+ var _brole = _next.role();
+ var _bidx = -1;
+ for (var _bi = 0; _bi < array_length(_killed_roles); _bi++) {
+ if (_killed_roles[_bi] == _brole) { _bidx = _bi; break; }
+ }
+ if (_bidx == -1) {
+ array_push(_killed_roles, _brole);
+ array_push(_killed_counts, 1);
+ } else {
+ _killed_counts[_bidx] += 1;
+ }
+ _carry = _next_net - _next_hp_before; // remaining overkill rolls on
+ } else {
+ break; // wounded but alive; the blast is spent on this body
+ }
+ _spread_left--;
+ }
+ }
+ }
+ }
+
+ // The loop overwrote unit_type with a random *surviving* unit every shot, so a dying
+ // screen in front of a tanky unit (Dreadnought) let the survivor win the "strike at X"
+ // headline while the casualty list named the dead. If anything fell, relabel the attack
+ // with the role that took the most casualties so the headline and the losses agree.
+ if (array_length(_killed_roles) > 0) {
+ var _top = 0;
+ for (var _ti = 1; _ti < array_length(_killed_counts); _ti++) {
+ if (_killed_counts[_ti] > _killed_counts[_top]) {
+ _top = _ti;
+ }
}
+ _damage_data.unit_type = _killed_roles[_top];
+ }
+
+ // Report the cover save in the combat log so a volley that hits little or nothing is
+ // explained rather than looking like a miss. Light blue, matching the non-lethal
+ // (armour-holds) family of lines.
+ if ((_cover_saved > 0) && (_cover_role != "")) {
+ obj_ncombat.combat_log.push($"The {_cover_role} weather the fire from effective cover!", make_color_rgb(120, 190, 225));
}
return;
}
/// @self Asset.GMObject.obj_pnunit
-function damage_vehicles(_damage_data, _shots, _damage, _weapon_index) {
- var _armour_pierce = apa[_weapon_index];
+function damage_vehicles(_damage_data, _shots, _damage, _weapon_index, _arp = 0) {
+ // See damage_infantry: _arp is the attacker's real armour pierce. The old
+ // apa[_weapon_index] read pulled from the player's own stack table by index.
+ var _armour_pierce = _arp;
var _armour_mod = 0;
switch (_armour_pierce) {
case 4:
@@ -308,7 +554,12 @@ function damage_vehicles(_damage_data, _shots, _damage, _weapon_index) {
_armour_mod = 4;
break;
case 1:
- _armour_mod = 6;
+ // Was 6, identical to no armour pierce at all. Nearly every enemy anti-tank
+ // weapon (Rokkit Launcha, Lascannon, Missile Launcher, Kannon) is arp 1, so
+ // enemy AT fire did literally nothing to player vehicles: a Rokkit at att 150
+ // against a Rhino (AC 30 x 6 = 180) resolved to 0. At mod 3 the same Rokkit
+ // does 60 per shot to a Rhino and still bounces off heavier hulls.
+ _armour_mod = 3;
break;
default:
_armour_mod = 6;
@@ -317,6 +568,10 @@ function damage_vehicles(_damage_data, _shots, _damage, _weapon_index) {
var veh_index = -1;
+ // Pen-vs-armour debug trail (MOD_IDEAS P5): one line per volley so "my rockets did
+ // nothing" reports can be diagnosed from last_messages.log instead of guesswork.
+ LOGGER.debug($"veh volley: arp {_armour_pierce} mod {_armour_mod} dmg/shot {_damage} shots {_shots} target_ac {(array_length(veh_ac) > 1 ? veh_ac[1] : -1)}");
+
// Find valid vehicle targets
var valid_vehicles = [];
for (var v = 0, l = array_length(veh_hp); v < l; v++) {
@@ -341,11 +596,27 @@ function damage_vehicles(_damage_data, _shots, _damage, _weapon_index) {
if (_modified_damage < 0) {
_modified_damage = 0;
}
- if (enemy == 13 && _modified_damage < 1) {
+ // Anti-tank penetration roll: a shot that can bite still has to roll to do so, so
+ // a stack of many shots does not automatically shred armour. A failed roll bounces
+ // for no damage. Only capable shots roll; ones already soaked to zero just bounce.
+ if (_modified_damage > 0) {
+ _modified_damage *= at_penetration_multiplier(veh_type[veh_index]);
+ }
+ // This ran in the obj_pnunit context, where `enemy` is the block's TARGET
+ // instance variable (set in its own Alarm_0), so enemy fire landing before
+ // the block's first tick crashed with an unset read; and comparing a target
+ // instance id to 13 was wrong regardless. The battle faction lives on
+ // obj_ncombat.
+ if (obj_ncombat.enemy == eFACTION.NECRONS && _modified_damage < 1) {
_modified_damage = 1;
}
+ var _veh_hp_before = veh_hp[veh_index];
veh_hp[veh_index] -= _modified_damage;
_damage_data.unit_type = veh_type[veh_index];
+ _damage_data.is_vehicle = true;
+ if (_veh_hp_before > 0) {
+ _damage_data.severity = max(_damage_data.severity, clamp(_modified_damage / _veh_hp_before, 0, 1));
+ }
// Check if the vehicle is destroyed
if (veh_hp[veh_index] <= 0 && veh_dead[veh_index] == 0) {
diff --git a/scripts/scr_company_view/scr_company_view.gml b/scripts/scr_company_view/scr_company_view.gml
index 0addd44874..61a6e41bda 100644
--- a/scripts/scr_company_view/scr_company_view.gml
+++ b/scripts/scr_company_view/scr_company_view.gml
@@ -49,6 +49,25 @@ function find_company_open_slot(target_company) {
break;
}
}
+ if (good == -1) {
+ // No free slot left: grow the company by one fully-initialised blank slot so
+ // companies are effectively uncapped (HQ included). Only runs on overflow, so
+ // normal recruiting is unchanged. All parallel arrays are extended together to
+ // stay in sync, mirroring _init_marine_row for a single index.
+ good = array_length(obj_ini.name[target_company]);
+ obj_ini.race[target_company][good] = 1;
+ obj_ini.name[target_company][good] = "";
+ obj_ini.role[target_company][good] = "";
+ obj_ini.wep1[target_company][good] = "";
+ obj_ini.spe[target_company][good] = "";
+ obj_ini.wep2[target_company][good] = "";
+ obj_ini.armour[target_company][good] = "";
+ obj_ini.gear[target_company][good] = "";
+ obj_ini.mobi[target_company][good] = "";
+ obj_ini.age[target_company][good] = 0;
+ obj_ini.god[target_company][good] = 0;
+ obj_ini.TTRPG[target_company][good] = new TTRPG_stats("chapter", target_company, good, "blank");
+ }
return good;
}
@@ -505,13 +524,13 @@ function company_manage_actions() {
}
// Previous company
if (point_and_click([xx + 424, yy + 80, xx + 496, yy + 128]) || (keyboard_check_pressed(ord(string("N"))) && allow_shortcuts)) {
- var new_view = managing == 1 ? 15 : managing - 1;
+ var new_view = managing == 1 ? 16 : managing - 1;
switch_view_company(new_view);
}
// Next company
if (point_and_click([xx + 1105, yy + 80, xx + 1178, yy + 128]) || (keyboard_check_pressed(ord(string("M"))) && allow_shortcuts)) {
- var new_view = managing == 15 ? 1 : managing + 1;
+ var new_view = managing == 16 ? 1 : managing + 1;
switch_view_company(new_view);
}
}
@@ -535,6 +554,8 @@ function ui_manage_hotkeys() {
switch_view_company(14);
} else if (press_exclusive(ord("Y"))) {
switch_view_company(15);
+ } else if (press_exclusive(ord("U"))) {
+ switch_view_company(16);
}
}
}
diff --git a/scripts/scr_controller_helpers/scr_controller_helpers.gml b/scripts/scr_controller_helpers/scr_controller_helpers.gml
index b21a3d29fe..19f6eda31a 100644
--- a/scripts/scr_controller_helpers/scr_controller_helpers.gml
+++ b/scripts/scr_controller_helpers/scr_controller_helpers.gml
@@ -38,7 +38,7 @@ function scr_menu_clear_up(specific_area_function) {
}
if (instance_exists(obj_temp_build)) {
- if (obj_temp_build.isnew) {
+ if (variable_instance_exists(obj_temp_build, "isnew") && obj_temp_build.isnew) {
exit;
}
}
@@ -443,6 +443,7 @@ function scr_end_turn() {
audio_play_sound(snd_end_turn, -50, false);
turn += 1;
+ eldar_incursion_tick();
with (obj_star) {
for (var i = 0; i <= 21; i++) {
present_fleet[i] = 0;
@@ -476,6 +477,12 @@ function scr_end_turn() {
}
player_forge_data.player_forges = 0;
+ // Reset the hangar list too: the per-turn forge scan in
+ // scr_enemy_ai_e re-pushes every forge hangar (player_forges is
+ // recomputed the same way and reset just above). Without this reset
+ // the array grew by one entry per hangar every turn, inflating the
+ // Vehicle STC & Hangars discount by 3% per turn (hanger_bonus reads
+ // its length) until vehicle costs bottomed out at the x0.10 clamp.
player_forge_data.vehicle_hanger = [];
requisition += income;
scr_income();
@@ -500,3 +507,246 @@ function scr_end_turn() {
}
});
}
+
+/// @desc How many pieces of Eldar intelligence have been collected. Guarded read so
+/// old saves and fresh campaigns start at zero without any save-format changes.
+function eldar_intel_count() {
+ if (!variable_instance_exists(obj_controller, "eldar_intel")) {
+ return 0;
+ }
+ return obj_controller.eldar_intel;
+}
+
+/// @desc Grant one piece of Eldar intelligence after a ground victory over a warhost.
+/// At ELDAR_INTEL_REQUIRED pieces the craftworld is revealed. Called from the battle
+/// aftermath (obj_ncombat\Alarm_5), where most map instances are deactivated, so the
+/// reveal only flips obj_controller.known (the craftworld un-hides itself through its
+/// own Draw check) and defers the map alert and fleet un-hiding to the next end-turn
+/// tick via eldar_reveal_alert_pending.
+function eldar_intel_grant() {
+ if (obj_controller.known[eFACTION.ELDAR] != 0) {
+ return;
+ }
+ if (!variable_instance_exists(obj_controller, "eldar_intel")) {
+ obj_controller.eldar_intel = 0;
+ }
+ obj_controller.eldar_intel += 1;
+ // Stamp the turn so the clue-expiry timer (see eldar_incursion_tick /
+ // ELDAR_CLUE_EXPIRY) measures from the most recent piece of intelligence.
+ obj_controller.eldar_intel_turn = obj_controller.turn;
+ var _n = obj_controller.eldar_intel;
+ var _clue_texts = [
+ "Among the alien dead your Apothecaries recover spirit stones that pulse in unison, all straining toward some distant point in the void. The Librarium begins triangulating.",
+ "A dying Warlock's staff yields a shard of wraithbone etched with webway routes. Cross-referenced with the spirit stones, the search narrows to a handful of sectors.",
+ "A captured wayseer's runes, broken under the Librarium's interrogation, complete the pattern. The hidden Craftworld's position is laid bare."
+ ];
+ var _text = _clue_texts[min(_n, array_length(_clue_texts)) - 1];
+ if (_n >= ELDAR_INTEL_REQUIRED) {
+ scr_popup("Eldar Intelligence", _text + $"\n\nIntelligence complete ({min(_n, ELDAR_INTEL_REQUIRED)}/{ELDAR_INTEL_REQUIRED}). The Eldar Craftworld has been located.", "");
+ obj_controller.known[eFACTION.ELDAR] = 1;
+ obj_controller.eldar_reveal_alert_pending = true;
+ } else {
+ scr_popup("Eldar Intelligence", _text + $"\n\nIntelligence gathered: {_n}/{ELDAR_INTEL_REQUIRED}.", "");
+ }
+}
+
+/// @desc Move the hidden craftworld to a fresh location and reset the hunt: clears
+/// gathered intelligence, re-hides the craftworld (known -> 0) and its escort fleet,
+/// and relocates both to a new valid position under the same placement constraints as
+/// initial worldgen. Called when gathered clues expire (see ELDAR_CLUE_EXPIRY). If no
+/// valid site is found in the search budget the craftworld stays put but the hunt
+/// still resets.
+function eldar_craftworld_relocate() {
+ if (obj_controller.faction_defeated[eFACTION.ELDAR] != 0) {
+ return;
+ }
+ var _craft = noone;
+ with (obj_star) {
+ if (craftworld) {
+ _craft = id;
+ break;
+ }
+ }
+ if (_craft == noone) {
+ return;
+ }
+ // Move the craftworld off-map during the search so instance_nearest doesn't
+ // measure the placement distance against itself.
+ var _old_x = _craft.x;
+ var _old_y = _craft.y;
+ _craft.x = -99999;
+ _craft.y = -99999;
+ var _nx = _old_x;
+ var _ny = _old_y;
+ var _go = 0;
+ // Same constraints as the worldgen placement in obj_controller Alarm_1: away from
+ // the sector centre, at least 150px from the nearest star, inside the bounds.
+ for (var _i = 0; _i < 200; _i++) {
+ if (_go == 0) {
+ _nx = floor(random(1152 + 600)) + 104;
+ _ny = floor(random(748 + 440)) + 104;
+ if (point_distance(room_width / 2, room_height / 2, _nx, _ny) >= 50) {
+ _go = 1;
+ }
+ var _me = instance_nearest(_nx, _ny, obj_star);
+ if ((_go == 1) && (point_distance(_me.x, _me.y, _nx, _ny) >= 150)) {
+ _go = 2;
+ }
+ if (_go == 1) {
+ _go = 0;
+ }
+ if ((_nx >= 1050 + 640) || (_ny <= 300 + 480)) {
+ _go = 0;
+ }
+ }
+ }
+ if (_go != 2) {
+ // No valid site found: leave it where it was.
+ _nx = _old_x;
+ _ny = _old_y;
+ }
+ _craft.x = _nx;
+ _craft.y = _ny;
+ _craft.old_x = _nx;
+ _craft.old_y = _ny;
+ _craft.vision = 0;
+ // Move and re-hide the escort fleet(s) orbiting the craftworld.
+ with (obj_en_fleet) {
+ if ((owner == eFACTION.ELDAR) && (orbiting == _craft)) {
+ x = _nx;
+ y = _ny;
+ image_alpha = 0;
+ }
+ }
+ // Reset the hunt: re-lock targeting (known -> 0), clear clues, restamp the timer.
+ // No map ping is fired, so the new position stays hidden.
+ obj_controller.known[eFACTION.ELDAR] = 0;
+ obj_controller.eldar_intel = 0;
+ obj_controller.eldar_intel_turn = obj_controller.turn;
+ obj_controller.eldar_reveal_alert_pending = false;
+ scr_event_log("green", "The Eldar Craftworld has slipped away. The trail has gone cold and the gathered intelligence is lost.");
+ scr_popup("Eldar Intelligence", "The gathered intelligence has gone stale. The Craftworld has moved beyond the reach of the runes, and the hunt must begin anew.", "");
+}
+
+/// @desc End-of-turn Eldar processing: fires the deferred craftworld reveal alert,
+/// expires stale clues (relocating the craftworld), then on a random
+/// ELDAR_INTERVAL_MIN..ELDAR_INTERVAL_MAX cadence processes warhosts on the ground
+/// (tainted worlds: scour the population, PDF and Guard while purging the taint;
+/// clean worlds: withdraw) and lands a new warhost on an inhabited imperial world, preferring worlds with
+/// heresy, chaos or traitor presence (ELDAR_TAINT_SPAWN_WEIGHT). Warhost strength
+/// ramps with collected intelligence (FORCE_BASE + clues, capped at FORCE_MAX),
+/// keeping the strongest Eldar for the craftworld itself. The planetary AI never
+/// acts on p_eldar (its pdf_attack line is commented out upstream); the fighting
+/// on tainted worlds is handled here instead.
+function eldar_incursion_tick() {
+ if (variable_instance_exists(obj_controller, "eldar_reveal_alert_pending") && obj_controller.eldar_reveal_alert_pending) {
+ obj_controller.eldar_reveal_alert_pending = false;
+ with (obj_star) {
+ if (p_type[1] == "Craftworld") {
+ // Grant fog-of-war vision with the reveal so the craftworld is
+ // immediately selectable and targetable (see obj_star Mouse_50).
+ vision = 1;
+ scr_alert("green", "elfs", "Eldar Craftworld discovered.", x, y);
+ scr_event_log("green", "Eldar Craftworld discovered.");
+ }
+ }
+ with (obj_en_fleet) {
+ if (owner == eFACTION.ELDAR) {
+ image_alpha = 1;
+ }
+ }
+ }
+ if (obj_controller.faction_defeated[eFACTION.ELDAR] != 0) {
+ return;
+ }
+ // Clue expiry: intelligence gathered but not acted upon goes cold after
+ // ELDAR_CLUE_EXPIRY turns. The clues are lost and the craftworld relocates, so a
+ // located craftworld must be reached and assaulted within that window (well above
+ // a sector crossing of ~40-50 turns). Allied Eldar (known >= 2) are exempt. Runs
+ // every turn, before the incursion cadence gate below.
+ if (variable_instance_exists(obj_controller, "eldar_intel") && (obj_controller.eldar_intel > 0) && (obj_controller.known[eFACTION.ELDAR] < 2)) {
+ if (!variable_instance_exists(obj_controller, "eldar_intel_turn")) {
+ obj_controller.eldar_intel_turn = obj_controller.turn;
+ }
+ if ((obj_controller.turn - obj_controller.eldar_intel_turn) >= ELDAR_CLUE_EXPIRY) {
+ eldar_craftworld_relocate();
+ }
+ }
+ // Variable incursion cadence: schedule the next strike a random
+ // ELDAR_INTERVAL_MIN..ELDAR_INTERVAL_MAX turns out instead of a fixed modulo, so
+ // arrivals are not perfectly predictable. Lazy-init on first run.
+ if (!variable_instance_exists(obj_controller, "eldar_next_incursion")) {
+ obj_controller.eldar_next_incursion = obj_controller.turn + irandom_range(ELDAR_INTERVAL_MIN, ELDAR_INTERVAL_MAX);
+ }
+ if (obj_controller.turn < obj_controller.eldar_next_incursion) {
+ return;
+ }
+ obj_controller.eldar_next_incursion = obj_controller.turn + irandom_range(ELDAR_INTERVAL_MIN, ELDAR_INTERVAL_MAX);
+ // Process warhosts already on the ground before landing a new one. On a world
+ // touched by the Great Enemy (heresy, chaos or traitor presence) the warhost
+ // stays: it battles the planetary defense force (whose loyalty it does not
+ // trust) and purges the taint itself, taking attrition. On a clean world its
+ // secret mission is done and it withdraws, so warhosts stop accumulating as
+ // permanent garrisons across the sector.
+ with (obj_star) {
+ if (craftworld || space_hulk) {
+ continue;
+ }
+ for (var i = 1; i <= planets; i++) {
+ if (p_eldar[i] <= 0) {
+ continue;
+ }
+ var _tainted = (p_hurssy[i] > 0) || (p_chaos[i] > 0) || (p_traitors[i] > 0);
+ if (_tainted) {
+ // The Eldar do not do proportionality. Any trace of the Great Enemy
+ // condemns the world: each incursion tick the warhost culls a large
+ // share of the population, cuts the defense forces and Guard down,
+ // and purges the taint itself, taking some attrition in return.
+ // Letting a warhost squat on a tainted world is therefore expensive,
+ // and clearing them off it is a real decision rather than free
+ // chaos-cleanup.
+ p_population[i] = max(0, p_population[i] - floor(p_population[i] * ELDAR_PURGE_POP_FRACTION));
+ p_pdf[i] = max(0, floor(p_pdf[i] * (1 - ELDAR_PURGE_DEFENSE_FRACTION)) - p_eldar[i]);
+ p_guardsmen[i] = max(0, floor(p_guardsmen[i] * (1 - ELDAR_PURGE_DEFENSE_FRACTION)));
+ if (irandom(2) == 0) {
+ p_eldar[i] = max(1, p_eldar[i] - 1);
+ }
+ p_hurssy[i] = max(0, p_hurssy[i] - 1);
+ p_chaos[i] = max(0, p_chaos[i] - 1);
+ p_traitors[i] = max(0, p_traitors[i] - 1);
+ scr_alert("red", "elfs", $"The Eldar have judged {name} {scr_roman(i)} tainted and are putting its population to the sword.", x, y);
+ scr_event_log("red", $"The Eldar warhost scours {name} {scr_roman(i)}: the defense forces are cut down and the population culled.");
+ } else {
+ p_eldar[i] = 0;
+ scr_event_log("green", $"The Eldar warhost on {name} {scr_roman(i)} has vanished as suddenly as it arrived.");
+ }
+ }
+ }
+ var _force = min(ELDAR_INCURSION_FORCE_BASE + eldar_intel_count(), ELDAR_INCURSION_FORCE_MAX);
+ var _targets = [];
+ with (obj_star) {
+ if (craftworld || space_hulk) {
+ continue;
+ }
+ for (var i = 1; i <= planets; i++) {
+ if ((p_type[i] != "Dead") && (p_owner[i] >= 1) && (p_owner[i] <= 5) && (p_eldar[i] == 0)) {
+ array_push(_targets, [id, i]);
+ // Tainted worlds draw the Eldar: weight them heavier in the pick.
+ if ((p_hurssy[i] > 0) || (p_chaos[i] > 0) || (p_traitors[i] > 0)) {
+ repeat (ELDAR_TAINT_SPAWN_WEIGHT - 1) {
+ array_push(_targets, [id, i]);
+ }
+ }
+ }
+ }
+ }
+ if (array_length(_targets) == 0) {
+ return;
+ }
+ var _pick = _targets[irandom(array_length(_targets) - 1)];
+ var _star = _pick[0];
+ var _planet = _pick[1];
+ _star.p_eldar[_planet] = _force;
+ scr_alert("red", "elfs", $"An Eldar warhost has struck {_star.name} {scr_roman(_planet)}.", _star.x, _star.y);
+ scr_event_log("red", $"An Eldar warhost has struck {_star.name} {scr_roman(_planet)}.");
+}
diff --git a/scripts/scr_draw_management_unit/scr_draw_management_unit.gml b/scripts/scr_draw_management_unit/scr_draw_management_unit.gml
index e945d66275..3d72be33fa 100644
--- a/scripts/scr_draw_management_unit/scr_draw_management_unit.gml
+++ b/scripts/scr_draw_management_unit/scr_draw_management_unit.gml
@@ -347,7 +347,7 @@ function scr_draw_management_unit(selected, yy = 0, xx = 0, draw = true, click_l
if (location_out_of_player_control(unit_location_string) || (unit_location_string == "=Penitorium=") || (assignment != "none")) {
draw_set_alpha(0.5);
}
- var truncatedLocation = string_truncate(string(unit_location_string), 130); // Truncate the location string to 100 pixels
+ var truncatedLocation = string_truncate_planet(string(unit_location_string), 130); // Truncate but keep the planet numeral
draw_text(xx + 430 + 8, yy + 66, truncatedLocation); // LOC
draw_set_alpha(1);
@@ -595,6 +595,15 @@ function scr_draw_management_unit(selected, yy = 0, xx = 0, draw = true, click_l
if (((mouse_x >= xx + 25 && mouse_y >= yy + 64 && mouse_x < xx + 974 && mouse_y < yy + 85) || force_tool == 1) && is_struct(_unit)) {
unit_focus = _unit; // unit_struct
+ // Hovering back onto a unit hands the portrait slot back to the unit image.
+ vehicle_portrait_role = "";
+ }
+ } else if ((man[selected] == "vehicle") && draw) {
+ // Vehicle portraits (mod): vehicles set no unit_focus, so the portrait slot
+ // never knew a vehicle was hovered. Store the role; the manage panel maps it
+ // to a runtime PNG (see draw_sprite_and_unit_equip_data in scr_ui_manage).
+ if (mouse_x >= xx + 25 && mouse_y >= yy + 64 && mouse_x < xx + 974 && mouse_y < yy + 85) {
+ vehicle_portrait_role = string(ma_role[selected]);
}
}
if (!ma_view[selected]) {
diff --git a/scripts/scr_draw_planet_features/scr_draw_planet_features.gml b/scripts/scr_draw_planet_features/scr_draw_planet_features.gml
index 3a924566bf..fd2242ddcc 100644
--- a/scripts/scr_draw_planet_features/scr_draw_planet_features.gml
+++ b/scripts/scr_draw_planet_features/scr_draw_planet_features.gml
@@ -240,7 +240,14 @@ function FeatureSelected(_feature, _system, _planet) constructor {
case "provide_garrison":
var reason;
if (feature.reason == "importance") {}
- mission_description = $"The governor of {planet_name} has requested a force of marines might stay behind following your departure.\n\n\n assign a squad to garrison to initiate mission, The garrison leeader will need to be capable of conducting himself in a diplomatic manner in order for the garrison duration to be a success";
+ // The requirement here used to be vague ("capable of conducting
+ // himself in a diplomatic manner"). The actual mechanics: the
+ // garrison's leader is the most senior squad leader among the
+ // garrisoned squads (rank does not gate the mission, it only
+ // decides who is tested); the disposition outcome keys off the
+ // leader having the Honorable trait, and a separate wisdom test
+ // (siege specialists excel) can add fortifications.
+ mission_description = $"The governor of {planet_name} has requested a force of marines might stay behind following your departure.\n\nAssign squads to Garrison Duty on this world to initiate the mission. Any squads qualify; the outcome rests on the garrison's leader, the most senior squad leader among those garrisoned. An Honorable leader will secure the diplomatic gains, while a leader lacking the trait risks incidents that sour relations. A wise leader (siege specialists excel) may also leave the world better fortified.";
break;
case "join_communion":
@@ -284,6 +291,12 @@ function FeatureSelected(_feature, _system, _planet) constructor {
draw_text_transformed(xx + (area_width / 2), yy + 5, mission_name_key(feature.problem), 2, 2, 0);
draw_set_halign(fa_left);
draw_set_color(c_gray);
+ // Initiated missions are clearly marked so the player knows the
+ // assignment registered and the clock is running (mission structs flip
+ // stage from "preliminary" to "active" in their init functions).
+ if (variable_struct_exists(feature, "stage") && (feature.stage == "active")) {
+ mission_description = "++MISSION IN PROGRESS++\n\n" + mission_description;
+ }
draw_text_ext(xx + 10, yy + 40, mission_description, -1, area_width - 20);
var text_body_height = string_height_ext(string_hash_to_newline(mission_description), -1, area_width - 20);
if (help != "none") {
diff --git a/scripts/scr_draw_unit_image/scr_draw_unit_image.gml b/scripts/scr_draw_unit_image/scr_draw_unit_image.gml
index 3f5062e37b..f51b01da91 100644
--- a/scripts/scr_draw_unit_image/scr_draw_unit_image.gml
+++ b/scripts/scr_draw_unit_image/scr_draw_unit_image.gml
@@ -524,6 +524,32 @@ function scr_draw_unit_image(_background = false) {
draw_sprite(spr_skitarii, 0, x_surface_offset, y_surface_offset);
}
}
+
+ // ===== Guardsman portrait (mod) =====
+ // Guardsmen are base_group "human", so the Marine body-part composition above is
+ // skipped and the surface is left empty. Draw the static guardsman portrait here, the
+ // same way Skitarii and Sisters draw theirs, loaded once from a runtime PNG because the
+ // mod cannot add compiled sprite assets. The image ships at images\units\guardsman.png.
+ // If alignment needs nudging, offset the draw from x_surface_offset / y_surface_offset.
+ if (role() == "Guardsman" || role() == "Veteran Guard" || role() == "Heavy Weapons Team") {
+ if (!variable_global_exists("guardsman_portrait")) {
+ global.guardsman_portrait = sprite_add(working_directory + "/images/units/guardsman.png", 1, false, false, 0, 0);
+ }
+ if (sprite_exists(global.guardsman_portrait)) {
+ draw_sprite(global.guardsman_portrait, 0, x_surface_offset, y_surface_offset);
+ }
+ }
+
+ // ===== Guard Sergeant portrait (mod) =====
+ // Same runtime-PNG approach as the guardsman above. The image ships at images\units\sarge.png.
+ if (role() == "Guard Sergeant") {
+ if (!variable_global_exists("sarge_portrait")) {
+ global.sarge_portrait = sprite_add(working_directory + "/images/units/sarge.png", 1, false, false, 0, 0);
+ }
+ if (sprite_exists(global.sarge_portrait)) {
+ draw_sprite(global.sarge_portrait, 0, x_surface_offset, y_surface_offset);
+ }
+ }
surface_reset_target();
shader_reset();
diff --git a/scripts/scr_drop_select_function/scr_drop_select_function.gml b/scripts/scr_drop_select_function/scr_drop_select_function.gml
index ad507786f8..d4bc831c96 100644
--- a/scripts/scr_drop_select_function/scr_drop_select_function.gml
+++ b/scripts/scr_drop_select_function/scr_drop_select_function.gml
@@ -46,17 +46,26 @@ function drop_select_unit_selection() {
// draw_rectangle(xx+1084,yy+215,xx+1142,yy+273,0);
// Formation
- var _formation_str = $"Formation: {obj_controller.bat_formation[formation_possible[formation_current]]}";
- btn_formation.x1 = x2 - 50 - (string_width(_formation_str));
+ // Hardening: never index formation_possible without checking it is non-empty and the
+ // index is in range. A drifted or stale formation_current (e.g. across a load) would
+ // otherwise crash the whole drop screen on draw.
+ var _formation_str = "Formation: -";
+ if (array_length(formation_possible) > 0) {
+ formation_current = clamp(formation_current, 0, array_length(formation_possible) - 1);
+ _formation_str = $"Formation: {obj_controller.bat_formation[formation_possible[formation_current]]}";
+ }
+ // Upstream renamed this button to btn_formation in obj_drop_select Create_0;
+ // the old name here crashed the whole attack screen on draw.
+ btn_formation.x1 = x2 - 40 - (string_width(_formation_str) + 4);
btn_formation.y1 = y1 + 80;
- btn_formation.button_color = CM_GREEN_COLOR;
- btn_formation.text_color = CM_GREEN_COLOR;
btn_formation.update({str1: _formation_str});
btn_formation.draw();
if (btn_formation.clicked()) {
- formation_current++;
- if (formation_current >= array_length(formation_possible)) {
- formation_current = 0;
+ if (array_length(formation_possible) > 0) {
+ formation_current++;
+ if (formation_current >= array_length(formation_possible)) {
+ formation_current = 0;
+ }
}
}
@@ -76,18 +85,45 @@ function drop_select_unit_selection() {
// Local force button;
if (purge != eDROP_TYPE.PURGEBOMBARD) {
var _local_button = roster.local_button;
+ // Local force exhaustion: planetside forces also support at most
+ // SHIP_ASSAULTS_PER_TURN ground assaults per turn, closing the loop of
+ // deploying troops to the surface and attacking endlessly for free. When
+ // spent, the button locks red like an exhausted ship. Attacks only.
+ var _locals_spent = (attack == 1) && (local_assaults_used(p_target, planet_number) >= SHIP_ASSAULTS_PER_TURN);
+ if (_locals_spent) {
+ if (_local_button.active) {
+ _local_button.active = false;
+ roster.update_roster();
+ }
+ _local_button.text_color = c_red;
+ _local_button.button_color = c_red;
+ _local_button.tooltip = "This planet's forces have already supported the maximum number of ground assaults this turn.";
+ }
_local_button.x1 = _buttons_x;
_local_button.y1 = _buttons_y;
_local_button.update();
_local_button.draw();
if (_local_button.clicked()) {
+ if (_locals_spent) {
+ _local_button.active = false;
+ }
roster.update_roster();
}
}
_buttons_y += 30;
- // Ship buttons;
+ // Ship assault economy: assault-exhausted ships are drawn locked and red (see
+ // scr_roster). ToggleButton clicks and Select All still flip their active flag,
+ // so force locked ships back off here before the selection is consumed.
+ for (var _ls = 0; _ls < array_length(roster.ships); _ls++) {
+ var _ls_btn = roster.ships[_ls];
+ if (variable_struct_exists(_ls_btn, "assault_locked") && _ls_btn.assault_locked && _ls_btn.active) {
+ _ls_btn.active = false;
+ roster.ship_multi_selector.changed = true;
+ }
+ }
+
if (roster.ship_multi_selector.changed) {
roster.update_roster();
}
@@ -142,6 +178,9 @@ function drop_select_unit_selection() {
if (purge == eDROP_TYPE.RAIDATTACK) {
var target_race = "";
var target_threat = "";
+ // Ported from upstream: without this declaration, attacking a world with no
+ // enemy forces left (race_quantity 0) skips the only assignment below and the
+ // string_width read throws "not set before reading it".
var _target_str = "No Target";
if (attacking >= 5 && attacking <= 13) {
@@ -221,11 +260,31 @@ function drop_select_unit_selection() {
if (purge == 0) {
combating = 1; // Start battle here
+ // Hardening: resolve the chosen formation through a single bounds-checked read so a
+ // bad formation_current cannot crash the drop launch. Falls back to formation 0 when
+ // no formations are available.
+ var _chosen_form = 0;
+ if (array_length(formation_possible) > 0) {
+ _chosen_form = formation_possible[clamp(formation_current, 0, array_length(formation_possible) - 1)];
+ }
+
if (attack == 1) {
- obj_controller.last_attack_form = formation_possible[formation_current];
+ obj_controller.last_attack_form = _chosen_form;
}
if (attack == 0) {
- obj_controller.last_raid_form = formation_possible[formation_current];
+ obj_controller.last_raid_form = _chosen_form;
+ }
+
+ // The fleet action tick used to run AFTER instance_deactivate_all, writing
+ // to a deactivated instance by id. Whether that write lands is
+ // runtime-dependent, and the tester's repro (unlimited raids from a
+ // stationary fleet, third raid never blocked) matches it silently failing
+ // in the compiled build: acted never climbed, so the raid gate
+ // (acted <= 1) always passed. Ticked before deactivation instead, with a
+ // proof line for the session log.
+ if (sh_target != noone) {
+ sh_target.acted += 1;
+ LOGGER.info($"DROP LAUNCH {((attack == 1) ? "attack" : "raid")}: fleet acted now {sh_target.acted}");
}
instance_deactivate_all(true);
@@ -233,9 +292,36 @@ function drop_select_unit_selection() {
instance_activate_object(obj_ini);
instance_activate_object(obj_drop_select);
- // 135 ; temporary balancing
- if (sh_target != noone) {
- sh_target.acted += 1;
+ // Ship assault economy: each distinct ship contributing units to this
+ // ground deployment spends one support use this turn (SHIP_ASSAULTS_PER_TURN
+ // max). This now covers raids as well as attacks (both are RAIDATTACK drops
+ // that land troops from ships), so a raid is gated per ship like an assault
+ // rather than by the fleet-wide acted counter. fleet.acted above still ticks
+ // for movement and the unconverted purge gate. Local planetside forces
+ // (ship id -1) cost nothing here; they spend a local use just below.
+ if (purge == eDROP_TYPE.RAIDATTACK) {
+ var _spent_ships = [];
+ var _local_participated = false;
+ for (var _su = 0; _su < array_length(roster.selected_units); _su++) {
+ var _sel = roster.selected_units[_su];
+ var _sel_ship = is_struct(_sel) ? _sel.ship_location : obj_ini.veh_lid[_sel[0]][_sel[1]];
+ if (_sel_ship > -1) {
+ if (!array_contains(_spent_ships, _sel_ship)) {
+ array_push(_spent_ships, _sel_ship);
+ var _drop_kind = (attack == 1) ? "assault" : "raid";
+ ship_action_spend(_sel_ship, _drop_kind);
+ LOGGER.info($"{string_upper(_drop_kind)} SPEND ship {_sel_ship}: uses now {ship_action_used(_sel_ship, _drop_kind)}/{SHIP_ASSAULTS_PER_TURN}");
+ }
+ } else {
+ _local_participated = true;
+ }
+ }
+ // Planetside forces joining the assault spend one of the planet's
+ // local support uses, so troops cannot be dropped onto the surface
+ // and used for unlimited free attacks.
+ if (_local_participated) {
+ local_assault_spend(p_target, planet_number);
+ }
}
if ((attacking == 10) || (attacking == 11)) {
@@ -250,10 +336,15 @@ function drop_select_unit_selection() {
obj_ncombat.dropping = 1 - attack;
obj_ncombat.attacking = attack;
obj_ncombat.enemy = attacking;
- obj_ncombat.formation_set = formation_possible[formation_current];
+ obj_ncombat.formation_set = _chosen_form;
obj_ncombat.defending = false;
obj_ncombat.local_forces = roster.local_button.active;
+ // (Imperial Guard assault bring-along disabled for now: the player-side
+ // battlefield unit needs real per-model data, so this is being rebuilt.
+ // Until then we do not touch the embarked Guard, so attacks cost nothing.)
+ obj_ncombat.player_attack_guard = 0;
+
var _planet = obj_ncombat.battle_object.p_feature[obj_ncombat.battle_id];
if (obj_ncombat.battle_object.space_hulk == 1) {
obj_ncombat.battle_special = "space_hulk";
diff --git a/scripts/scr_en_weapon/scr_en_weapon.gml b/scripts/scr_en_weapon/scr_en_weapon.gml
index cba3e5d9ec..d2b1d98ab1 100644
--- a/scripts/scr_en_weapon/scr_en_weapon.gml
+++ b/scripts/scr_en_weapon/scr_en_weapon.gml
@@ -23,6 +23,31 @@ function scr_en_weapon(name, is_man, man_number, man_type, group) {
//if (obj_ncombat.enemy=5) then faith_bonus=faith[man_type];
switch (name) {
+ // "Melee1" is the roster's generic sidearm-melee slot, carried by ten unit
+ // types across three factions (Eldar Guardians, Dire Avengers, Pathfinders,
+ // Fire Dragons, Dark Reapers and Exarchs; Ork Tankbustas; Chaos Havocs,
+ // Noise Marines, Rubric Marines). It never had stats anywhere (verbatim
+ // upstream), so every one of those units built an attack-0 range-0 weapon
+ // stack: "broken range" error spam each turn and no melee capability at all,
+ // a major silent contributor to the last-survivor melee stalemates. Statted
+ // here as a modest close-combat weapon; units with a real melee identity
+ // already carry proper weapons alongside it.
+ case "Melee1":
+ atta = 40;
+ arp = 1;
+ rang = 1;
+ break;
+ // Flesh Hooks are carried by Lictors (Tyranid battles) AND Mutants (Chaos
+ // and Heretic rosters). Their stats lived only under the Tyranid faction
+ // gate below, so Mutant flesh hooks were attack-0 range-0 in every battle
+ // mutants actually appear in. Weapon stats in this file are faction-gated;
+ // any weapon shared across factions must live in this ungated switch.
+ case "Flesh Hooks":
+ atta = 100;
+ arp = 2;
+ rang = 2;
+ amm = 1;
+ break;
case "Venom Claws":
atta = 200;
arp = 4;
@@ -665,12 +690,10 @@ function scr_en_weapon(name, is_man, man_number, man_type, group) {
arp = 3;
rang = 1;
break;
- case "Flesh Hooks":
- atta = 100;
- arp = 2;
- rang = 2;
- amm = 1;
- break;
+ // Genestealer Cult arsenal, ported verbatim from upstream scr_en_weapon.
+ // Cult revolt battles run under the TYRANIDS gate; without these cases
+ // every cult heavy weapon fell through with range 0 and never fired
+ // ("has broken range!" log spam, one-sided battles, no red loss lines).
case "Hand Flamer":
atta = 40;
arp = 1;
@@ -717,11 +740,6 @@ function scr_en_weapon(name, is_man, man_number, man_type, group) {
rang = 12;
amm = 10;
break;
- case "Melee Weapon":
- atta = 50;
- arp = 1;
- rang = 1;
- break;
default:
break;
}
@@ -1150,7 +1168,12 @@ function scr_en_weapon(name, is_man, man_number, man_type, group) {
for (var b = 0; b < 30; b++) {
if ((wep[b] == name) && (goody == 0)) {
att[b] += atta * man_number;
- apa[b] += arp;
+ // apa is the weapon's armour-pierce TIER, a property of the weapon, not a
+ // pool. It used to accumulate (+= arp) once per merged dude group and, via
+ // the un-reset slot 0, once per turn, so the tier drifted with block
+ // composition and battle length: two lascannon groups read as tier 2, four
+ // as tier 4 (ignore armour), five as junk. Set it, don't sum it.
+ apa[b] = arp;
range[b] = rang;
wep_num[b] += man_number;
if (obj_ncombat.started == 0) {
@@ -1173,7 +1196,7 @@ function scr_en_weapon(name, is_man, man_number, man_type, group) {
wep[first] = name;
splash[first] = spli;
att[first] += atta * man_number;
- apa[first] += arp;
+ apa[first] = arp;
range[first] = rang;
wep_num[first] += man_number;
if (obj_ncombat.started == 0) {
diff --git a/scripts/scr_flavor/scr_flavor.gml b/scripts/scr_flavor/scr_flavor.gml
index 97ec9117b1..ecc29dc2c4 100644
--- a/scripts/scr_flavor/scr_flavor.gml
+++ b/scripts/scr_flavor/scr_flavor.gml
@@ -1,3 +1,27 @@
+/// @function battle_log_tail_slot
+/// @desc First free slot AFTER the last queued message. The old posting scheme wrote
+/// at index `messages`, which is a COUNT, not a tail pointer: every displayed message
+/// clears a slot at the FRONT of the queue and decrements the count, so until the
+/// next compaction tick the count points AT the newest queued message. Any post
+/// landing in that window overwrote it (silently destroying lines, vehicle-loss
+/// lines in message-heavy piercing turns being the reported casualties) and inflated
+/// `messages` by one phantom per overwrite, which is how battles ended with the pump
+/// spinning on a nonzero count over empty slots, printing nothing.
+/// @returns {real} slot index, or -1 when the log is genuinely full (drop, never overwrite)
+function battle_log_tail_slot() {
+ var _last = 0;
+ for (var _m = COMBAT_LOG_CAPACITY; _m >= 1; _m--) {
+ if (obj_ncombat.message[_m] != "") {
+ _last = _m;
+ break;
+ }
+ }
+ if (_last >= COMBAT_LOG_CAPACITY) {
+ return -1;
+ }
+ return _last + 1;
+}
+
/// @function add_battle_log_message
/// @param {string} _message - The message text to add to the battle log
/// @param {real} [_message_color=0] - The color enum value (0=default, eMSG_COLOR.*)
@@ -43,7 +67,7 @@ function report_held_fire(_weapon_names) {
add_battle_log_message($"{_list} held fire lacking live targets.", eMSG_COLOR.WHITE);
}
-function scr_flavor(id_of_attacking_weapons, target, target_type, number_of_shots, casulties, shots_bounced = false, _defer = false) {
+function scr_flavor(id_of_attacking_weapons, target, target_type, number_of_shots, casulties, shots_bounced = false, _defer = false, _graze_severity = 0) {
// Generates flavor based on the damage and casualties from scr_shoot, only for the player
// shots_bounced: true when armour stopped the shots outright (AP too low) and nothing died,
// so the log can explain *why* instead of a flat "no casualties".
@@ -153,6 +177,10 @@ function scr_flavor(id_of_attacking_weapons, target, target_type, number_of_shot
firing_subject = $"{number_of_shots} {weapon_plural}";
}
+ // A plain volley line (" ", no character title) can be summed with other volleys
+ // of the same weapon on the same target into one consolidated kill line (see emit_volley_flavour).
+ var _volley_line = (!(character_shot && unit_name != "")) && (number_of_shots > 1);
+
var flavoured = false;
if (weapon_data.has_tag("bolt")) {
@@ -722,8 +750,14 @@ function scr_flavor(id_of_attacking_weapons, target, target_type, number_of_shot
color: message_color,
bounced: (shots_bounced && casulties == 0),
injured: (!shots_bounced && casulties == 0),
+ severity: _graze_severity,
+ is_vehicle: (instance_exists(target) ? (target.dudes_vehicle[targeh] != 0) : false),
target: target_name,
subject: firing_subject,
+ weapon: weapon_plural,
+ shots: number_of_shots,
+ kills: casulties,
+ volley: _volley_line,
};
}
@@ -771,13 +805,25 @@ function emit_volley_flavour(_primary, _spill_kills) {
// Non-killing volley (armour-bounce or a wound that dropped no-one, and nothing spilled):
// consolidate into one chronological line per target instead of one line per weapon.
if (is_struct(_primary) && (_primary.bounced || _primary.injured) && _list == "") {
- combat_tally_add(_primary.target, _primary.subject, _primary.injured);
+ combat_kill_tally_flush();
+ combat_tally_add(_primary.target, _primary.subject, _primary.injured, _primary.severity, _primary.is_vehicle);
return;
}
- // A killing volley posts immediately; flush any pending bounce/injure tally first so the log
- // stays in chronological order.
+ // Simple killing volley with no spill-over: buffer it so consecutive volleys of the same weapon
+ // on the same target (e.g. a split lasgun volley firing in sub-stacks) collapse into one summed
+ // line. A single unmerged volley keeps its original rich flavour on flush. Flush the wound/bounce
+ // tally first so the log stays chronological.
+ if (is_struct(_primary) && _list == "" && _primary.volley && _primary.kills > 0) {
+ combat_tally_flush();
+ combat_kill_tally_add(_primary.target, _primary.weapon, _primary.shots, _primary.kills, _primary.attack, _primary.color, _primary.leader);
+ return;
+ }
+
+ // A killing volley with spill-over, or a titled/lone shot, posts immediately; flush both pending
+ // tallies first so the log stays in chronological order.
combat_tally_flush();
+ combat_kill_tally_flush();
if (!is_struct(_primary)) {
// No primary line (scr_flavor bailed on a dead target - shouldn't happen now that emptied
@@ -804,13 +850,17 @@ function emit_volley_flavour(_primary, _spill_kills) {
/// @desc Buffers a non-killing volley (wound or armour-bounce) against a target. Consecutive volleys
/// on the same target merge; switching target flushes the previous one, keeping the log
/// chronological. _injured true = penetrated but no kill; false = bounced off armour.
-function combat_tally_add(_target, _subject, _injured) {
+function combat_tally_add(_target, _subject, _injured, _severity = 0, _is_vehicle = false) {
if (obj_ncombat.ctally_target != _target) {
combat_tally_flush();
obj_ncombat.ctally_target = _target;
}
if (_injured) {
array_push(obj_ncombat.ctally_injure, _subject);
+ // Keep the worst wound in the group so the consolidated line grades by the
+ // heaviest hit, and remember whether the target is a vehicle for the tier table.
+ obj_ncombat.ctally_injure_severity = max(obj_ncombat.ctally_injure_severity, _severity);
+ obj_ncombat.ctally_is_vehicle = _is_vehicle;
} else {
array_push(obj_ncombat.ctally_bounce, _subject);
}
@@ -823,7 +873,13 @@ function combat_tally_flush() {
}
var _t = obj_ncombat.ctally_target;
if (array_length(obj_ncombat.ctally_injure) > 0) {
- add_battle_log_message($"Fire from {combat_subject_join(obj_ncombat.ctally_injure)} wounded the {_t} but didn't bring it down.", eMSG_COLOR.WHITE);
+ // Grade the wound by how much got through, reusing the enemy side's severity tiers
+ // (incoming_damage_flavor). Injured means damage landed, so floor the severity at the
+ // graze tier (0.10) so it never shows the "shrugged off" band. Light green for
+ // wounded-but-standing, per Tavish's CM-Poligon color coding (CptMacTavish2224,
+ // tag LW_Beta_1.2).
+ var _wound_grade = incoming_damage_flavor(max(0.10, obj_ncombat.ctally_injure_severity), obj_ncombat.ctally_is_vehicle);
+ add_battle_log_message($"Fire from {combat_subject_join(obj_ncombat.ctally_injure)} hits the {_t}. {_wound_grade}", eMSG_COLOR.LIGHTGREEN);
}
if (array_length(obj_ncombat.ctally_bounce) > 0) {
add_battle_log_message($"Fire from {combat_subject_join(obj_ncombat.ctally_bounce)} cannot penetrate the {_t}'s armour.", eMSG_COLOR.WHITE);
@@ -831,6 +887,59 @@ function combat_tally_flush() {
obj_ncombat.ctally_target = undefined;
obj_ncombat.ctally_bounce = [];
obj_ncombat.ctally_injure = [];
+ obj_ncombat.ctally_injure_severity = 0;
+ obj_ncombat.ctally_is_vehicle = false;
+}
+
+/// @desc Buffers killing volleys that share a weapon and target, summing shots and kills so a split
+/// volley (many sub-stacks firing at one target) collapses into one line. The first volley's
+/// rich flavour is kept and used verbatim if no second volley merges with it. Switching target
+/// flushes the previous target first, keeping the log chronological.
+function combat_kill_tally_add(_target, _weapon, _shots, _kills, _attack, _color, _leader) {
+ if (obj_ncombat.ktally_target != _target) {
+ combat_kill_tally_flush();
+ obj_ncombat.ktally_target = _target;
+ obj_ncombat.ktally_weapons = {};
+ obj_ncombat.ktally_order = [];
+ obj_ncombat.ktally_leaders = [];
+ }
+ if (!variable_struct_exists(obj_ncombat.ktally_weapons, _weapon)) {
+ obj_ncombat.ktally_weapons[$ _weapon] = { shots: 0, kills: 0, count: 0, attack: _attack, color: _color };
+ array_push(obj_ncombat.ktally_order, _weapon);
+ }
+ var _acc = obj_ncombat.ktally_weapons[$ _weapon];
+ _acc.shots += _shots;
+ _acc.kills += _kills;
+ _acc.count += 1;
+ if (_leader != "") {
+ array_push(obj_ncombat.ktally_leaders, { text: _leader, color: _color });
+ }
+}
+
+/// @desc Posts the buffered kills for the current target: one line per weapon (the original rich
+/// line when only a single volley landed, otherwise a summed " strike at the
+/// ranks, killing "), then any deferred leader lines, and clears.
+function combat_kill_tally_flush() {
+ if (obj_ncombat.ktally_target == undefined) {
+ return;
+ }
+ var _t = obj_ncombat.ktally_target;
+ for (var _i = 0; _i < array_length(obj_ncombat.ktally_order); _i++) {
+ var _w = obj_ncombat.ktally_order[_i];
+ var _acc = obj_ncombat.ktally_weapons[$ _w];
+ if (_acc.count == 1) {
+ add_battle_log_message(_acc.attack, _acc.color);
+ } else {
+ add_battle_log_message($"{_acc.shots} {_w} strike at the {_t} ranks, killing {_acc.kills}.", _acc.color);
+ }
+ }
+ for (var _i = 0; _i < array_length(obj_ncombat.ktally_leaders); _i++) {
+ add_battle_log_message(obj_ncombat.ktally_leaders[_i].text, obj_ncombat.ktally_leaders[_i].color);
+ }
+ obj_ncombat.ktally_target = undefined;
+ obj_ncombat.ktally_weapons = {};
+ obj_ncombat.ktally_order = [];
+ obj_ncombat.ktally_leaders = [];
}
/// @desc Joins firing subjects into "A", "A and B", or "A, B, and C".
diff --git a/scripts/scr_flavor2/scr_flavor2.gml b/scripts/scr_flavor2/scr_flavor2.gml
index 58a78bf8eb..c60cd6a4c9 100644
--- a/scripts/scr_flavor2/scr_flavor2.gml
+++ b/scripts/scr_flavor2/scr_flavor2.gml
@@ -1,5 +1,8 @@
/// @self Id.Instance.obj_pnunit|Id.Instance.obj_enunit
-function scr_flavor2(lost_units_count, target_type, hostile_range, hostile_weapon, hostile_shots, hostile_splash) {
+// Merge seam: the fork's damage-severity reporting (scr_clean passes severity and
+// a vehicle flag; used by the armour-held flavor below) rides on upstream's rebuilt
+// log. Parameters restored with safe defaults.
+function scr_flavor2(lost_units_count, target_type, hostile_range, hostile_weapon, hostile_shots, hostile_splash, damage_severity = 0, target_is_vehicle = false) {
// Generates flavor based on the damage and casualties from scr_shoot, only for the opponent
if (obj_ncombat.wall_destroyed == 1) {
@@ -37,6 +40,47 @@ function scr_flavor2(lost_units_count, target_type, hostile_range, hostile_weapo
_hostile_shots = max(1, round(_hostile_shots / 3));
}
+ // A resolution that attributed no unit at all (damage_data.unit_type left empty:
+ // the shot landed on a block whose relevant pool was already gone) produces
+ // "X strikes at ." lines. They carry no information; skip them entirely.
+ if ((target_type == "") && (lost_units_count == 0)) {
+ exit;
+ }
+
+ // Target readout: the bare unit name ("strikes at Assault") says nothing about what
+ // was hit or how many stand there. Append the block's living strength for that unit
+ // type. scr_flavor2 runs in the target block's own scope, so the arrays are directly
+ // readable. Only the "wall" comparisons below care about the raw value, and walls
+ // are never modified here.
+ if ((target_type != "wall") && is_string(target_type) && (target_type != "")) {
+ var _target_count = 0;
+ if (target_is_vehicle) {
+ if (variable_instance_exists(id, "veh_type")) {
+ for (var _tc = 0; _tc < array_length(veh_type); _tc++) {
+ if ((veh_type[_tc] == target_type) && (_tc < array_length(veh_dead)) && (veh_dead[_tc] == 0) && (_tc < array_length(veh_hp)) && (veh_hp[_tc] > 0)) {
+ _target_count++;
+ }
+ }
+ }
+ if (_target_count > 0) {
+ target_type = $"{target_type} ({_target_count} remaining)";
+ }
+ } else if (target_type == "Imperial Guardsman") {
+ if (variable_instance_exists(id, "men") && (men > 0)) {
+ target_type = $"{target_type} ranks ({men} strong)";
+ }
+ } else if (variable_instance_exists(id, "marine_type")) {
+ for (var _tc = 0; _tc < array_length(marine_type); _tc++) {
+ if ((marine_type[_tc] == target_type) && (_tc < array_length(marine_dead)) && (marine_dead[_tc] == 0)) {
+ _target_count++;
+ }
+ }
+ if (_target_count > 0) {
+ target_type = $"{target_type} ranks ({_target_count} strong)";
+ }
+ }
+ }
+
var flavor = 0;
if (_hostile_weapon == "Daemonette Melee") {
@@ -362,17 +406,9 @@ function scr_flavor2(lost_units_count, target_type, hostile_range, hostile_weapo
if (flavor == 0) {
flavor = true;
if (_hostile_shots == 1) {
- if (lost_units_count == 0) {
- m1 += $"{_hostile_weapon} strikes at {target_type}, no casualties.";
- } else {
- m1 += $"{_hostile_weapon} strikes at {target_type}. ";
- }
+ m1 += $"{_hostile_weapon} strikes at {target_type}. ";
} else {
- if (lost_units_count == 0) {
- m1 += $"{_hostile_shots} {_hostile_weapon}s strike at {target_type}, no casualties.";
- } else {
- m1 += $"{_hostile_shots} {_hostile_weapon}s strike at {target_type}. ";
- }
+ m1 += $"{_hostile_shots} {_hostile_weapon}s strike at {target_type}. ";
}
}
@@ -410,8 +446,11 @@ function scr_flavor2(lost_units_count, target_type, hostile_range, hostile_weapo
special = is_specialist(unit_role, SPECIALISTS_HEADS) || unit_role == obj_ini.role[100][eROLE.CHAPTERMASTER] || unit_role == "Venerable " + string(obj_ini.role[100][eROLE.DREADNOUGHT]) || unit_role == obj_ini.role[100][eROLE.CAPTAIN] || obj_ncombat.player_max <= 6;
if (!special) {
- plural = units_lost > 1 ? "s" : "";
- m2 += $"{units_lost} {unit_role}{plural}, ";
+ var _plural_name = unit_role;
+ if (units_lost > 1) {
+ _plural_name = (unit_role == "Guardsman") ? "Guardsmen" : (unit_role + "s");
+ }
+ m2 += $"{units_lost} {_plural_name}, ";
} else {
him = -1; // Find which unit this is
for (var marine = 0; marine < marine_length; marine++) {
@@ -477,6 +516,20 @@ function scr_flavor2(lost_units_count, target_type, hostile_range, hostile_weapo
}
}
+ // No kills but the attack connected: report the damage instead of a bare attack verb, scaled by
+ // how close it came to a kill. Severity is 0 for targets that do not track it (e.g. guardsmen),
+ // which lands on the lowest tier.
+ if ((m2 == "") && (lost_units_count == 0) && (hostile_shots > 0) && (target_type != "wall")) {
+ m2 = incoming_damage_flavor(damage_severity, target_is_vehicle);
+ // Enemy fire reads in the red family, mirroring the player's green. A shot that
+ // bounces with no effect renders grey (neutral, same as armour-holds on both
+ // sides); a hit that wounds or pierces without killing renders bright red so
+ // incoming damage stands out as a threat rather than borrowing the player's
+ // light green. (Colour paradigm: green/light green = you dealing damage,
+ // red/bright red = the enemy dealing damage, grey = a save with no effect.)
+ mes_color = (damage_severity < 0.10) ? eMSG_COLOR.WHITE : eMSG_COLOR.BRIGHT_RED;
+ }
+
mes = m1 + m2 + m3;
if (string_length(mes) > 3) {
@@ -484,3 +537,31 @@ function scr_flavor2(lost_units_count, target_type, hostile_range, hostile_weapo
obj_ncombat.alarm[3] = 2;
}
}
+
+
+// Merge seam: fork-owned severity flavor (restored complete from the pre-merge
+// tree; the first restoration appended a truncated fragment that broke the whole
+// script's parse, degrading scr_flavor2 to a zero-argument function).
+/// @function incoming_damage_flavor
+/// @description Combat-log sentence for an enemy hit that did NOT kill, scaled by how close it came
+/// to a kill (_severity = damage over the target's health before the hit, 0..1). Vehicles get
+/// armour/hull language, infantry get wound language. Appended after the attack verb, e.g.
+/// "24 Big Shootaz roar and blast away at Rhino. Piercing the armour." Edit the wording freely;
+/// only the tier thresholds matter to the rest of the code.
+/// @param {real} _severity 0..1
+/// @param {bool} _is_vehicle target is a vehicle
+/// @returns {string}
+function incoming_damage_flavor(_severity, _is_vehicle) {
+ if (_is_vehicle) {
+ if (_severity < 0.10) return choose("Only peeling the paint.", "Just chipping the paint.", "Pinging off the armour.", "Bouncing off the hull.", "Only scratching the armour.");
+ if (_severity < 0.35) return choose("Barely putting a dent in the armour.", "Leaving a few dents in the hull.", "Only scuffing the armour.");
+ if (_severity < 0.65) return choose("Piercing the armour.", "Punching through the plating.", "Cracking the armour open.");
+ if (_severity < 0.90) return choose("Punching a huge hole in the armour.", "Tearing a gash through the hull.", "Blowing a hole in the plating.");
+ return choose("Almost destroying it.", "Leaving it a smoking wreck.", "Nearly tearing it apart.");
+ }
+ if (_severity < 0.10) return choose("But the armour holds.", "But it is shrugged off.");
+ if (_severity < 0.35) return choose("Drawing blood.", "Causing light wounds.", "Leaving a few grazes.");
+ if (_severity < 0.65) return choose("Wounding several.", "Bloodying the ranks.", "Leaving wounded behind.");
+ if (_severity < 0.90) return choose("Leaving deep wounds.", "Savaging the ranks.", "Leaving many badly wounded.");
+ return choose("Leaving the survivors maimed and reeling.", "All but breaking them.", "Leaving them maimed and scattered.");
+}
diff --git a/scripts/scr_fleet_advisor/scr_fleet_advisor.gml b/scripts/scr_fleet_advisor/scr_fleet_advisor.gml
index ead8f667f8..0696818b72 100644
--- a/scripts/scr_fleet_advisor/scr_fleet_advisor.gml
+++ b/scripts/scr_fleet_advisor/scr_fleet_advisor.gml
@@ -183,6 +183,9 @@ function scr_fleet_advisor() {
location.contents = obj_ini.ship_location[i];
hp.contents = $"{round(obj_ini.ship_hp[i] / obj_ini.ship_maxhp[i] * 100)}%";
carrying.contents = $"{obj_ini.ship_carrying[i]}/{obj_ini.ship_capacity[i]}";
+ if (obj_ini.ship_guardsmen[i] > 0) {
+ carrying.contents += $" +{scr_display_number(obj_ini.ship_guardsmen[i])} IG";
+ }
}
for (var g = 0; g < array_length(_columns_array); g++) {
@@ -234,7 +237,8 @@ function scr_fleet_advisor() {
cn.temp[119] = scr_ship_occupants(i);
}
}
- tooltip_draw($"Carrying ({cn.temp[118]}): {cn.temp[119]}");
+ var _ig_line = (obj_ini.ship_guardsmen[i] > 0) ? $" | Imperial Guard embarked: {scr_display_number(obj_ini.ship_guardsmen[i])}" : "";
+ tooltip_draw($"Carrying ({cn.temp[118]}): {cn.temp[119]}{_ig_line}");
if (_goto_button.click()) {
with (obj_p_fleet) {
var _fleet_ships = fleet_full_ship_array();
diff --git a/scripts/scr_get_item_names/scr_get_item_names.gml b/scripts/scr_get_item_names/scr_get_item_names.gml
index 1f1baeb389..fa61a18051 100644
--- a/scripts/scr_get_item_names/scr_get_item_names.gml
+++ b/scripts/scr_get_item_names/scr_get_item_names.gml
@@ -62,7 +62,7 @@ function push_marine_ranged_weapons_item_names(_item_names) {
/// @param {array} _item_names - The list to append to.
/// @returns {void}
function push_marine_melee_weapons_item_names(_item_names) {
- var item_count = 19;
+ var item_count = 20;
var initial_size = array_length(_item_names);
array_resize(_item_names, initial_size + item_count);
@@ -82,10 +82,11 @@ function push_marine_melee_weapons_item_names(_item_names) {
_item_names[@ index++] = "Thunder Hammer";
_item_names[@ index++] = "Heavy Thunder Hammer";
_item_names[@ index++] = "Crozius Arcanum";
+ _item_names[@ index++] = "Omnissian Axe";
_item_names[@ index++] = "Boarding Shield";
_item_names[@ index++] = "Storm Shield";
_item_names[@ index++] = "Bolt Pistol";
- _item_names[@ index++] = "Bolter"; // 19
+ _item_names[@ index++] = "Bolter"; // 20
}
/// @description This function appends the list of marine armour items to the given list.
diff --git a/scripts/scr_ini_ship_cleanup/scr_ini_ship_cleanup.gml b/scripts/scr_ini_ship_cleanup/scr_ini_ship_cleanup.gml
index 73e1b13c2a..ae90424800 100644
--- a/scripts/scr_ini_ship_cleanup/scr_ini_ship_cleanup.gml
+++ b/scripts/scr_ini_ship_cleanup/scr_ini_ship_cleanup.gml
@@ -100,6 +100,8 @@ function scr_kill_ship(index) {
array_delete(ship_carrying, index, 1);
array_delete(ship_contents, index, 1);
array_delete(ship_turrets, index, 1);
+ array_delete(ship_guardsmen, index, 1);
+ array_delete(ship_guardsmen_max, index, 1);
if (!in_warp) {
if (_nearest_star != noone) {
diff --git a/scripts/scr_initialize_custom/scr_initialize_custom.gml b/scripts/scr_initialize_custom/scr_initialize_custom.gml
index 7bee2adb51..99780f0f8e 100644
--- a/scripts/scr_initialize_custom/scr_initialize_custom.gml
+++ b/scripts/scr_initialize_custom/scr_initialize_custom.gml
@@ -1475,7 +1475,9 @@ function scr_initialize_custom() {
load_default_gear(eROLE.VETERAN, "Veteran", "Combiflamer", "Combat Knife", STR_ANY_POWER_ARMOUR, "", "");
load_default_gear(eROLE.TERMINATOR, "Terminator", "Power Fist", "Storm Bolter", "Terminator Armour", "", "");
load_default_gear(eROLE.CAPTAIN, "Captain", "Power Sword", "Bolt Pistol", STR_ANY_POWER_ARMOUR, "", "Iron Halo");
- load_default_gear(eROLE.DREADNOUGHT, "Dreadnought", "Close Combat Weapon", "Lascannon", "Dreadnought", "", "");
+ // "Lascannon" is not in the dreadnought ranged weapon list (push_dreadnought_ranged_weapons_item_names),
+ // so start-of-game dreads spawned with it could never re-equip one. Use the dread-pattern twin-linked version.
+ load_default_gear(eROLE.DREADNOUGHT, "Dreadnought", "Close Combat Weapon", "Twin Linked Lascannon", "Dreadnought", "", "");
load_default_gear(eROLE.CHAMPION, "Champion", "Power Sword", "Bolt Pistol", STR_ANY_POWER_ARMOUR, "", "Combat Shield");
load_default_gear(eROLE.TACTICAL, "Tactical", "Bolter", "Combat Knife", STR_ANY_POWER_ARMOUR, "", "");
load_default_gear(eROLE.DEVASTATOR, "Devastator", "", "Combat Knife", STR_ANY_POWER_ARMOUR, "", "");
diff --git a/scripts/scr_inquisition_inspection/scr_inquisition_inspection.gml b/scripts/scr_inquisition_inspection/scr_inquisition_inspection.gml
index ac4f99ce3f..eee038d10a 100644
--- a/scripts/scr_inquisition_inspection/scr_inquisition_inspection.gml
+++ b/scripts/scr_inquisition_inspection/scr_inquisition_inspection.gml
@@ -514,9 +514,19 @@ function inquisitor_inspection_structure() constructor {
}
finalize_contraband_popup();
+ } else {
+ // Findings existed but the Inquisitor's tolerance roll let them slide.
+ // This branch was previously silent, while the "blind eye" text below
+ // sat on the CLEAN branch, so a spotless chapter was told the
+ // Inquisitor had overlooked its "heretical dealings" and a chapter
+ // with real findings heard nothing. This is the community-reported
+ // "inspections always find something but never say what". Bug exists
+ // verbatim in upstream main.
+ var _tolerated_string = "The Inquisitor has chosen to turn a blind eye to some of your more questionable dealings on this occasion. It would perhaps be wise to be more careful in future.";
+ scr_popup("Inquisitor Finish Inspection", _tolerated_string, "inquisition");
}
} else {
- var _inspection_passed_string = "The inquisitor Has chosen to turnj a blind eye to some of your more heretical dealings on this occaision. However, it would perhaps be wise to be more careful in furture ";
+ var _inspection_passed_string = "The Inquisitor concludes the inspection having found nothing of concern. The chapter's conduct is, for now, beyond reproach.";
scr_popup("Inquisitor Finish Inspection", _inspection_passed_string, "inquisition");
}
};
diff --git a/scripts/scr_load/scr_load.gml b/scripts/scr_load/scr_load.gml
index ff3f006676..06b5d12aa4 100644
--- a/scripts/scr_load/scr_load.gml
+++ b/scripts/scr_load/scr_load.gml
@@ -144,6 +144,31 @@ function scr_load(save_part, save_id) {
obj_event_log.event = obj_saveload.GameSave.EventLog;
LOGGER.info("EVENT LOG Loaded");
+ // Sanitize NaN coordinates baked into older saves. A fleet whose x/y went NaN
+ // (from the pre-fix action_eta divide-by-zero) keeps re-baking that NaN into
+ // obj_controller through the fleet-focus copy, which then crashes the collision
+ // grid when obj_cursor is recreated on a room change. Snap any broken instance
+ // back to a valid star so the bad value can never reach the controller again.
+ var _anchor = instance_nearest(0, 0, obj_star);
+ var _ax = instance_exists(_anchor) ? _anchor.x : 0;
+ var _ay = instance_exists(_anchor) ? _anchor.y : 0;
+ with (obj_p_fleet) {
+ if (is_nan(x) || is_nan(y)) {
+ x = _ax;
+ y = _ay;
+ }
+ }
+ with (obj_en_fleet) {
+ if (is_nan(x) || is_nan(y)) {
+ x = _ax;
+ y = _ay;
+ }
+ }
+ if (is_nan(obj_controller.x) || is_nan(obj_controller.y)) {
+ obj_controller.x = _ax;
+ obj_controller.y = _ay;
+ }
+
obj_saveload.alarm[1] = 5;
obj_controller.invis = false;
global.load = -1;
diff --git a/scripts/scr_load_all/scr_load_all.gml b/scripts/scr_load_all/scr_load_all.gml
index 85f0fed57d..124c41efb9 100644
--- a/scripts/scr_load_all/scr_load_all.gml
+++ b/scripts/scr_load_all/scr_load_all.gml
@@ -4,6 +4,21 @@ function scr_load_all(select_units) {
// Load / Select All
if (select_units) {
man_size = 0;
+ // If no location is anchored yet, anchor it to the first selectable unit so a
+ // straight Select All works even when nothing was individually clicked first
+ // (e.g. a body of guardsmen mustered on a planet). The vehicle branch below
+ // already self-anchors; this gives the man branch the same behaviour.
+ if (selecting_location == "") {
+ for (var j = 0; j < array_length(display_unit); j++) {
+ var anchor_unit = display_unit[j];
+ if (is_struct(anchor_unit) && (man[j] == "man") && (ma_god[j] < 10) && (anchor_unit.assignment() == "none")) {
+ selecting_location = ma_loc[j];
+ selecting_planet = ma_wid[j];
+ selecting_ship = ma_lid[j];
+ break;
+ }
+ }
+ }
// This sets the maximum size of marines in a company to 200 size
for (var i = 0; i < array_length(display_unit); i++) {
unit = display_unit[i];
diff --git a/scripts/scr_management/scr_management.gml b/scripts/scr_management/scr_management.gml
index 4a5befcff6..17ad1579c4 100644
--- a/scripts/scr_management/scr_management.gml
+++ b/scripts/scr_management/scr_management.gml
@@ -61,6 +61,18 @@ function scr_management(argument0) {
pane.line = array_join(pane.line, _lib_units.create_plural_strings_array());
+ pane = instance_create(1325, 180 - 48, obj_managment_panel);
+ pane.company = 0;
+ pane.manage = 16;
+ pane.header = 2;
+ pane.title = "AUXILIA";
+
+ var _auxilia_units = collect_role_group("all", "", false, {roles: auxilia_roles()}, true);
+
+ _auxilia_units = _auxilia_units.index_roles();
+
+ pane.line = array_join(pane.line, _auxilia_units.create_plural_strings_array());
+
pane = instance_create(700, 180 - 48, obj_managment_panel);
pane.company = 0;
pane.manage = 11;
diff --git a/scripts/scr_marine_struct/scr_marine_struct.gml b/scripts/scr_marine_struct/scr_marine_struct.gml
index b09d0696a3..081762014b 100644
--- a/scripts/scr_marine_struct/scr_marine_struct.gml
+++ b/scripts/scr_marine_struct/scr_marine_struct.gml
@@ -192,6 +192,13 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
load_json_data(global.base_stats[$ class]);
}
+ // Roll array-form stats into numbers BEFORE any equipment is applied. Classes with
+ // ranged stats in unit_stats.json (e.g. guardsman constitution [28, 1, "max"]) used to
+ // keep the raw array until after alter_equipment below, but equipping armour runs
+ // hp_portion -> max_health, which since the max-health rework reads constitution
+ // directly. Multiplying the un-rolled array threw "Variable is malformed" and aborted
+ // unit creation (Governor-bought Guardsmen crashed and never arrived). Upstream main
+ // has the same latent ordering bug.
var stats = [
"constitution",
"strength",
@@ -572,6 +579,20 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
var unit_role = role();
var arm = armour();
var sz = 1;
+ // Guardsmen are line infantry, racked aboard far denser than power-armoured
+ // Astartes, so a single trooper takes only a tenth of a marine's berth. A Guard
+ // Squad is left at a full slot, since one already stands in for a whole squad. A
+ // Guard Sergeant bunks with his men, so he takes the same tenth-slot as a trooper.
+ if (unit_role == "Guardsman" || unit_role == "Guard Sergeant" || unit_role == "Veteran Guard") {
+ size = 0.1;
+ return size;
+ }
+ // Heavy Weapons Team: three crew plus the gun and its ammunition. Half a marine
+ // berth rather than a lone trooper's tenth-slot.
+ if (unit_role == "Heavy Weapons Team") {
+ size = 0.5;
+ return size;
+ }
if (string_count("Dread", arm) > 0) {
sz += 5;
} else if (array_contains(global.list_terminator_armour, arm)) {
@@ -604,6 +625,12 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
_gear_mod /= 100;
_max_h *= _gear_mod;
}
+ if (role() == "Guard Squad") {
+ _max_h *= GUARD_SQUAD_SIZE; // a squad shares one pooled health bar and dies as a whole
+ }
+ if (role() == "Heavy Weapons Team") {
+ _max_h *= GUARD_HEAVY_WEAPONS_TEAM_SIZE; // a 3-man weapons team shares one pooled health bar
+ }
return round(_max_h);
};
@@ -1183,10 +1210,16 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
};
static get_weapon_one_data = function(type = "all") {
+ if (is_dreadnought()) {
+ return gear_weapon_data("weapon", dread_infantry_weapon(weapon_one()), type, false, weapon_one_quality, dread_infantry_weapon(weapon_one(true)));
+ }
return gear_weapon_data("weapon", weapon_one(), type, false, weapon_one_quality, weapon_one(true));
};
static get_weapon_two_data = function(type = "all") {
+ if (is_dreadnought()) {
+ return gear_weapon_data("weapon", dread_infantry_weapon(weapon_two()), type, false, weapon_two_quality, dread_infantry_weapon(weapon_two(true)));
+ }
return gear_weapon_data("weapon", weapon_two(), type, false, weapon_two_quality, weapon_two(true));
};
@@ -1228,6 +1261,14 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
} else if (base_group == "human") {
ranged_hands_limit = 1;
}
+ if (role() == "Heavy Weapons Team") {
+ // A weapons team is GUARD_HEAVY_WEAPONS_TEAM_SIZE crew serving one deployed
+ // emplacement, not one man hauling it. The human base of 1 left every
+ // ranged_hands 2 heavy weapon (Heavy Bolter, Lascannon, Autocannon) over the
+ // cap, so the team fired its own gun permanently encumbered. Give the unit
+ // the crew's combined hands instead.
+ ranged_hands_limit = GUARD_HEAVY_WEAPONS_TEAM_SIZE;
+ }
carry_string += $"Base: {ranged_hands_limit}#";
if (strength >= 50) {
ranged_hands_limit += 0.5;
@@ -1424,6 +1465,10 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
} else if (base_group == "human") {
melee_hands_limit = 1;
}
+ if (role() == "Heavy Weapons Team") {
+ // Crew hands, matching the ranged cap above.
+ melee_hands_limit = GUARD_HEAVY_WEAPONS_TEAM_SIZE;
+ }
carry_string += $"Base: {melee_hands_limit}#";
if (strength >= 50) {
melee_hands_limit += 0.25;
@@ -1881,6 +1926,25 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
static allocate_unit_to_fresh_spawn = function(type = "default") {
var homestar = noone;
var spawn_location_chosen = false;
+ if (type == "home_planet") {
+ // Place on the chapter's actual owned planet in the home system and keep the unit
+ // off-ship, so bulk spawns are not scattered across the fleet's ships. Scan for a
+ // player-owned planet rather than trusting obj_ini.home_planet, which can be a
+ // stale index pointing at a planet that does not exist in this system.
+ ship_location = -1;
+ location_string = obj_ini.home_name;
+ planet_location = obj_ini.home_planet;
+ var homestar = find_star_by_name(obj_ini.home_name);
+ if (homestar != "none") {
+ for (var i = 1; i <= homestar.planets; i++) {
+ if (homestar.p_owner[i] == eFACTION.PLAYER) {
+ planet_location = i;
+ break;
+ }
+ }
+ }
+ return;
+ }
if (((type == "home") || (type == "default")) && (obj_ini.fleet_type == ePLAYER_BASE.HOME_WORLD)) {
homestar = find_star_by_name(obj_ini.home_name);
} else if (type != "ship") {
@@ -2257,6 +2321,26 @@ function TTRPG_stats(faction, comp, mar, class = "marine", other_spawn_data = {}
};
}
+/// @desc Maps a dreadnought's heavy Mount/Sponson weapon variant to its infantry
+/// equivalent. Per upstream direction, a dreadnought fires the infantry version of what it
+/// carries: the Mount/Sponson variants exist for vehicles and hit far harder, which was
+/// only ever a way to brute-force vehicle damage before the penetration roll existed.
+/// Weapons that are already infantry, or unique dreadnought weapons without an infantry
+/// counterpart (Kheres Assault Cannon, Inferno Cannon), pass through unchanged, as do
+/// non-string equips (artifacts).
+function dread_infantry_weapon(_weapon) {
+ if (!is_string(_weapon)) {
+ return _weapon;
+ }
+ switch (_weapon) {
+ case "Twin Linked Assault Cannon Mount": return "Assault Cannon";
+ case "Twin Linked Heavy Bolter Mount": return "Twin Linked Heavy Bolter";
+ case "Twin Linked Lascannon Mount": return "Twin Linked Lascannon";
+ case "Twin Linked Heavy Flamer Sponsons": return "Heavy Flamer";
+ }
+ return _weapon;
+}
+
function jsonify_marine_struct(company, marine, stringify = true) {
var copy_marine_struct = obj_ini.TTRPG[company][marine]; //grab marine structure
var new_marine = {};
diff --git a/scripts/scr_move_unit_info/scr_move_unit_info.gml b/scripts/scr_move_unit_info/scr_move_unit_info.gml
index ee8a427ea5..6d43aeb433 100644
--- a/scripts/scr_move_unit_info/scr_move_unit_info.gml
+++ b/scripts/scr_move_unit_info/scr_move_unit_info.gml
@@ -1,6 +1,58 @@
+/// @desc Ensure every parallel per-company marine array (and TTRPG) for this company is
+/// long enough to index this slot, appending the blank defaults find_company_open_slot
+/// uses. Companies are uncapped, so a marine can occupy a slot beyond the legacy 501-length
+/// arrays (for example a save where name[] grew past the flat arrays). Padding each array
+/// independently up to the slot keeps them in sync so reads and writes at that slot never
+/// go out of range. Each array is padded on its own length, so an array that already
+/// reaches the slot is left untouched while a shorter sibling is caught up.
+function ensure_marine_slot(_company, _slot) {
+ while (array_length(obj_ini.race[_company]) <= _slot) {
+ array_push(obj_ini.race[_company], 1);
+ }
+ while (array_length(obj_ini.name[_company]) <= _slot) {
+ array_push(obj_ini.name[_company], "");
+ }
+ while (array_length(obj_ini.role[_company]) <= _slot) {
+ array_push(obj_ini.role[_company], "");
+ }
+ while (array_length(obj_ini.wep1[_company]) <= _slot) {
+ array_push(obj_ini.wep1[_company], "");
+ }
+ while (array_length(obj_ini.spe[_company]) <= _slot) {
+ array_push(obj_ini.spe[_company], "");
+ }
+ while (array_length(obj_ini.wep2[_company]) <= _slot) {
+ array_push(obj_ini.wep2[_company], "");
+ }
+ while (array_length(obj_ini.armour[_company]) <= _slot) {
+ array_push(obj_ini.armour[_company], "");
+ }
+ while (array_length(obj_ini.gear[_company]) <= _slot) {
+ array_push(obj_ini.gear[_company], "");
+ }
+ while (array_length(obj_ini.mobi[_company]) <= _slot) {
+ array_push(obj_ini.mobi[_company], "");
+ }
+ while (array_length(obj_ini.age[_company]) <= _slot) {
+ array_push(obj_ini.age[_company], 0);
+ }
+ while (array_length(obj_ini.god[_company]) <= _slot) {
+ array_push(obj_ini.god[_company], 0);
+ }
+ while (array_length(obj_ini.TTRPG[_company]) <= _slot) {
+ array_push(obj_ini.TTRPG[_company], new TTRPG_stats("chapter", _company, array_length(obj_ini.TTRPG[_company]), "blank"));
+ }
+}
+
function scr_move_unit_info(start_company, end_company, start_slot, end_slot, eval_squad = true) {
//eval_squad : determine whether movement of units between companies should decide to check their squad coherency or not, defaults to true
+ // Companies are uncapped, so either slot can sit beyond the legacy 501-length parallel
+ // arrays. Pad every array for both companies up to the slots in use so the copies below
+ // never read or write out of range (the crash when a company exceeded 500 marines).
+ ensure_marine_slot(start_company, start_slot);
+ ensure_marine_slot(end_company, end_slot);
+
//this makes sure coherency of the unit's squad and the squads logging of the unit location are kept up to date
var unit = obj_ini.TTRPG[start_company][start_slot];
if (eval_squad) {
diff --git a/scripts/scr_player_combat_weapon_stacks/scr_player_combat_weapon_stacks.gml b/scripts/scr_player_combat_weapon_stacks/scr_player_combat_weapon_stacks.gml
index bd5c70399c..8b2a6d4632 100644
--- a/scripts/scr_player_combat_weapon_stacks/scr_player_combat_weapon_stacks.gml
+++ b/scripts/scr_player_combat_weapon_stacks/scr_player_combat_weapon_stacks.gml
@@ -57,6 +57,36 @@ function add_data_to_stack(stack_index, weapon, unit_damage = false, head_role =
}
}
+/// @self Asset.GMObject.obj_pnunit
+/// Adds a single named weapon to the stacks, firing "count" times. Used for Guard
+/// Squads, where one unit struct stands for a whole squad: the default weapon (wep1)
+/// fires once per man and the special weapon (wep2) fires once for the squad. Mirrors
+/// the enemy horde firepower in scr_en_weapon (attack times the number firing) rather
+/// than the normal single-primary-weapon path, which would only fire the higher-attack gun.
+function add_squad_weapon(weapon_name, count, head_role = false, unit = "none") {
+ if (weapon_name == "") {
+ return;
+ }
+ var _w = gear_weapon_data("weapon", weapon_name, "all", false, "standard");
+ if (!is_struct(_w) || _w.name == "") {
+ return;
+ }
+ var _idx = find_stack_index(_w.name, head_role, unit);
+ if (_idx < 0) {
+ return;
+ }
+ att[_idx] += _w.attack * count;
+ apa[_idx] = _w.arp;
+ range[_idx] = _w.range;
+ wep_num[_idx] += count;
+ splash[_idx] = _w.spli;
+ wep[_idx] = _w.name;
+ if (obj_ncombat.started == 0) {
+ ammo[_idx] = _w.ammo;
+ }
+ wep_owner[_idx] = "assorted";
+}
+
/// @self Asset.GMObject.obj_pnunit
function find_stack_index(weapon_name, head_role = false, unit = "none") {
final_index = -1;
@@ -78,6 +108,35 @@ function find_stack_index(weapon_name, head_role = false, unit = "none") {
return final_index;
}
+/// @self Asset.GMObject.obj_pnunit
+/// Like find_stack_index, but caps how many shooters share a stack so a large body of
+/// identically-armed troops (a guard regiment of lasguns) splits into several smaller stacks
+/// instead of one regiment-wide one. Each capped stack fires and targets independently in
+/// combat, the way the enemy's obj_enunit blocks already do. Fills a partial chunk first,
+/// opens a fresh stack when the current one is full, and only as a last resort (all 71 stack
+/// slots used) merges over the cap so fire is never silently dropped.
+function find_capped_stack_index(weapon_name, cap) {
+ // 1. a matching, un-led chunk that still has room
+ for (var si = 1; si < array_length(wep); si++) {
+ if (wep[si] == weapon_name && wep_title[si] == "" && wep_num[si] < cap) {
+ return si;
+ }
+ }
+ // 2. no room left in any existing chunk: start a fresh one
+ for (var si = 1; si < array_length(wep); si++) {
+ if (wep[si] == "" && wep_title[si] == "") {
+ return si;
+ }
+ }
+ // 3. stack slots exhausted: merge over the cap rather than drop the shots
+ for (var si = 1; si < array_length(wep); si++) {
+ if (wep[si] == weapon_name && wep_title[si] == "") {
+ return si;
+ }
+ }
+ return -1;
+}
+
/// @self Asset.GMObject.obj_pnunit
function player_head_role_stack(stack_index, unit) {
wep_title[stack_index] = unit.role();
@@ -127,10 +186,87 @@ function scr_player_combat_weapon_stacks() {
exit;
}
+ // ===== OBSOLETE: planetary Guard (iteration 1) =====
+ // First-iteration "planetary Guard" men-block (PDF / Imperial Navy). The obj_pnunit
+ // `guard` flag is never set to 1 anywhere in the project, so this branch is dead and
+ // never runs. Left for reference only. The live guardsmen are individual unit_struct
+ // units with role "Guardsman" and fire through the normal weapon path below, not here.
+ if (guard == 1) {
+ var _gi = 0;
+ var _pg = men; // current Guardsmen in this block
+
+ // Massed lasguns: one per man, attack 60, armour pierce 1, range 6, 30 rounds.
+ _gi += 1;
+ wep[_gi] = "Lasgun";
+ wep_num[_gi] = max(1, _pg);
+ range[_gi] = 6;
+ att[_gi] = 60 * wep_num[_gi];
+ apa[_gi] = 1;
+ ammo[_gi] = 30;
+ splash[_gi] = 0;
+
+ // Bayonets (melee, range 1). Required or the block locks up in melee: once an
+ // enemy is adjacent the fire logic disables every ranged weapon, and with no
+ // melee weapon the Guard can neither shoot nor swing. Guardsmen are poor in
+ // melee, so this is a weak profile.
+ _gi += 1;
+ wep[_gi] = "Bayonet";
+ wep_num[_gi] = max(1, _pg);
+ range[_gi] = 1;
+ att[_gi] = 12 * wep_num[_gi];
+ apa[_gi] = 0;
+ ammo[_gi] = -1;
+ splash[_gi] = 0;
+
+ // Heavy bolters: attack 120, range 16. Anti-infantry support only. The Guard
+ // carry no anti-tank weapon by design, so a pure-infantry force cannot crack
+ // armour; they bleed against vehicles unless a Leman Russ tank line is present.
+ _gi += 1;
+ wep[_gi] = "Heavy Bolter";
+ wep_num[_gi] = max(1, round(_pg / 200));
+ range[_gi] = 16;
+ att[_gi] = 120 * wep_num[_gi];
+ apa[_gi] = 0;
+ ammo[_gi] = -1;
+ splash[_gi] = 0;
+
+ exit;
+ }
+
+ if (guard == 2) {
+ // Leman Russ tank line, fielded as its own block separate from the infantry,
+ // the way the enemy Imperial Guard keep tanks out of their soldier lines.
+ // Battle Cannon 300 and Lascannon 200, both armour-piercing, scaled to the
+ // tanks still alive. This is the Guard's only anti-armour.
+ var _gi = 0;
+ var _tk = veh;
+
+ _gi += 1;
+ wep[_gi] = "Battle Cannon";
+ wep_num[_gi] = max(1, _tk);
+ range[_gi] = 12;
+ att[_gi] = 300 * wep_num[_gi];
+ apa[_gi] = round(att[_gi] * 0.6);
+ ammo[_gi] = -1;
+ splash[_gi] = 0;
+
+ _gi += 1;
+ wep[_gi] = "Lascannon";
+ wep_num[_gi] = max(1, _tk);
+ range[_gi] = 20;
+ att[_gi] = 200 * wep_num[_gi];
+ apa[_gi] = round(att[_gi] * 0.8);
+ ammo[_gi] = -1;
+ splash[_gi] = 0;
+
+ exit;
+ }
+
+ var i, g = 0;
veh = 0;
men = 0;
dreads = 0;
- for (var i = 0; i < array_length(att); i++) {
+ for (i = 0; i < array_length(att); i++) {
dudes_num[i] = 0;
att[i] = 0;
apa[i] = 0;
@@ -141,7 +277,7 @@ function scr_player_combat_weapon_stacks() {
var dreaded = false;
- for (var g = 0; g < array_length(unit_struct); g++) {
+ for (g = 0; g < array_length(unit_struct); g++) {
var unit = unit_struct[g];
if (is_struct(unit)) {
if (unit.hp() > 0) {
@@ -182,6 +318,16 @@ function scr_player_combat_weapon_stacks() {
}
}
}
+ if (is_struct(mobi_item) && mobi_item.has_tag("bike")) {
+ var _speed_force = unit.speed_force(mobi_item.has_tag("sf_ranged"));
+ var stack_index = find_stack_index(_speed_force.name, head_role, unit);
+ if (stack_index > -1) {
+ add_data_to_stack(stack_index, _speed_force, false, head_role, unit);
+ if (head_role) {
+ player_head_role_stack(stack_index, unit);
+ }
+ }
+ }
if (is_struct(mobi_item)) {
add_second_profiles_to_stack(mobi_item);
@@ -230,12 +376,36 @@ function scr_player_combat_weapon_stacks() {
}
}
if (marine_casting[g] == false) {
- var primary_ranged = unit.ranged_damage_data[3]; //collect unit ranged data
- var weapon_stack_index = find_stack_index(primary_ranged.name, head_role, unit);
- if (weapon_stack_index > -1) {
- add_data_to_stack(weapon_stack_index, primary_ranged, unit.ranged_damage_data[0], head_role, unit);
- if (head_role) {
- player_head_role_stack(weapon_stack_index, unit);
+ var weapon_stack_index = 0;
+ // ===== RESERVED: Guard Squad (iteration 2) =====
+ // Single pooled-HP squad entity (role "Guard Squad"). Not deployed in
+ // normal play; the live guardsmen are individuals. Kept deliberately for
+ // planned reuse as heavy weapons teams. Do not delete.
+ if (unit.role() == "Guard Squad") {
+ // The squad thins as it takes losses: its surviving strength scales with
+ // remaining health, so a half-health squad fires half its lasguns and a
+ // squad on its last legs fires one. It fires wep1 once per surviving man
+ // and its special weapon (wep2) once while the squad still lives.
+ var _sq_max = unit.max_health();
+ var _sq_men = (_sq_max > 0) ? max(1, ceil(GUARD_SQUAD_SIZE * unit.hp() / _sq_max)) : 1;
+ add_squad_weapon(unit.weapon_one(), _sq_men, head_role, unit);
+ add_squad_weapon(unit.weapon_two(), 1, head_role, unit);
+ } else {
+ var primary_ranged = unit.ranged_damage_data[3]; //collect unit ranged data
+ // Rank-and-file guardsmen split into enemy-block-sized volleys (capped
+ // stacks) instead of merging the whole regiment into one lasgun stack, so
+ // each volley fires and picks its target on its own. Everyone else (Marines,
+ // sergeants, specialists) stacks normally.
+ if (unit.role() == "Guardsman") {
+ weapon_stack_index = find_capped_stack_index(primary_ranged.name, GUARD_VOLLEY_SIZE);
+ } else {
+ weapon_stack_index = find_stack_index(primary_ranged.name, head_role, unit);
+ }
+ if (weapon_stack_index > -1) {
+ add_data_to_stack(weapon_stack_index, primary_ranged, unit.ranged_damage_data[0], head_role, unit);
+ if (head_role) {
+ player_head_role_stack(weapon_stack_index, unit);
+ }
}
}
@@ -258,7 +428,7 @@ function scr_player_combat_weapon_stacks() {
}
}
}
- for (var g = 0; g < array_length(veh_id); g++) {
+ for (g = 0; g < array_length(veh_id); g++) {
if ((veh_id[g] > 0) && (veh_hp[g] > 0) && (veh_dead[g] != 1)) {
if ((veh_id[g] > 0) && (veh_hp[g] > 0)) {
veh_dead[g] = 0;
@@ -321,7 +491,7 @@ function scr_player_combat_weapon_stacks() {
if ((men == 1) && (veh == 0) && (obj_ncombat.player_forces == 1)) {
var h = 0;
- for (var i = 0; i < array_length(unit_struct); i++) {
+ for (i = 0; i < array_length(unit_struct); i++) {
if (h == 0) {
var unit = unit_struct[i];
if (!is_struct(unit)) {
@@ -341,6 +511,11 @@ function scr_player_combat_weapon_stacks() {
/// @self Asset.GMObject.obj_ncombat
function set_up_player_blocks_turn() {
if (instance_exists(obj_pnunit)) {
+ // Advance the line front-first as one ordered pass before any block fires, so a
+ // rear block never stalls behind a front block that has not moved yet (see
+ // move_player_blocks). Movement used to run per-block inside Alarm_0 in arbitrary
+ // instance order, which drifted rear vehicles out of the formation.
+ move_player_blocks();
with (obj_pnunit) {
alarm[3] = 2;
wait_and_execute(3, scr_player_combat_weapon_stacks);
diff --git a/scripts/scr_player_ship_functions/scr_player_ship_functions.gml b/scripts/scr_player_ship_functions/scr_player_ship_functions.gml
index 103488105e..731ed58b29 100644
--- a/scripts/scr_player_ship_functions/scr_player_ship_functions.gml
+++ b/scripts/scr_player_ship_functions/scr_player_ship_functions.gml
@@ -123,6 +123,129 @@ function get_player_ships(location = "", name = "") {
return _ships;
}
+/// @desc Generic per-ship, per-turn action-use counter for the ship action economy. _kind
+/// is "assault" (ground assault), "bombard", or "raid"; each has its OWN independent
+/// counter (obj_ini.ship__uses / obj_ini.ship__turn), so spending one action
+/// never consumes another. Counters are keyed to obj_controller.turn, so a stored count
+/// from any earlier turn reads as zero: no per-turn reset pass and nothing new to save
+/// (after a load the counters simply start fresh). Out-of-range or missing arrays read as
+/// zero.
+function ship_action_used(ship_index, _kind) {
+ if (ship_index < 0) {
+ return 0;
+ }
+ var _uses_var = "ship_" + _kind + "_uses";
+ var _turn_var = "ship_" + _kind + "_turn";
+ if (!variable_instance_exists(obj_ini, _uses_var) || !variable_instance_exists(obj_ini, _turn_var)) {
+ return 0;
+ }
+ var _uses = variable_instance_get(obj_ini, _uses_var);
+ var _turn = variable_instance_get(obj_ini, _turn_var);
+ if ((ship_index >= array_length(_uses)) || (ship_index >= array_length(_turn))) {
+ return 0;
+ }
+ if (_turn[ship_index] != obj_controller.turn) {
+ return 0;
+ }
+ return _uses[ship_index];
+}
+
+/// @desc Spend one use of action _kind on this ship for the current turn.
+function ship_action_spend(ship_index, _kind) {
+ if (ship_index < 0) {
+ return;
+ }
+ var _uses_var = "ship_" + _kind + "_uses";
+ var _turn_var = "ship_" + _kind + "_turn";
+ if (!variable_instance_exists(obj_ini, _uses_var)) {
+ variable_instance_set(obj_ini, _uses_var, []);
+ }
+ if (!variable_instance_exists(obj_ini, _turn_var)) {
+ variable_instance_set(obj_ini, _turn_var, []);
+ }
+ var _uses = variable_instance_get(obj_ini, _uses_var);
+ var _turn = variable_instance_get(obj_ini, _turn_var);
+ while (array_length(_uses) <= ship_index) {
+ array_push(_uses, 0);
+ }
+ while (array_length(_turn) <= ship_index) {
+ array_push(_turn, -1);
+ }
+ if (_turn[ship_index] != obj_controller.turn) {
+ _turn[ship_index] = obj_controller.turn;
+ _uses[ship_index] = 0;
+ }
+ _uses[ship_index] += 1;
+ variable_instance_set(obj_ini, _uses_var, _uses);
+ variable_instance_set(obj_ini, _turn_var, _turn);
+}
+
+/// @desc How many ground assaults this ship has supported this turn (its own counter,
+/// independent of bombardment and raids).
+function ship_assaults_used(ship_index) {
+ return ship_action_used(ship_index, "assault");
+}
+
+/// @desc Spend one ground assault support use on this ship for the current turn.
+function ship_assault_spend(ship_index) {
+ ship_action_spend(ship_index, "assault");
+}
+
+/// @desc How many orbital bombardments this ship has run this turn (its own counter,
+/// independent of ground assaults and raids).
+function ship_bombards_used(ship_index) {
+ return ship_action_used(ship_index, "bombard");
+}
+
+/// @desc How many raids this ship has supported this turn (its own counter, independent
+/// of ground assaults and bombardment).
+function ship_raids_used(ship_index) {
+ return ship_action_used(ship_index, "raid");
+}
+
+/// @desc How many ground assaults this planet's local forces have supported this turn.
+/// Same epoch-keyed pattern as the ship counters, stored on the star instance: counts
+/// from earlier turns read as zero, missing arrays read as zero, nothing new is saved.
+function local_assaults_used(star_object, planet_number) {
+ if (!instance_exists(star_object) || (planet_number < 0)) {
+ return 0;
+ }
+ if (!variable_instance_exists(star_object, "local_assault_uses") || !variable_instance_exists(star_object, "local_assault_turn")) {
+ return 0;
+ }
+ if ((planet_number >= array_length(star_object.local_assault_uses)) || (planet_number >= array_length(star_object.local_assault_turn))) {
+ return 0;
+ }
+ if (star_object.local_assault_turn[planet_number] != obj_controller.turn) {
+ return 0;
+ }
+ return star_object.local_assault_uses[planet_number];
+}
+
+/// @desc Spend one ground assault support use from this planet's local forces.
+function local_assault_spend(star_object, planet_number) {
+ if (!instance_exists(star_object) || (planet_number < 0)) {
+ return;
+ }
+ if (!variable_instance_exists(star_object, "local_assault_uses")) {
+ star_object.local_assault_uses = [];
+ }
+ if (!variable_instance_exists(star_object, "local_assault_turn")) {
+ star_object.local_assault_turn = [];
+ }
+ while (array_length(star_object.local_assault_uses) <= planet_number) {
+ array_push(star_object.local_assault_uses, 0);
+ }
+ while (array_length(star_object.local_assault_turn) <= planet_number) {
+ array_push(star_object.local_assault_turn, -1);
+ }
+ if (star_object.local_assault_turn[planet_number] != obj_controller.turn) {
+ star_object.local_assault_turn[planet_number] = obj_controller.turn;
+ star_object.local_assault_uses[planet_number] = 0;
+ }
+ star_object.local_assault_uses[planet_number] += 1;
+}
+
function new_player_ship_defaults() {
with (obj_ini) {
array_push(ship, "");
@@ -148,6 +271,8 @@ function new_player_ship_defaults() {
array_push(ship_carrying, 0);
array_push(ship_contents, "");
array_push(ship_turrets, 0);
+ array_push(ship_guardsmen, 0);
+ array_push(ship_guardsmen_max, 0);
}
return array_length(obj_ini.ship) - 1;
}
@@ -290,6 +415,8 @@ function new_player_ship(type, start_loc = "home", new_name = "") {
obj_ini.ship_wep_facing[index][5] = "most";
obj_ini.ship_wep_condition[index][5] = "";
obj_ini.ship_capacity[index] = 600;
+ obj_ini.ship_guardsmen_max[index] = 500000; // Battle Barge: Guard auxilia capacity
+ obj_ini.ship_guardsmen[index] = 0;
obj_ini.ship_carrying[index] = 0;
obj_ini.ship_contents[index] = "";
obj_ini.ship_turrets[index] = 3;
@@ -319,6 +446,8 @@ function new_player_ship(type, start_loc = "home", new_name = "") {
obj_ini.ship_wep_facing[index][4] = "most";
obj_ini.ship_wep_condition[index][4] = "";
obj_ini.ship_capacity[index] = 250;
+ obj_ini.ship_guardsmen_max[index] = 150000; // Strike Cruiser: Guard auxilia capacity
+ obj_ini.ship_guardsmen[index] = 0;
obj_ini.ship_carrying[index] = 0;
obj_ini.ship_contents[index] = "";
obj_ini.ship_turrets[index] = 1;
@@ -338,6 +467,8 @@ function new_player_ship(type, start_loc = "home", new_name = "") {
obj_ini.ship_wep_facing[index][1] = "most";
obj_ini.ship_wep_condition[index][1] = "";
obj_ini.ship_capacity[index] = 30;
+ obj_ini.ship_guardsmen_max[index] = 0; // Gladius escort: no Guard capacity
+ obj_ini.ship_guardsmen[index] = 0;
obj_ini.ship_carrying[index] = 0;
obj_ini.ship_contents[index] = "";
obj_ini.ship_turrets[index] = 1;
@@ -360,6 +491,8 @@ function new_player_ship(type, start_loc = "home", new_name = "") {
obj_ini.ship_wep_facing[index][2] = "most";
obj_ini.ship_wep_condition[index][2] = "";
obj_ini.ship_capacity[index] = 25;
+ obj_ini.ship_guardsmen_max[index] = 0; // Hunter escort: no Guard capacity
+ obj_ini.ship_guardsmen[index] = 0;
obj_ini.ship_carrying[index] = 0;
obj_ini.ship_contents[index] = "";
obj_ini.ship_turrets[index] = 1;
@@ -395,6 +528,8 @@ function new_player_ship(type, start_loc = "home", new_name = "") {
obj_ini.ship_wep_facing[index][5] = "most";
obj_ini.ship_wep_condition[index][5] = "";
obj_ini.ship_capacity[index] = 800;
+ obj_ini.ship_guardsmen_max[index] = 1000000; // Gloriana: Guard auxilia capacity
+ obj_ini.ship_guardsmen[index] = 0;
obj_ini.ship_carrying[index] = 0;
obj_ini.ship_contents[index] = "";
obj_ini.ship_turrets[index] = 8;
@@ -459,3 +594,198 @@ function ship_bombard_score(ship_id) {
return _bomb_score;
}
+
+// =====================================================================
+// Imperial Guard Auxilia - player embark / deploy / raise
+// Added by mod. Uses the same p_guardsmen planetary force the Imperial
+// Navy uses, so deployed Guard plug straight into the ground-war AI.
+// =====================================================================
+
+/// @description Pad the parallel Guard arrays out to ship[] length so that indexing
+/// ship_guardsmen[i] by a ship index is always safe. ship_guardsmen starts
+/// empty and only grows as ships are added during play, so a loaded save
+/// (especially one predating these arrays) can restore ship[] while leaving
+/// ship_guardsmen shorter or empty. Without this, reading ship_guardsmen[i]
+/// for a real ship throws "index out of range".
+function ensure_ship_guardsmen_arrays() {
+ with (obj_ini) {
+ var _n = array_length(ship);
+ while (array_length(ship_guardsmen) < _n) {
+ array_push(ship_guardsmen, 0);
+ }
+ while (array_length(ship_guardsmen_max) < _n) {
+ array_push(ship_guardsmen_max, 0);
+ }
+ }
+}
+
+/// @description Total Imperial Guard auxilia currently embarked across all player ships.
+/// @returns {real}
+function player_guardsmen_embarked() {
+ var _total = 0;
+ with (obj_ini) {
+ for (var i = 0; i < array_length(ship_guardsmen); i++) {
+ _total += ship_guardsmen[i];
+ }
+ }
+ return _total;
+}
+
+/// @description Embark Guard from a world you own onto your ships in that system.
+/// Pulls from the planet garrison (p_guardsmen) and fills each ship up
+/// to its ship_guardsmen_max. Returns the number actually loaded.
+/// @param {string} system_name Star system name (e.g. obj_ini.home_name)
+/// @param {real} planet Planet index in that system (e.g. obj_ini.home_planet)
+/// @returns {real}
+function embark_guardsmen(system_name, planet) {
+ var _star = find_star_by_name(system_name);
+ if (_star == "none") {
+ return 0;
+ }
+ if (_star.p_owner[planet] != eFACTION.PLAYER) {
+ return 0; // only from worlds you control
+ }
+
+ var _pdata = new PlanetData(planet, _star);
+ var _available = _pdata.guardsmen;
+ if (_available <= 0) {
+ return 0; // nothing garrisoned to pick up
+ }
+
+ var _loaded = 0;
+ ensure_ship_guardsmen_arrays();
+ with (obj_ini) {
+ for (var i = 0; i < array_length(ship); i++) {
+ if (ship[i] == "") continue; // empty roster slot
+ if (ship_location[i] != system_name) continue; // ship must be here
+ var _space = ship_guardsmen_max[i] - ship_guardsmen[i];
+ if (_space <= 0) continue; // no hull room (escorts = 0)
+ var _take = min(_space, _available - _loaded);
+ if (_take <= 0) break;
+ ship_guardsmen[i] += _take;
+ _loaded += _take;
+ if (_loaded >= _available) break;
+ }
+ }
+
+ _pdata.edit_guardsmen(-_loaded); // remove what we embarked from the planet
+ return _loaded;
+}
+
+/// @description Deploy all embarked Guard from your ships in a system onto a planet.
+/// Adds them to p_guardsmen so the ground-war AI fields them.
+/// @param {string} system_name Star system the fleet is in
+/// @param {real} planet Planet index to garrison
+/// @returns {real}
+function deploy_guardsmen(system_name, planet) {
+ var _star = find_star_by_name(system_name);
+ if (_star == "none") {
+ return 0;
+ }
+
+ var _unloaded = 0;
+ ensure_ship_guardsmen_arrays();
+ with (obj_ini) {
+ for (var i = 0; i < array_length(ship); i++) {
+ if (ship[i] == "") continue;
+ if (ship_location[i] != system_name) continue;
+ if (ship_guardsmen[i] <= 0) continue;
+ _unloaded += ship_guardsmen[i];
+ ship_guardsmen[i] = 0;
+ }
+ }
+ if (_unloaded <= 0) {
+ return 0;
+ }
+
+ var _pdata = new PlanetData(planet, _star);
+ _pdata.edit_guardsmen(_unloaded);
+ return _unloaded;
+}
+
+/// @description OPTIONAL: raise fresh Guard from a controlled world's population,
+/// adding them to that world's garrison so you can then embark them.
+/// Mirrors the Imperial Navy recruit idiom, so it is safe on both
+/// "small" and "large" population worlds.
+/// @param {string} system_name
+/// @param {real} planet
+/// @param {real} amount headcount of Guard to raise
+/// @returns {real}
+function tithe_guardsmen(system_name, planet, amount) {
+ var _star = find_star_by_name(system_name);
+ if (_star == "none") {
+ return 0;
+ }
+ if (_star.p_owner[planet] != eFACTION.PLAYER) {
+ return 0;
+ }
+
+ var _pdata = new PlanetData(planet, _star);
+ var _headcount = _pdata.population_as_small();
+ if (_headcount <= 0) {
+ return 0;
+ }
+
+ amount = min(amount, _headcount);
+ _pdata.edit_population(-_pdata.population_large_conversion(amount));
+ _pdata.edit_guardsmen(amount);
+ return amount;
+}
+
+/// @description Total embarked Guard on player ships currently at a given system.
+/// @param {string} system_name
+/// @returns {real}
+function player_guardsmen_at(system_name) {
+ ensure_ship_guardsmen_arrays();
+ var _total = 0;
+ with (obj_ini) {
+ for (var i = 0; i < array_length(ship); i++) {
+ if (ship[i] == "") continue;
+ if (ship_location[i] != system_name) continue;
+ _total += ship_guardsmen[i];
+ }
+ }
+ return _total;
+}
+
+/// @desc Whether a raid can still be supported at this star this turn: any carrying ship
+/// with raid uses left (its own counter), or the planet's local forces with local uses
+/// left. Ship raid uses are independent of ground-assault and bombardment uses, so
+/// bombarding or attacking with a ship does not block raiding with it.
+function can_ground_deploy(star_object, planet_number) {
+ if (!instance_exists(star_object)) {
+ return false;
+ }
+ var _ships = get_player_ships(star_object.name);
+ for (var i = 0; i < array_length(_ships); i++) {
+ if ((obj_ini.ship_carrying[_ships[i]] > 0) && (ship_raids_used(_ships[i]) < SHIP_ASSAULTS_PER_TURN)) {
+ return true;
+ }
+ }
+ if ((star_object.p_player[planet_number] > 0) && (local_assaults_used(star_object, planet_number) < SHIP_ASSAULTS_PER_TURN)) {
+ return true;
+ }
+ return false;
+}
+
+/// @desc First ship at this star that has not yet bombarded this turn, or -1. Bombardment
+/// is one per ship per turn on its own counter, independent of ground support.
+function get_fresh_bombard_ship(location) {
+ var _ships = get_player_ships(location);
+ for (var i = 0; i < array_length(_ships); i++) {
+ if (ship_bombards_used(_ships[i]) == 0) {
+ return _ships[i];
+ }
+ }
+ return -1;
+}
+
+/// @desc Spend a ship's one bombardment use for the turn, on its own counter. This is
+/// independent of the ship's ground assault and raid support, so a ship can bombard and
+/// still land troops (attack or raid) the same turn.
+function ship_bombard_spend(ship_index) {
+ if (ship_index < 0) {
+ return;
+ }
+ ship_action_spend(ship_index, "bombard");
+}
diff --git a/scripts/scr_post_battle_events/scr_post_battle_events.gml b/scripts/scr_post_battle_events/scr_post_battle_events.gml
index 2c2ac888a3..0ca3342f3d 100644
--- a/scripts/scr_post_battle_events/scr_post_battle_events.gml
+++ b/scripts/scr_post_battle_events/scr_post_battle_events.gml
@@ -150,4 +150,120 @@ function space_hulk_explore_battle_aftermath() {
pop.text = "The fallen heretics wore several suits of Terminator Armour- a handful of them were found to be cleansible and worthy of use. " + string(termi) + " Terminator Armour has been added to the Armamentarium.";
}
}
+
+ // Hulk fully cleared this battle: offer the salvage choice. The hulk is removed from the map
+ // by whichever option the player picks (space_hulk_strip / space_hulk_surrender).
+ if (!defeat && hulk_cleared) {
+ var _shi = 0, _loc = "";
+ var _shiyp = instance_nearest(battle_object.x, battle_object.y, obj_p_fleet);
+ if (_shiyp.x == battle_object.x && _shiyp.y == battle_object.y) {
+ _shi = fleet_full_ship_array(_shiyp)[0];
+ _loc = obj_ini.ship[_shi];
+ }
+
+ var pop = instance_create(0, 0, obj_popup);
+ pop.image = "space_hulk_done";
+ pop.title = "Space Hulk Cleared";
+ pop.text = "The last of the hulk's defenders are purged and the drifting wreck falls silent. It is yours to dispose of. Strip it for the Chapter, or tow it to the nearest Forge World for the Adeptus Mechanicus?";
+ pop.hulk_star = battle_object;
+ pop.hulk_loot_ship = _shi;
+ pop.hulk_loot_loc = _loc;
+ with (pop) {
+ replace_options([
+ { str1: "Strip it for the Chapter", choice_func: space_hulk_strip },
+ { str1: "Tow it to the nearest Forge World", choice_func: space_hulk_surrender }
+ ]);
+ }
+ }
+}
+
+/// @description Salvage choice: strip a cleared space hulk for the Chapter. Runs in obj_popup scope.
+/// Raises Inquisitorial suspicion and angers the Mechanicus, but yields requisition and a relic
+/// that carries the normal chance of chaos/daemonic taint. Removes the hulk from the map.
+function space_hulk_strip() {
+ // --- Penalties (tunable) ---
+ // Inquisition takes note; the Mechanicus consider the claim theft from the Omnissiah.
+ obj_ini.chapter_data.chapter_suspicion = clamp(obj_ini.chapter_data.chapter_suspicion + 1, -5, 5);
+ obj_controller.disposition[eFACTION.MECHANICUS] = clamp(obj_controller.disposition[eFACTION.MECHANICUS] - 12, 0, 100);
+ // The uncancellable cost of hoarding forbidden discoveries: Chapter standing drops a tier
+ // (100 -> 75 leaves the >=85 "Loyal" band). Both visible and hidden loyalty fall, the same
+ // idiom the existing crime penalties use, so it persists through Inquisition inspections.
+ // Gifting any recovered STC to the Mechanicus can repair their disposition, never this.
+ var _loyalty_hit = 25;
+ obj_controller.loyalty = clamp(obj_controller.loyalty - _loyalty_hit, 0, 100);
+ obj_controller.loyalty_hidden = clamp(obj_controller.loyalty_hidden - _loyalty_hit, 0, 100);
+
+ // --- Rewards (tunable) ---
+ var _reqi = irandom_range(80, 120) * 10; // ~1000 Requisition
+ obj_controller.requisition += _reqi;
+
+ // 1-3 relics, most carrying chaos/daemonic taint. ~65% are forced daemonic; the rest still
+ // roll the normal ~30% taint chance, so the overall haul is dangerous more often than not.
+ var _arts = irandom_range(1, 3);
+ for (var _a = 0; _a < _arts; _a++) {
+ var _art_tags = (random(1) < 0.65) ? "daemonic" : "random";
+ scr_add_artifact("random", _art_tags, 4, hulk_loot_loc, hulk_loot_ship + 500);
+ }
+
+ // Rare STC fragment in the haul. Kept low on purpose: a guaranteed STC would let the player
+ // gift it back to the Mechanicus and buy back the disposition they just lost (the loyalty
+ // hit above is what they can never undo).
+ var _found_stc = (random(1) < 0.20);
+ if (_found_stc) {
+ scr_add_stc_fragment();
+ }
+
+ if (instance_exists(hulk_star)) {
+ with (hulk_star) {
+ instance_destroy();
+ }
+ }
+ if (instance_exists(obj_star_select)) {
+ with (obj_star_select) {
+ instance_destroy();
+ }
+ }
+ obj_controller.sel_system_x = 0;
+ obj_controller.sel_system_y = 0;
+
+ image = "space_hulk_done";
+ title = "Space Hulk Stripped";
+ var _stc_line = _found_stc ? " An intact STC fragment is prised from the wreck and spirited away." : "";
+ text = $"Your Chapter strips the hulk for itself. {_arts} relic(s) and {_reqi} Requisition worth of archeotech are hauled aboard.{_stc_line} The unsanctioned claim will not go unnoticed: the Adeptus Mechanicus consider it theft from the Omnissiah, the Inquisition's gaze lingers, and the Chapter's standing suffers for hoarding forbidden discoveries.";
+ reset_popup_options();
+ cooldown = 20;
+}
+
+/// @description Salvage choice: tow a cleared space hulk to the nearest Forge World. Runs in
+/// obj_popup scope. The sanctioned, expected course: the Mechanicus are grateful and send a
+/// token of thanks. Removes the hulk from the map.
+function space_hulk_surrender() {
+ // --- Rewards (tunable): the payoff here is Mechanicus favour, not loot. ---
+ obj_controller.disposition[eFACTION.MECHANICUS] = clamp(obj_controller.disposition[eFACTION.MECHANICUS] + 12, 0, 100);
+
+ var _reqi = irandom_range(8, 12) * 10; // ~100 Requisition, a token of thanks
+ obj_controller.requisition += _reqi;
+
+ // A single minor, untainted relic of the sort the Mechanicus part with in trade. No major
+ // relic wargear here: the Mechanicus are stingy and keep their best for themselves.
+ scr_add_artifact("random_nodemon", "minor", 4, hulk_loot_loc, hulk_loot_ship + 500);
+
+ if (instance_exists(hulk_star)) {
+ with (hulk_star) {
+ instance_destroy();
+ }
+ }
+ if (instance_exists(obj_star_select)) {
+ with (obj_star_select) {
+ instance_destroy();
+ }
+ }
+ obj_controller.sel_system_x = 0;
+ obj_controller.sel_system_y = 0;
+
+ image = "space_hulk_done";
+ title = "Space Hulk Surrendered";
+ text = $"The hulk is towed to the nearest Forge World for the Adeptus Mechanicus to pick apart at their leisure. They receive it gratefully, their disposition toward your Chapter warms, and they send a token of thanks worth {_reqi} Requisition along with a minor relic from their archives.";
+ reset_popup_options();
+ cooldown = 20;
}
diff --git a/scripts/scr_powers/scr_powers.gml b/scripts/scr_powers/scr_powers.gml
index 747b731fe2..ea30c726ef 100644
--- a/scripts/scr_powers/scr_powers.gml
+++ b/scripts/scr_powers/scr_powers.gml
@@ -373,6 +373,13 @@ function flush_psychic_summary(_psy_log) {
}
}
+/// @desc Folds one attack-power cast into the per-formation psychic summary, keyed by power + target,
+/// so many identical Librarian casts collapse into a single battle-log line.
+/// @param {Struct} _psy_log The accumulator struct (one per formation casting phase).
+// Duplicate accumulate_psychic_cast / flush_psychic_summary removed here: the
+// fork carried Tavish-lineage copies (legacy 3-argument add_battle_log_message)
+// alongside upstream's rebuilt versions above, producing duplicate script names.
+
/// @desc Function to get requested data from the disciplines_data structure. Returns The requested data, or undefined if not found.
/// @param _discipline_name - The name of the discipline
/// @param _data_name - The specific data attribute you want
diff --git a/scripts/scr_punit_combat_heplers/scr_punit_combat_heplers.gml b/scripts/scr_punit_combat_heplers/scr_punit_combat_heplers.gml
index 0364003fcc..deeff17675 100644
--- a/scripts/scr_punit_combat_heplers/scr_punit_combat_heplers.gml
+++ b/scripts/scr_punit_combat_heplers/scr_punit_combat_heplers.gml
@@ -74,7 +74,14 @@ function get_rightmost(block_type = obj_pnunit, include_flanking = true, include
}
}
if (rightmost == noone && x > 0) {
- rightmost = block_type.id;
+ // Was block_type.id, which resolves to obj_pnunit.id (the first
+ // instance in the list) rather than the current instance in this
+ // with-loop, so the first valid block reaching here was recorded as
+ // whatever block was created first. When that first-created block
+ // was not the actual edge, get_rightmost returned the wrong block
+ // and the enemy fired on the wrong column even with the formation
+ // in correct order. Bug exists verbatim in upstream main.
+ rightmost = id;
} else {
if (x > rightmost.x) {
rightmost = id;
@@ -115,7 +122,13 @@ function get_leftmost(block_type = obj_pnunit, include_flanking = true) {
}
}
if (left_most == noone && x > 0) {
- left_most = block_type.id;
+ // Same bug as get_rightmost above: block_type.id is the first
+ // instance in the list, not the current one, so the first valid
+ // block was recorded as the first-created block. This is why a
+ // flanking force (which targets get_leftmost, the rear column) hit
+ // the wrong line, striking the bulk block created first rather than
+ // the block actually closest to it. Bug exists verbatim in upstream.
+ left_most = id;
} else {
if (x < left_most.x && x > 0) {
left_most = id;
@@ -170,25 +183,37 @@ function block_position_collision(position_x, position_y) {
/// @param {string} direction In what direction to move ("east" or "west")
/// @param {real} blocks How far to move (in unit blocks)
/// @param {bool} allow_collision Are unit blocks allowed to passthrough other unit blocks
+/// @param {bool} leapfrog Retained for callers; the move-through behaviours this once
+/// enabled (leapfrog teleport, parallel-lane pass) were tried and reverted, so a block
+/// blocked by a friendly currently just holds. Movement-through will return with the
+/// per-unit-type formation rework.
/// @return {bool}
/// @self Asset.GMObject.obj_pnunit
-function move_unit_block(direction, blocks = 1, allow_collision = false) {
+function move_unit_block(direction, blocks = 1, allow_collision = false, leapfrog = false) {
try {
var distance = 10 * blocks;
var _new_pos = x;
+ var _step = 0;
if (direction == "east") {
+ _step = distance;
_new_pos = x + distance;
} else if (direction == "west") {
+ _step = -distance;
_new_pos = x - distance;
}
if (allow_collision == true || !block_position_collision(_new_pos, y)) {
x = _new_pos;
return true;
- } else {
- return false;
}
+
+ // A block blocked by a friendly ahead simply holds this tick rather than
+ // teleporting over the line or side-stepping into a parallel lane (both tried and
+ // reverted). Movement-through will be revisited with the per-unit-type formation
+ // rework; for now the auto-advancing body closes to contact and manual orders
+ // advance until something is in the way.
+ return false;
} catch (_exception) {
ERROR_HANDLER.handle_exception(_exception);
}
@@ -220,6 +245,52 @@ function move_enemy_blocks() {
ds_priority_destroy(_enemy_movement_queue);
}
+/// @self Asset.GMObject.obj_pnunit
+/// @desc One player block's advance-to-contact step. Seeds this block's order on its
+/// first tick (raids/attacks advance, defense/static hold), advances one column east if
+/// ordered to (a personally ordered block leapfrogs; the auto-advancing body stops once
+/// the formation has met the enemy line), then latches formation contact so blocks
+/// processed later in the same front-first sweep hold instead of surging into gaps.
+/// Extracted from obj_pnunit Alarm_0 so movement can run in a single ordered pass.
+function move_player_block() {
+ if (move_order == "") {
+ move_order = (obj_ncombat.dropping || (!obj_ncombat.defending && obj_ncombat.formation_set != 2)) ? "advance" : "hold";
+ }
+ if ((move_order == "advance") && (veh_type[1] != "Defenses")) {
+ if (order_manual) {
+ move_unit_block("east", 1, false, true);
+ } else if (!obj_ncombat.player_front_contact) {
+ move_unit_block("east", 1, false, false);
+ }
+ }
+ if (collision_point(x + 14, y, obj_enunit, 0, 1)) {
+ obj_ncombat.player_front_contact = true;
+ }
+}
+
+/// @self Asset.GMObject.obj_controller
+/// @desc Advance the whole player line front-first, mirroring move_enemy_blocks. Player
+/// front is the high-x (east) side, so the queue is drained highest-x first: the frontmost
+/// block advances and clears its column before the block behind it tries to move, so a rear
+/// block (a Rhino sitting behind the infantry) no longer stalls against a slot the front
+/// block has not vacated yet and drift out of the line. Replaces the old arbitrary
+/// instance-order per-block advance in obj_pnunit Alarm_0.
+function move_player_blocks() {
+ var _player_movement_queue = ds_priority_create();
+ with (obj_pnunit) {
+ ds_priority_add(_player_movement_queue, id, x);
+ }
+ while (!ds_priority_empty(_player_movement_queue)) {
+ var _player_block = ds_priority_delete_max(_player_movement_queue);
+ if (instance_exists(_player_block)) {
+ with (_player_block) {
+ move_player_block();
+ }
+ }
+ }
+ ds_priority_destroy(_player_movement_queue);
+}
+
/// @self Asset.GMObject.obj_enunit|Asset.GMObject.obj_pnunit
function block_composition_string() {
var _composition_string = $"{unit_count}x Total; ";
diff --git a/scripts/scr_purge_world/scr_purge_world.gml b/scripts/scr_purge_world/scr_purge_world.gml
index 5e4d979d0a..efe546a797 100644
--- a/scripts/scr_purge_world/scr_purge_world.gml
+++ b/scripts/scr_purge_world/scr_purge_world.gml
@@ -163,11 +163,16 @@ function scr_purge_world(action_type, action_score) {
var _no_chaos = (planet_forces[eFACTION.HERETICS] + planet_forces[eFACTION.CHAOS]) == 0;
if ((action_type == eDROP_TYPE.PURGEFIRE || action_type == eDROP_TYPE.PURGESELECTIVE) && _no_chaos && obj_controller.turn >= obj_controller.chaos_turn) {
if (has_feature(eP_FEATURES.WARLORD10) && obj_controller.known[10] == 0 && obj_controller.faction_gender[10] == 1) {
+ // {name()} was being interpolated inside with (obj_drop_select), where self
+ // is the drop-select instance and name() does not exist: a caught error and
+ // a blank popup every time a purge uncovered a concealed Chaos warlord.
+ // Capture the planet name in PlanetData scope before entering the with.
+ var _planet_name = name();
with (obj_drop_select) {
var pop = instance_create(0, 0, obj_popup);
pop.image = "chaos_symbol";
pop.title = "Concealed Heresy";
- pop.text = $"Your astartes set out and begin to cleanse {name()} of possible heresy. The general populace appears to be devout in their faith, but a disturbing trend appears- the odd citizen cursing your forces, frothing at the mouth, and screaming out heresy most foul. One week into the cleansing a large hostile force is detected approaching and encircling your forces.";
+ pop.text = $"Your astartes set out and begin to cleanse {_planet_name} of possible heresy. The general populace appears to be devout in their faith, but a disturbing trend appears- the odd citizen cursing your forces, frothing at the mouth, and screaming out heresy most foul. One week into the cleansing a large hostile force is detected approaching and encircling your forces.";
exit;
}
}
@@ -229,8 +234,8 @@ function scr_purge_world(action_type, action_score) {
alter_disposition(eFACTION.INQUISITION, obj_controller.demanding ? choose(0, 0, 1) : 1);
- _popup_text = "Your marines scour the underhive of {name()}, spraying mutants down with promethium as they go. It takes several days but a sizeable dent is put in their numbers.";
- scr_event_log("", "Inquisition Mission Completed: The mutants of {name()} have been cleansed by promethium.");
+ _popup_text = $"Your marines scour the underhive of {name()}, spraying mutants down with promethium as they go. It takes several days but a sizeable dent is put in their numbers.";
+ scr_event_log("", $"Inquisition Mission Completed: The mutants of {name()} have been cleansed by promethium.");
add_disposition(choose(1, 2, 3));
}
} else {
@@ -251,6 +256,21 @@ function scr_purge_world(action_type, action_score) {
}
_popup_text += _purge.population_death_string();
+
+ // Indiscriminate fire purges turn the surviving populace against the
+ // Chapter, scaled by the share of the population put to the torch. Before
+ // this, a fire purge of a world with no active Inquisition quest changed
+ // disposition not at all, so burning millions of your own citizens cost
+ // nothing. Selective purges (targeted heretics) and assassinations keep no
+ // penalty, matching the intent that precision killing of known enemies is
+ // tolerated while razing hab blocks is not.
+ if (_purge.pop_before > 0) {
+ var _fire_dispo_drop = round((_purge.kill / _purge.pop_before) * PURGE_FIRE_DISPO_PENALTY);
+ if (_fire_dispo_drop > 0) {
+ add_disposition(-_fire_dispo_drop);
+ _popup_text += " Word of the burnings spreads, and the survivors' regard for your Chapter falls.";
+ }
+ }
}
}
@@ -270,7 +290,7 @@ function scr_purge_world(action_type, action_score) {
alter_disposition(eFACTION.INQUISITION, obj_controller.demanding ? choose(0, 0, 1) : 1);
_popup_text = "Your marines drop fast and hard, blowing through guards and mercenaries with minimal resistance. Before ten minutes have passed all your targets are executed.";
- scr_event_log("", "Inquisition Mission Completed: The unruly Nobles of {name()} have been purged.");
+ scr_event_log("", $"Inquisition Mission Completed: The unruly Nobles of {name()} have been purged.");
add_disposition(choose(1, 2, 3));
}
} else if (_isquest == 0) {
diff --git a/scripts/scr_reequip_units/scr_reequip_units.gml b/scripts/scr_reequip_units/scr_reequip_units.gml
index ec58f7219b..aa89acba82 100644
--- a/scripts/scr_reequip_units/scr_reequip_units.gml
+++ b/scripts/scr_reequip_units/scr_reequip_units.gml
@@ -205,39 +205,6 @@ function reload_items() {
);
}
-/// @param {Struct.EquipmentStruct} _armour_data
-/// @param {Struct.EquipmentStruct} _mobility_data
-/// @returns {Struct} { valid: bool, warning: string }
-function check_mobility_armour_compatibility(_armour_data, _mobility_data) {
- var _result = { valid: true, warning: "" };
-
- if (is_struct(_armour_data) && is_struct(_mobility_data)) {
- if (_armour_data.has_tag("terminator") && !_mobility_data.has_tag("terminator") && !_mobility_data.has_tag("terminator_only")) {
- _result.valid = false;
- _result.warning = "Cannot use this with Terminator Armour.";
- } else if (!_armour_data.has_tag("terminator") && _mobility_data.has_tag("terminator_only")) {
- _result.valid = false;
- _result.warning = "Cannot use this without Terminator Armour.";
- } else if (_armour_data.has_tag("dreadnought") && !_mobility_data.has_tag("dreadnought") && !_mobility_data.has_tag("dreadnought_only")) {
- _result.valid = false;
- _result.warning = "Cannot use this with Dreadnought Armour.";
- } else if (!_armour_data.has_tag("dreadnought") && _mobility_data.has_tag("dreadnought_only")) {
- _result.valid = false;
- _result.warning = "Cannot use this without Dreadnought Armour.";
- }
- } else if (!is_struct(_armour_data) && is_struct(_mobility_data)) {
- if (_mobility_data.has_tag("terminator") || _mobility_data.has_tag("terminator_only")) {
- _result.valid = false;
- _result.warning = "Cannot use this without Terminator Armour.";
- } else if (_mobility_data.has_tag("dreadnought") || _mobility_data.has_tag("dreadnought_only")) {
- _result.valid = false;
- _result.warning = "Cannot use this without Dreadnought Armour.";
- }
- }
-
- return _result;
-}
-
/// @self Asset.GMObject.obj_popup
function draw_popup_equip() {
main_slate.draw_with_dimensions();
@@ -556,55 +523,50 @@ function draw_popup_equip() {
n_good2 = 0;
warning = "Only " + string(obj_ini.role[100][6]) + " can use Close Combat Weapons.";
}
+ // Removed the blanket block that invalidated any weapon-two change for
+ // Terminator/Tartaros/Dreadnought armour whenever a mobility item was equipped.
+ // It hard-locked the second weapon slot for terminators with a Cyclone Missile
+ // System or Servo-arm/harness (with no warning text), even for light weapons.
+ // The burden system in scr_marine_struct already prices these items in via
+ // their melee_hands/ranged_hands cap maluses and encumbrance penalties.
}
}
- if (equipmet_area == eEQUIPMENT_SLOT.ARMOUR) {
- if (is_struct(armour_data)) {
- // Check numbers
- req_armour_num = units;
- have_armour_num = 0;
- for (var i = 0; i < array_length(obj_controller.display_unit); i++) {
- if ((vehicle_equipment != -1) && (obj_controller.man_sel[i] == 1) && (obj_controller.ma_armour[i] == n_armour)) {
- have_armour_num += 1;
- }
+ if ((equipmet_area == eEQUIPMENT_SLOT.ARMOUR) && is_struct(armour_data)) {
+ // Check numbers
+ req_armour_num = units;
+ have_armour_num = 0;
+ for (var i = 0; i < array_length(obj_controller.display_unit); i++) {
+ if ((vehicle_equipment != -1) && (obj_controller.man_sel[i] == 1) && (obj_controller.ma_armour[i] == n_armour)) {
+ have_armour_num += 1;
}
- have_armour_num += scr_item_count(n_armour);
+ }
+ have_armour_num += scr_item_count(n_armour);
- if (have_armour_num >= req_armour_num || n_armour == ITEM_NAME_NONE) {
- n_good3 = 1;
- }
- if (have_armour_num < req_armour_num && (n_armour != ITEM_NAME_ANY && n_armour != ITEM_NAME_NONE)) {
- n_good3 = 0;
- warning = $"Not enough {n_armour} : {req_armour_num - have_armour_num} more are required.";
- }
+ if (have_armour_num >= req_armour_num || n_armour == ITEM_NAME_NONE) {
+ n_good3 = 1;
+ }
+ if (have_armour_num < req_armour_num && (n_armour != ITEM_NAME_ANY && n_armour != ITEM_NAME_NONE)) {
+ n_good3 = 0;
+ warning = $"Not enough {n_armour} : {req_armour_num - have_armour_num} more are required.";
+ }
- if (armour_data.has_tag("terminator")) {
- if (armour_data.req_exp > 0) {
- for (var g = 0; g < array_length(obj_controller.display_unit); g++) {
- if (obj_controller.man_sel[g] == 1 && is_struct(obj_controller.display_unit[g])) {
- if (obj_controller.display_unit[g].experience < armour_data.req_exp) {
- n_good3 = 0;
- warning = $"A unit must have {armour_data.req_exp}+ EXP to use a {armour_data.name}.";
- break;
- }
+ if (armour_data.has_tag("terminator")) {
+ if (armour_data.req_exp > 0) {
+ for (var g = 0; g < array_length(obj_controller.display_unit); g++) {
+ if (obj_controller.man_sel[g] == 1 && is_struct(obj_controller.display_unit[g])) {
+ if (obj_controller.display_unit[g].experience < armour_data.req_exp) {
+ n_good3 = 0;
+ warning = $"A unit must have {armour_data.req_exp}+ EXP to use a {armour_data.name}.";
+ break;
}
}
}
}
-
- if ((string_count("Dread", o_armour) > 0) && (string_count("Dread", n_armour) == 0)) {
- n_good4 = 0;
- warning = "Marines may not exit Dreadnoughts.";
- }
}
- if (is_struct(mobility_data)) {
- n_good5 = 1;
- var _compat = check_mobility_armour_compatibility(armour_data, mobility_data);
- if (!_compat.valid) {
- n_good5 = 0;
- warning = _compat.warning;
- }
+ if ((string_count("Dread", o_armour) > 0) && (string_count("Dread", n_armour) == 0)) {
+ n_good4 = 0;
+ warning = "Marines may not exit Dreadnoughts.";
}
}
if ((equipmet_area == eEQUIPMENT_SLOT.GEAR) && (n_gear != "Assortment") && (n_gear != ITEM_NAME_NONE)) {
@@ -664,11 +626,47 @@ function draw_popup_equip() {
warning = "Not enough " + string(n_mobi) + "; " + string(req_mobi_num - have_mobi_num) + " more are required.";
}
- if (is_struct(mobility_data)) {
- var _compat = check_mobility_armour_compatibility(armour_data, mobility_data);
- if (!_compat.valid) {
+ if (is_struct(armour_data) && is_struct(mobility_data)) {
+ if (armour_data.has_tag("terminator") && !mobility_data.has_tag("terminator") && !mobility_data.has_tag("terminator_only")) {
+ n_good5 = 0;
+ warning = "Cannot use this with Terminator Armour.";
+ } else if (!armour_data.has_tag("terminator") && mobility_data.has_tag("terminator_only")) {
+ n_good5 = 0;
+ warning = "Cannot use this without Terminator Armour.";
+ } else if (armour_data.has_tag("dreadnought") && !mobility_data.has_tag("dreadnought") && !mobility_data.has_tag("dreadnought_only")) {
+ n_good5 = 0;
+ warning = "Cannot use this with Dreadnought Armour.";
+ } else if (!armour_data.has_tag("dreadnought") && mobility_data.has_tag("dreadnought_only")) {
n_good5 = 0;
- warning = _compat.warning;
+ warning = "Cannot use this without Dreadnought Armour.";
+ }
+ } else if (!is_struct(armour_data) && is_struct(mobility_data)) {
+ // Mixed armour selections collapse n_armour to "Assortment", so armour_data is
+ // not a struct even when every selected unit wears terminator plate (e.g. one in
+ // Tartaros and one in Indomitus). Check each selected unit's real armour tags
+ // instead of blanket-rejecting terminator/dreadnought mobility items.
+ if (mobility_data.has_tag("terminator") || mobility_data.has_tag("terminator_only")) {
+ for (var g = 0; g < array_length(obj_controller.display_unit); g++) {
+ if (obj_controller.man_sel[g] == 1 && is_struct(obj_controller.display_unit[g])) {
+ var _unit_armour_data = obj_controller.display_unit[g].get_armour_data();
+ if (!is_struct(_unit_armour_data) || !_unit_armour_data.has_tag("terminator")) {
+ n_good5 = 0;
+ warning = "Cannot use this without Terminator Armour.";
+ break;
+ }
+ }
+ }
+ } else if (mobility_data.has_tag("dreadnought") || mobility_data.has_tag("dreadnought_only")) {
+ for (var g = 0; g < array_length(obj_controller.display_unit); g++) {
+ if (obj_controller.man_sel[g] == 1 && is_struct(obj_controller.display_unit[g])) {
+ var _unit_armour_data = obj_controller.display_unit[g].get_armour_data();
+ if (!is_struct(_unit_armour_data) || !_unit_armour_data.has_tag("dreadnought")) {
+ n_good5 = 0;
+ warning = "Cannot use this without Dreadnought Armour.";
+ break;
+ }
+ }
+ }
}
}
}
diff --git a/scripts/scr_roster/scr_roster.gml b/scripts/scr_roster/scr_roster.gml
index 881512e1fb..e5a8c194ac 100644
--- a/scripts/scr_roster/scr_roster.gml
+++ b/scripts/scr_roster/scr_roster.gml
@@ -114,6 +114,11 @@ function Roster() constructor {
_valid_type = _allow_dreadnoughts;
}
}
+ // Guardsmen answer to their own filter button rather than always passing
+ var _grd_role = _unit.role();
+ if (_grd_role == "Guardsman" || _grd_role == "Guard Squad" || _grd_role == "Guard Sergeant" || _grd_role == "Veteran Guard" || _grd_role == "Heavy Weapons Team") {
+ _valid_type = array_contains(_valid_squad_types, "guardsman");
+ }
}
if (_unit.ship_location > -1) {
@@ -161,7 +166,7 @@ function Roster() constructor {
static new_squad_button = function(display, squad_id) {
var _button = new ToggleButton();
display = string_replace(display, " Squad", "");
- if (display != "Command") {
+ if (display != "Command" && display != "Guardsmen") {
display = string_plural(display);
}
_button.str1 = display;
@@ -293,6 +298,13 @@ function Roster() constructor {
new_squad_button("Dreadnought", "dreadnought");
}
}
+ // Guardsmen and Guard Squads have no squad type, so give them their
+ // own filter button (added once) so they can be selected on their own.
+ var _grd_role = _unit.role();
+ if ((_grd_role == "Guardsman" || _grd_role == "Guard Squad" || _grd_role == "Guard Sergeant" || _grd_role == "Veteran Guard" || _grd_role == "Heavy Weapons Team") && !array_contains(_squads, "guardsman")) {
+ array_push(_squads, "guardsman");
+ new_squad_button("Guardsmen", "guardsman");
+ }
}
}
}
@@ -355,6 +367,26 @@ function Roster() constructor {
_ship_index = _ships[s];
if (obj_ini.ship_carrying[_ship_index] > 0) {
new_ship_button(obj_ini.ship[_ship_index], _ship_index);
+ // Ship assault economy: a ship that has already supported its maximum
+ // ground assaults this turn stays listed but locked and red, so the
+ // player can see it is spent. The lock is enforced in
+ // scr_drop_select_function (clicks and Select All are forced back off),
+ // and update_roster only admits units whose ship toggle is active.
+ // Applies to attacks only; raids and purges do not spend uses.
+ if (instance_exists(obj_drop_select)) {
+ // Attack rosters gate on the ship's assault counter, raid rosters on
+ // its raid counter (each independent), so a ship spent on one action
+ // still shows available for the other.
+ var _dr_used = obj_drop_select.attack ? ship_assaults_used(_ship_index) : ship_raids_used(_ship_index);
+ if (_dr_used >= SHIP_ASSAULTS_PER_TURN) {
+ var _spent_btn = ships[array_length(ships) - 1];
+ _spent_btn.assault_locked = true;
+ _spent_btn.active = false;
+ _spent_btn.text_color = c_red;
+ _spent_btn.button_color = c_red;
+ _spent_btn.tooltip = obj_drop_select.attack ? "This ship has already supported the maximum number of ground assaults this turn." : "This ship has already supported the maximum number of raids this turn.";
+ }
+ }
}
}
};
@@ -468,6 +500,15 @@ function PurgeButton(purge_image, xx, yy, purge_type) constructor {
shader_set_uniform_f(shader_get_uniform(light_dark_shader, "highlight"), bright_shader);
scr_image("purge", purge_image, x1, y1, width, height);
shader_reset();
+ // The description field existed but was never rendered. Drawn after the
+ // shader resets so the tooltip is not tinted; inactive buttons explain why.
+ if ((description != "") && hover()) {
+ var _tip = description;
+ if (!active) {
+ _tip += "\n\nYour selected force cannot perform this purge.";
+ }
+ tooltip_draw(_tip);
+ }
};
static clicked = function() {
@@ -705,6 +746,21 @@ function add_unit_to_battle(unit, meeting, is_local) {
col = max(obj_controller.bat_assault_column, obj_controller.bat_command_column, obj_controller.bat_honor_column, obj_controller.bat_dreadnought_column, obj_controller.bat_veteran_column);
}
+ // ===== Guardsmen: "Hirelings" formation =====
+ // Guardsmen go into the movable Hirelings block (bat_hire_column), so the player can position
+ // them anywhere from the formation screen as a single line. This restores the behaviour from
+ // before the positional-screen experiment: every guardsman shares this one column instead of
+ // being pinned to fixed front columns. bat_hire_column was resolved for this formation at the
+ // top of this function and is driven by the Hirelings bar (bat_hire_for, unit_id 12).
+ if (_unit_role == "Heavy Weapons Team") {
+ // Heavy weapons teams fight from the Devastator formation, the chapter's own heavy-weapon
+ // line, instead of the Hirelings block, so the auxilia's heavy guns stand with the Marines'.
+ col = obj_controller.bat_devastator_column;
+ new_combat.devastators++;
+ } else if (_unit_role == "Guardsman" || _unit_role == "Guard Sergeant" || _unit_role == "Veteran Guard") {
+ col = obj_controller.bat_hire_column;
+ }
+
targ = instance_nearest(col * 10, 240, obj_pnunit);
with (targ) {
@@ -723,10 +779,21 @@ function add_vehicle_to_battle(company, veh_index, is_local) {
col = obj_controller.bat_rhino_column;
new_combat.rhinos++;
break;
+ // Chimera (guard transport) screens like a Rhino. This first drop parks it in the Rhino
+ // column; the dedicated Imperial Armor column arrives with the formation drop.
+ case "Chimera":
+ col = obj_controller.bat_rhino_column;
+ new_combat.rhinos++;
+ break;
case "Predator":
col = obj_controller.bat_predator_column;
new_combat.predators++;
break;
+ // Leman Russ (guard battle tank) anchors the armour line alongside the Predators.
+ case "Leman Russ":
+ col = obj_controller.bat_predator_column;
+ new_combat.predators++;
+ break;
case "Land Raider":
col = obj_controller.bat_landraider_column;
new_combat.land_raiders++;
@@ -770,6 +837,24 @@ function add_vehicle_to_battle(company, veh_index, is_local) {
targ.veh_hp[targ.veh] = obj_ini.veh_hp[company][v] * 4;
targ.veh_hp_multiplier[targ.veh] = 4;
targ.veh_ac[targ.veh] = 40;
+ } else if (obj_ini.veh_role[company][v] == "Chimera") {
+ // Mirrors the enemy Chimera: HP 200 (base 100 x2), armour 30.
+ targ.veh_hp[targ.veh] = obj_ini.veh_hp[company][v] * 2;
+ targ.veh_hp_multiplier[targ.veh] = 2;
+ targ.veh_ac[targ.veh] = 30;
+ } else if (obj_ini.veh_role[company][v] == "Leman Russ") {
+ // Mirrors the enemy Leman Russ Battle Tank: heavy armour 40, HP 300 (base 100 x3),
+ // so it shrugs off small arms and trades blows with other tanks like a Predator.
+ targ.veh_hp[targ.veh] = obj_ini.veh_hp[company][v] * 3;
+ targ.veh_hp_multiplier[targ.veh] = 3;
+ targ.veh_ac[targ.veh] = 40;
+ } else if (obj_ini.veh_role[company][v] == "Basilisk") {
+ // Mirrors the enemy Basilisk: armour 30, HP 150 (base 100 x1.5). A self-propelled
+ // artillery piece, tougher than a Chimera but lighter than a Leman Russ, built to
+ // shell from the rear of the line rather than trade blows at the front.
+ targ.veh_hp[targ.veh] = obj_ini.veh_hp[company][v] * 1.5;
+ targ.veh_hp_multiplier[targ.veh] = 1.5;
+ targ.veh_ac[targ.veh] = 30;
}
// STC Bonuses
@@ -795,3 +880,48 @@ function add_vehicle_to_battle(company, veh_index, is_local) {
}
}
}
+
+/// @function auxilia_roles
+/// @description Single source of truth for which unit roles belong to the Auxilia company
+/// screen (managing 16). These are non-Astartes auxiliary mercenaries mustered into company 0
+/// alongside the Headquarters, but managed on their own screen. Add future merc roles here
+/// (Ogryn, heavy weapons team, etc.) and they will automatically appear under Auxilia and be
+/// excluded from the Headquarters detail view.
+/// @returns {array}
+function auxilia_roles() {
+ return ["Guardsman", "Guard Squad", "Guard Sergeant", "Veteran Guard", "Heavy Weapons Team"];
+}
+
+/// @description Promote every basic Guardsman to Veteran Guard, applying the veteran stat
+/// buff. Veterans keep all Guard behaviour (Auxilia screen, hireling line, volley fire,
+/// tenth-slot berth, guardsman portrait) through the role closure. They receive no free
+/// weapon; Hellguns are forged separately and equip-gated to this role. Intended to be wired
+/// to the Auxilia "Promote All" button. Pass a company index to limit promotion to one
+/// company, or leave it undefined to promote every auxilia Guardsman. The stat_boosts numbers
+/// are tunable. Additions are flat; stat_boosts rebalances constitution into current health.
+/// @param {real} [_company] optional company index to limit promotion to
+/// @returns {real} number of troopers promoted
+function promote_auxilia_to_veteran(_company = undefined) {
+ var _troops = collect_role_group("all", "", false, { roles: ["Guardsman"] });
+ var _count = 0;
+ for (var _i = 0; _i < array_length(_troops); _i++) {
+ var _unit = _troops[_i];
+ if (_company != undefined && _unit.company != _company) {
+ continue;
+ }
+ // Only battle-hardened Guardsmen qualify: they must have earned GUARD_VETERAN_XP
+ // experience, roughly GUARD_VETERAN_XP / GUARD_BATTLE_XP survived battles. Fresh
+ // recruits are skipped until they have bled for it.
+ if (_unit.experience < GUARD_VETERAN_XP) {
+ continue;
+ }
+ _unit.update_role("Veteran Guard");
+ _unit.stat_boosts({
+ ballistic_skill: 8,
+ constitution: 6,
+ dexterity: 4
+ });
+ _count++;
+ }
+ return _count;
+}
diff --git a/scripts/scr_shoot/scr_shoot.gml b/scripts/scr_shoot/scr_shoot.gml
index 45efa9ce8c..1ee09982d3 100644
--- a/scripts/scr_shoot/scr_shoot.gml
+++ b/scripts/scr_shoot/scr_shoot.gml
@@ -1,18 +1,22 @@
-/// @self Id.Instance.obj_enunit|Id.Instance.obj_pnunit
-/// @param {Real} weapon_index_position Weapon number
-/// @param {Id.Instance.obj_enunit|Id.Instance.obj_pnunit} target_object Target object
-/// @param {Real} target_type Target dudes
-/// @param {String} damage_data "att" or "arp" or "highest"
-/// @param {String} melee_or_ranged melee or ranged
-function scr_shoot(weapon_index_position, target_object, target_type, damage_data, melee_or_ranged) {
+function scr_shoot(weapon_index_position, target_object, target_type, damage_data, melee_or_ranged, shot_override = -1, consume_ammo = true) {
try {
- // This massive clusterfuck of a script uses the newly determined weapon and target data to attack and assign damage
- for (var j = 1; j <= 100; j++) {
- obj_ncombat.dead_ene[j] = "";
- obj_ncombat.dead_ene_n[j] = 0;
- }
- obj_ncombat.dead_enemies = 0;
+ // weapon_index_position: Weapon number
+ // target_object: Target object
+ // target_type: Target dudes
+ // damage_data: "att" or "arp" or "highest"
+ // melee_or_ranged: melee or ranged
+ // shot_override: fire only this many of the stack's shots (-1 = full stack, the
+ // default and the behaviour of every pre-existing caller). Used by enemy column
+ // piercing to split one volley across several player blocks.
+ // consume_ammo: when one weapon stack fires several sub-volleys in the same alarm
+ // (column piercing), only the first call spends the turn's ammo tick.
+ // This massive clusterfuck of a script uses the newly determined weapon and target data to attack and assign damage
+ // attack_count_mod is declared once here at function scope. It is set in the
+ // early weapon block and reset/recomputed in the that_works damage block; GML
+ // hoists var to function scope anyway, so this only makes the shared scope
+ // explicit and silences the GM2043 "outside its scope" warnings.
+ var attack_count_mod = 1;
var hostile_type;
var hostile_damage;
var hostile_weapon;
@@ -20,12 +24,39 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
var hostile_splash;
var aggregate_damage = att[weapon_index_position];
var armour_pierce = apa[weapon_index_position];
+
+ // Shooter-to-target distance in block units, threaded into scr_clean so the cover
+ // save can fade as the enemy closes (see damage_infantry / COVER_SAVE_FULL_RANGE).
+ var _shot_dist = instance_exists(target_object) ? point_distance(x, y, target_object.x, target_object.y) / 10 : 0;
+
+ // Range accuracy/damage falloff. Ranged fire hits hardest up close and softens
+ // toward the weapon's maximum range; melee and wall fire are exempt (a knife does
+ // not care about distance). Applied to dealt damage below. See RANGE_* macros.
+ var _range_mult = 1;
+ if (melee_or_ranged == "ranged") {
+ var _weapon_range = range[weapon_index_position];
+ var _range_ratio = (_weapon_range > 0) ? clamp(_shot_dist / _weapon_range, 0, 1) : 0;
+ _range_mult = clamp(RANGE_POINT_BLANK_BONUS - _range_ratio * RANGE_FALLOFF, RANGE_MIN_MULT, RANGE_POINT_BLANK_BONUS);
+ }
if (obj_ncombat.wall_destroyed == 1) {
exit;
}
if ((weapon_index_position >= 0) && instance_exists(target_object) && (owner == 2)) {
var shots_fired = wep_num[weapon_index_position];
+ if (shot_override > -1) {
+ var _stack_shots = shots_fired;
+ shots_fired = min(shot_override, shots_fired);
+ // att[] is the stack's TOTAL damage (the builder accumulates
+ // atta * man_number), and per-hit damage is aggregate / hit count.
+ // A partial volley must carry a proportional slice of the aggregate,
+ // otherwise 50 shots deliver the whole stack's damage and chip fire
+ // one-shots vehicles it should bounce off. apa[] is per-weapon
+ // (set, not summed in the builder) and stays untouched.
+ if ((_stack_shots > 0) && (shots_fired < _stack_shots)) {
+ aggregate_damage = (aggregate_damage * shots_fired) / _stack_shots;
+ }
+ }
if (shots_fired == 0 || ammo[weapon_index_position] == 0) {
exit;
}
@@ -60,7 +91,7 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
var damage_type = "";
var stop = 0;
- if (ammo[weapon_index_position] > 0) {
+ if (consume_ammo && (ammo[weapon_index_position] > 0)) {
ammo[weapon_index_position] -= 1;
}
@@ -76,7 +107,7 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
damage_type = "status";
}
- var attack_count_mod = max(1, splash[weapon_index_position]);
+ attack_count_mod = max(1, splash[weapon_index_position]);
if ((damage_type == "status") && (stop == 0) && (shots_fired > 0)) {
var damage_per_weapon = 0, hit_number = shots_fired;
@@ -95,7 +126,7 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
hostile_range = range[weapon_index_position];
hostile_splash = attack_count_mod;
- scr_clean(target_object, hostile_type, hit_number, hostile_damage, hostile_weapon, hostile_range, hostile_splash, weapon_index_position);
+ scr_clean(target_object, hostile_type, hit_number, (hostile_damage * _range_mult), hostile_weapon, hostile_range, hostile_splash, weapon_index_position, armour_pierce, _shot_dist);
}
} else if ((damage_type == "att") && (aggregate_damage > 0) && (stop == 0) && (shots_fired > 0)) {
var damage_per_weapon, hit_number;
@@ -131,7 +162,7 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
hostile_range = range[weapon_index_position];
hostile_splash = attack_count_mod;
- scr_clean(target_object, hostile_type, hit_number, hostile_damage, hostile_weapon, hostile_range, hostile_splash, weapon_index_position);
+ scr_clean(target_object, hostile_type, hit_number, (hostile_damage * _range_mult), hostile_weapon, hostile_range, hostile_splash, weapon_index_position, armour_pierce, _shot_dist);
}
} else if (((damage_type == "arp") || (damage_type == "dread")) && (armour_pierce > 0) && (stop == 0) && (shots_fired > 0)) {
var damage_per_weapon, hit_number;
@@ -182,7 +213,7 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
target_object.hostile_shooters = (wep_owner[weapon_index_position] == "assorted") ? 999 : 1;
hostile_type = 0;
- scr_clean(target_object, hostile_type, hit_number, hostile_damage, hostile_weapon, hostile_range, hostile_splash, weapon_index_position);
+ scr_clean(target_object, hostile_type, hit_number, (hostile_damage * _range_mult), hostile_weapon, hostile_range, hostile_splash, weapon_index_position, armour_pierce, _shot_dist);
}
}
}
@@ -196,11 +227,37 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
if (weapon_index_position >= 0) {
shots_fired = wep_num[weapon_index_position];
}
+ // Column piercing sub-volleys (player side). Unlike the enemy branch, no
+ // aggregate scaling is needed: player per-shot damage divides by wep_num
+ // (the full stack) rather than by shots fired, so a partial volley already
+ // deals a clean linear share of the stack's damage.
+ if (shot_override > -1) {
+ shots_fired = min(shot_override, shots_fired);
+ }
if (shots_fired == 0) {
exit;
}
+ // Guardsman accuracy: mirror the enemy's doom (the owner == eFACTION.IMPERIUM branch
+ // above) on the player side. Ranged, multi-shot lasgun stacks only, matching the enemy
+ // gating (shots_fired != 1 && not melee). Scaling shots_fired flows through total damage
+ // (c = shots_fired * final_hit_damage_value), the casualty cap, and the announced count,
+ // while damage_per_weapon stays divided by wep_num, so the cut is a clean linear share.
+ if ((weapon_index_position >= 0) && (shots_fired > 1) && (melee_or_ranged != "melee") && (wep[weapon_index_position] == "Lasgun")) {
+ shots_fired = max(1, floor(shots_fired * GUARD_DOOM));
+ }
+
+ /*if (weapon_index_position<-40){
+ if (weapon_index_position=-53){
+ if (player_silos>30) then shots_fired=30;
+ if (player_silos<30) then shots_fired=player_silos;
+ }
+ if (weapon_index_position=-51) or (weapon_index_position=-52){
+ shots_fired=round(player_silos/2);
+ }
+ }*/
+
while (target_type < array_length(target_object.dudes_hp)) {
if (target_object.dudes_hp[target_type] == 0) {
target_type++;
@@ -215,7 +272,7 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
if (ammo[weapon_index_position] == 0) {
stop = 1;
}
- if (ammo[weapon_index_position] > 0) {
+ if (consume_ammo && (ammo[weapon_index_position] > 0)) {
ammo[weapon_index_position] -= 1;
}
}
@@ -240,8 +297,6 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
if ((weapon_index_position >= 0) || (weapon_index_position < -40)) {
// Normal shooting
- var overkill = 0, damage_remaining = 0, shots_remaining = 0;
-
var that_works = false;
if (weapon_index_position >= 0) {
@@ -254,254 +309,327 @@ function scr_shoot(weapon_index_position, target_object, target_type, damage_dat
}
if (that_works == true) {
- var damage_per_weapon = 0, c = 0, target_armour_value = 0, ap = 0, wii = "";
- var attack_count_mod = 0;
+ var damage_per_weapon = 0;
+ attack_count_mod = 0;
if (weapon_index_position >= 0) {
damage_per_weapon = aggregate_damage / wep_num[weapon_index_position];
- ap = armour_pierce;
} // Average damage
if (weapon_index_position < -40) {
- wii = "";
attack_count_mod = 3;
if (weapon_index_position == -51) {
- wii = "Heavy Bolter Emplacement";
at = 160;
armour_pierce = 0;
}
if (weapon_index_position == -52) {
- wii = "Missile Launcher Emplacement";
at = 200;
armour_pierce = -1;
}
if (weapon_index_position == -53) {
- wii = "Missile Silo";
at = 250;
armour_pierce = 0;
}
}
- target_armour_value = target_object.dudes_ac[target_type];
+ attack_count_mod = max(1, splash[weapon_index_position]);
- // Calculate final armor value based on armor piercing (AP) rating against target type
- if (target_object.dudes_vehicle[target_type]) {
- if (armour_pierce == 4) {
- target_armour_value = 0;
+ // Armour multiplier indexed by AP rating (1..4); any AP outside that range
+ // leaves armour untouched. Infantry and vehicles scale differently.
+ var _inf_ap = [1, 3, 2, 1.5, 0];
+ var _veh_ap = [1, 6, 4, 2, 0];
+ var _ap_valid = (armour_pierce >= 1) && (armour_pierce <= 4);
+
+ // Never open fire on a dead rank/formation. Stale men/veh/medi (only refreshed on
+ // the enemy's own alarm) and scr_target's rank-1 fallback can aim us at corpses;
+ // snap to a living rank instead, or clean up the empty formation and bail.
+ if (!instance_exists(target_object)) {
+ exit;
+ }
+ if (target_object.dudes_num[target_type] <= 0) {
+ var _alive_rank = find_next_alive_rank(target_object, -1);
+ if (_alive_rank == -1) {
+ destroy_empty_column(target_object);
+ exit;
}
- if (armour_pierce == 3) {
- target_armour_value = target_armour_value * 2;
+ target_type = _alive_rank;
+ }
+
+ // Damage spills across ranks and, once a formation is spent, into the
+ // formation behind it. Every target actually fired upon gets its own flavour
+ // line with its own casualty count. The loop always terminates: shots_left
+ // strictly shrinks on every iteration that continues.
+ var spill_block = target_object;
+ var spill_rank = target_type;
+ var shots_left = shots_fired;
+ var touched_blocks = []; // Spill-over formations only; target_object is cleaned below.
+
+ // The whole volley posts ONE battle-log line: the first target gets the rich
+ // weapon flavour (deferred), and every later target the overflow kills is
+ // gathered into a kill list appended to it (see emit_volley_flavour).
+ var _first_target = true;
+ var _primary_flavour = undefined;
+ var _spill_kills = []; // [{ name, count }] for targets killed after the first.
+
+ while (shots_left > 0) {
+ // This target's armour against our AP rating.
+ var _armour = spill_block.dudes_ac[spill_rank];
+ if (_ap_valid) {
+ var _ap_table = spill_block.dudes_vehicle[spill_rank] ? _veh_ap : _inf_ap;
+ _armour *= _ap_table[armour_pierce];
}
- if (armour_pierce == 2) {
- target_armour_value = target_armour_value * 4;
+ var final_hit = max(0, (damage_per_weapon * _range_mult - (_armour * attack_count_mod)) * spill_block.dudes_dr[spill_rank]);
+
+ var rank_num = spill_block.dudes_num[spill_rank];
+ var rank_hp = spill_block.dudes_hp[spill_rank];
+ var total_damage = shots_left * final_hit;
+ var raw_kills = floor(total_damage / rank_hp);
+ var casualties = min(raw_kills, rank_num, shots_left * attack_count_mod);
+
+ // Surplus damage only spills once this rank is actually wiped out.
+ var next_shots = 0;
+ if ((casualties >= rank_num) && (rank_num > 0) && (raw_kills > rank_num)) {
+ next_shots = max(0, shots_left - ceil((rank_num * rank_hp) / final_hit));
}
- if (armour_pierce == 1) {
- target_armour_value = target_armour_value * 6;
+
+ // Gather flavour. The first target carries the rich phrasing (deferred, with
+ // the full weapon count); later targets just contribute to the kill list.
+ // final_hit <= 0 means armour stopped the shots cold (AP too low).
+ if (_first_target) {
+ // Grazing severity for the wound-no-kill case: how far this volley's
+ // damage got toward killing one model (0..1), so scr_flavor can grade the
+ // player's wound line the way the enemy side already grades incoming fire.
+ var _graze_sev = ((final_hit > 0) && (casualties == 0) && (rank_hp > 0)) ? clamp(total_damage / rank_hp, 0, 1) : 0;
+ _primary_flavour = scr_flavor(weapon_index_position, spill_block, spill_rank, shots_fired, casualties, final_hit <= 0, true, _graze_sev);
+ _first_target = false;
+ } else if (casualties > 0) {
+ array_push(_spill_kills, { name: spill_block.dudes[spill_rank], count: casualties });
}
- } else {
- if (armour_pierce == 4) {
- target_armour_value = 0;
+
+ if ((rank_num == 1) && (casualties == 0) && (total_damage > 0)) {
+ spill_block.dudes_hp[spill_rank] -= total_damage; // Chip a lone survivor
+ if (spill_block.dudes_hp[spill_rank] <= 0) {
+ // Chipped to death: remove it now and drop the force count. Otherwise
+ // dudes_num stays 1 at dudes_hp <= 0 - a "zombie" that find_next_alive_rank
+ // skips, so it's never finished off and keeps inflating enemy_forces.
+ spill_block.dudes_num[spill_rank] = 0;
+ obj_ncombat.enemy_forces -= 1;
+ }
}
- if (armour_pierce == 3) {
- target_armour_value = target_armour_value * 1.5;
+ if (casualties >= 1) {
+ spill_block.dudes_num[spill_rank] -= casualties;
+ obj_ncombat.enemy_forces -= casualties;
}
- if (armour_pierce == 2) {
- target_armour_value = target_armour_value * 2;
+
+ shots_left = next_shots;
+ if (shots_left <= 0) {
+ break;
}
- if (armour_pierce == 1) {
- target_armour_value = target_armour_value * 3;
+
+ // Next target: a living rank in this formation, else the formation behind.
+ var next_rank = find_next_alive_rank(spill_block, spill_block.dudes_vehicle[spill_rank]);
+ if (next_rank == -1) {
+ var _spent_x = spill_block.x;
+ spill_block = get_next_enemy_formation(spill_block);
+ if (spill_block == noone) {
+ break;
+ }
+ // Overkill only spills into a formation standing directly
+ // behind the one just wiped (touching columns). An air gap
+ // stops it: the tester's cultists two rows back, with open
+ // ground between them and the CSM, were being slaughtered
+ // by thunder hammer spill leaping the gap.
+ if (abs(spill_block.x - _spent_x) > OVERKILL_SPILL_MAX_GAP) {
+ break;
+ }
+ array_push(touched_blocks, spill_block);
+ next_rank = find_next_alive_rank(spill_block, -1);
+ if (next_rank == -1) {
+ break;
+ }
}
+ spill_rank = next_rank;
}
- attack_count_mod = max(1, splash[weapon_index_position]);
-
- final_hit_damage_value = max(0, damage_per_weapon - target_armour_value); //damage armour reduction
+ // Post the single consolidated line for the whole volley.
+ emit_volley_flavour(_primary_flavour, _spill_kills);
- final_hit_damage_value *= target_object.dudes_dr[target_type]; //damage_resistance mod
+ // Clean up the spill-over formations (target_object is handled below).
+ for (var _tb = 0; _tb < array_length(touched_blocks); _tb++) {
+ if (instance_exists(touched_blocks[_tb])) {
+ compress_enemy_array(touched_blocks[_tb]);
+ destroy_empty_column(touched_blocks[_tb]);
+ }
+ }
+ }
+ }
- c = shots_fired * final_hit_damage_value; // New damage
+ if (stop == 0) {
+ compress_enemy_array(target_object);
+ destroy_empty_column(target_object);
+ }
+ }
+ } catch (_exception) {
+ ERROR_HANDLER.handle_exception(_exception);
+ }
+}
- var casualties, onceh = 0, ponies = 0;
+/// @function find_next_alive_rank
+/// @description Returns the index of the next living rank (dudes_num > 0 and dudes_hp > 0) in a
+/// formation, preferring ranks that match the requested vehicle flag. Returns -1 if
+/// none. The dudes_hp > 0 check keeps callers safe from dividing by a rank's HP.
+/// @param {Id.Instance} _block The obj_enunit formation to search.
+/// @param {Real} _prefer_vehicle 0/1 to prefer that category, or -1 for any living rank.
+/// @returns {Real}
+function find_next_alive_rank(_block, _prefer_vehicle) {
+ if (!instance_exists(_block)) {
+ return -1;
+ }
+ var _fallback = -1;
+ for (var f = 1; f <= 30; f++) {
+ if (_block.dudes_num[f] <= 0 || _block.dudes_hp[f] <= 0) {
+ continue;
+ }
+ if (_prefer_vehicle == -1 || _block.dudes_vehicle[f] == _prefer_vehicle) {
+ return f;
+ }
+ if (_fallback == -1) {
+ _fallback = f;
+ }
+ }
+ return _fallback;
+}
- casualties = min(floor(c / target_object.dudes_hp[target_type]), shots_fired * attack_count_mod);
+/// @function get_next_enemy_formation
+/// @description Returns the nearest enemy formation (obj_enunit) sitting behind the given one
+/// that still contains at least one living rank, or noone if there isn't one.
+/// @param {Id.Instance} _block The formation we are spilling out of.
+/// @returns {Id.Instance}
+function get_next_enemy_formation(_block) {
+ if (!instance_exists(_block)) {
+ return noone;
+ }
+ var _bx = _block.x;
+ var _bid = _block.id;
+ var _best = noone;
+ var _best_x = 0;
+ with (obj_enunit) {
+ if (id == _bid) {
+ continue;
+ }
+ if (x <= _bx) {
+ continue;
+ }
+ if (find_next_alive_rank(id, -1) == -1) {
+ continue;
+ }
+ if (_best == noone || x < _best_x) {
+ _best = id;
+ _best_x = x;
+ }
+ }
+ return _best;
+}
- ponies = target_object.dudes_num[target_type];
- if ((target_object.dudes_num[target_type] == 1) && ((target_object.dudes_hp[target_type] - c) <= 0)) {
- casualties = 1;
- }
+/// @self Asset.GMObject.obj_pnunit
+/// @description Speed Force: sweep the whole enemy force, dividing damage proportionally to rank
+/// size, and report it as ONE consolidated volley line (see emit_volley_flavour).
+/// @param {Real} weapon_index_position The Speed Force weapon stack index.
+function scr_shoot_spread(weapon_index_position) {
+ try {
+ if (wep_num[weapon_index_position] <= 0 || ammo[weapon_index_position] == 0) {
+ exit;
+ }
- if (target_object.dudes_num[target_type] - casualties < 0) {
- overkill = casualties - target_object.dudes_num[target_type];
- damage_remaining = c - (overkill * target_object.dudes_hp[target_type]);
+ var _shots = wep_num[weapon_index_position];
+ var _ap = apa[weapon_index_position];
+ var _dpw = att[weapon_index_position] / _shots; // per-bike damage
+ var _mod = max(1, splash[weapon_index_position]);
+ if (ammo[weapon_index_position] > 0) {
+ ammo[weapon_index_position] -= 1;
+ }
- shots_remaining = round(damage_remaining / damage_per_weapon);
- }
+ // Armour multiplier indexed by AP rating (1..4), matching scr_shoot's normal path.
+ var _inf_ap = [1, 3, 2, 1.5, 0];
+ var _veh_ap = [1, 6, 4, 2, 0];
+ var _ap_valid = (_ap >= 1) && (_ap <= 4);
+
+ // Total living models across every formation on the field.
+ var _formations = [];
+ var _total = 0;
+ with (obj_enunit) {
+ array_push(_formations, id);
+ for (var r = 1; r <= 30; r++) {
+ if (dudes[r] != "" && dudes_num[r] > 0) {
+ _total += dudes_num[r];
+ }
+ }
+ }
+ if (_total <= 0) {
+ exit;
+ }
- if (target_object.dudes_num[target_type] - casualties < 0) {
- casualties = ponies;
- }
- if (casualties < 0) {
- casualties = 0;
- }
+ // Apply damage proportionally to each rank's share of the field; record every rank that lost models.
+ var _hits = []; // [{ name, kills, bounced }]
+ for (var fi = 0; fi < array_length(_formations); fi++) {
+ var _f = _formations[fi];
+ if (!instance_exists(_f)) {
+ continue;
+ }
+ for (var r = 1; r <= 30; r++) {
+ if (_f.dudes[r] == "" || _f.dudes_num[r] <= 0) {
+ continue;
+ }
- if (casualties >= 1) {
- var iii = 0, found = 0, openz = 0;
- for (iii = 0; iii <= 40; iii++) {
- iii += 1;
- if (found == 0) {
- if ((obj_ncombat.dead_ene[iii] == "") && (openz == 0)) {
- openz = iii;
- }
- if ((obj_ncombat.dead_ene[iii] == target_object.dudes[target_type]) && (found == 0)) {
- found = iii;
- obj_ncombat.dead_ene_n[obj_ncombat.dead_enemies] += casualties;
- }
- }
- }
- if (found == 0) {
- obj_ncombat.dead_enemies += 1;
- obj_ncombat.dead_ene[openz] = string(target_object.dudes[target_type]);
- obj_ncombat.dead_ene_n[openz] = casualties;
- }
- }
+ var _armour = _f.dudes_ac[r];
+ if (_ap_valid) {
+ var _ap_table = _f.dudes_vehicle[r] ? _veh_ap : _inf_ap;
+ _armour *= _ap_table[_ap];
+ }
- var k = 0;
- if ((damage_remaining > 0) && (shots_remaining > 0)) {
- repeat (10) {
- if ((damage_remaining > 0) && (shots_remaining > 0)) {
- var godd;
- godd = 0;
- k = target_type;
-
- // Find similar target in this same group
- repeat (10) {
- k += 1;
- if (godd == 0) {
- if ((target_object.dudes_num[k] > 0) && (target_object.dudes_vehicle[k] == target_object.dudes_vehicle[target_type])) {
- godd = k;
- }
- }
- }
- k = target_type;
- if (godd == 0) {
- repeat (10) {
- k -= 1;
- if ((godd == 0) && (k >= 1)) {
- if ((target_object.dudes_num[k] > 0) && (target_object.dudes_vehicle[k] == target_object.dudes_vehicle[target_type])) {
- godd = k;
- }
- }
- }
- }
-
- // Found damage_per_weapon similar target to get the damage
- if ((godd > 0) && (damage_remaining > 0) && (shots_remaining > 0)) {
- var a2, b2, c2, target_armour_value2, ap2;
- ap2 = damage_remaining;
- a2 = damage_per_weapon; // Average damage
-
- target_armour_value2 = target_object.dudes_ac[godd];
- if (target_object.dudes_vehicle[godd] == 0) {
- if (ap2 == 1) {
- target_armour_value2 = target_armour_value2 * 3;
- }
- if (ap2 == 2) {
- target_armour_value2 = target_armour_value2 * 2;
- }
- if (ap2 == 3) {
- target_armour_value2 = target_armour_value2 * 1.5;
- }
- if (ap2 == 4) {
- target_armour_value2 = 0;
- }
- }
- if (target_object.dudes_vehicle[godd] == 1) {
- if (ap2 == 1) {
- target_armour_value2 = target_armour_value2 * 6;
- }
- if (ap2 == 2) {
- target_armour_value2 = target_armour_value2 * 4;
- }
- if (ap2 == 3) {
- target_armour_value2 = target_armour_value2 * 2;
- }
- }
- b2 = a2 - target_armour_value2;
- if (b2 <= 0) {
- b2 = 0;
- } // Average after armour
-
- c2 = b2 * shots_remaining; // New damage
-
- var casualties2 = 0;
- var onceh2 = 0;
- var ponies2 = 0;
- if (attack_count_mod <= 1) {
- casualties2 = min(floor(c2 / target_object.dudes_hp[godd]), shots_remaining);
- }
-
- if (attack_count_mod > 1) {
- casualties2 = floor(c2 / target_object.dudes_hp[godd]);
- }
- ponies2 = target_object.dudes_num[godd];
- if ((target_object.dudes_num[godd] == 1) && ((target_object.dudes_hp[godd] - c2) <= 0)) {
- casualties2 = 1;
- }
- if (target_object.dudes_num[godd] < casualties2) {
- casualties2 = target_object.dudes_num[godd];
- }
- if (casualties2 < 1) {
- casualties2 = 0;
- damage_remaining = 0;
- overkill = 0;
- shots_remaining = 0;
- }
-
- if ((casualties2 >= 1) && (shots_fired > 0)) {
- var iii = 0;
- var found = 0;
- var openz = 0;
- repeat (40) {
- iii += 1;
- if (found == 0) {
- if ((obj_ncombat.dead_ene[iii] == "") && (openz == 0)) {
- openz = iii;
- }
- if ((obj_ncombat.dead_ene[iii] == target_object.dudes[godd]) && (found == 0)) {
- found = iii;
- obj_ncombat.dead_ene_n[obj_ncombat.dead_enemies] += casualties;
- }
- }
- }
- if (found == 0) {
- obj_ncombat.dead_enemies += 1;
- obj_ncombat.dead_ene[openz] = string(target_object.dudes[godd]);
- obj_ncombat.dead_ene_n[openz] = casualties;
- }
-
- target_object.dudes_num[godd] -= casualties2;
- obj_ncombat.enemy_forces -= casualties2;
- }
- }
- }
- }
- } // End repeat 10
- scr_flavor(weapon_index_position, target_object, target_type, shots_fired - wep_rnum[weapon_index_position], casualties);
+ var _rank_shots = _shots * (_f.dudes_num[r] / _total);
+ var _final_hit = max(0, (_dpw * _range_mult - (_armour * _mod)) * _f.dudes_dr[r]);
+ var _kills = min(floor((_rank_shots * _final_hit) / _f.dudes_hp[r]), _f.dudes_num[r]);
+ if (_kills < 0) {
+ _kills = 0;
+ }
- if ((target_object.dudes_num[target_type] == 1) && (c > 0)) {
- target_object.dudes_hp[target_type] -= c;
- } // Need special flavor here for just damaging
+ if (_kills > 0) {
+ _f.dudes_num[r] -= _kills;
+ obj_ncombat.enemy_forces -= _kills;
+ array_push(_hits, { name: _f.dudes[r], kills: _kills, bounced: (_final_hit <= 0), block: _f, rank: r });
+ }
+ }
+ }
- if (casualties >= 1) {
- target_object.dudes_num[target_type] -= casualties;
- obj_ncombat.enemy_forces -= casualties;
- }
+ // Primary = the rank with the most kills (rich deferred flavour); the rest form the kill list.
+ var _primary = undefined;
+ var _spill = [];
+ if (array_length(_hits) > 0) {
+ var _best = 0;
+ for (var i = 1; i < array_length(_hits); i++) {
+ if (_hits[i].kills > _hits[_best].kills) {
+ _best = i;
+ }
+ }
+ for (var i = 0; i < array_length(_hits); i++) {
+ if (i == _best) {
+ continue;
}
+ array_push(_spill, { name: _hits[i].name, count: _hits[i].kills });
}
+ var _p = _hits[_best];
+ if (instance_exists(_p.block)) {
+ _primary = scr_flavor(weapon_index_position, _p.block, _p.rank, _shots, _p.kills, _p.bounced, true);
+ }
+ }
+ emit_volley_flavour(_primary, _spill);
- if (stop == 0) {
- compress_enemy_array(target_object);
- destroy_empty_column(target_object);
+ // Clean up spent ranks/formations (mirrors scr_shoot).
+ for (var fi = 0; fi < array_length(_formations); fi++) {
+ if (instance_exists(_formations[fi])) {
+ compress_enemy_array(_formations[fi]);
+ destroy_empty_column(_formations[fi]);
}
}
} catch (_exception) {
diff --git a/scripts/scr_special_view/scr_special_view.gml b/scripts/scr_special_view/scr_special_view.gml
index e597e6387b..08ff2b466e 100644
--- a/scripts/scr_special_view/scr_special_view.gml
+++ b/scripts/scr_special_view/scr_special_view.gml
@@ -72,6 +72,18 @@ function scr_special_view(command_group) {
}
}
+ if ((command_group == 16) || (command_group == 0)) {
+ // Auxilia (Guardsmen and other auxiliary mercs). Gathered by role, not specialist
+ // group, since they are mustered into company 0 with race IMPERIUM but are not Astartes
+ // specialists. auxilia_roles() is the single source of truth shared with the HQ exclusion.
+ var auxilia = collect_role_group("all", "", false, {roles: auxilia_roles()});
+ for (var i = 0; i < array_length(auxilia); i++) {
+ unit = auxilia[i];
+ array_push(_already_used, unit.marine_number);
+ add_man_to_manage_arrays(auxilia[i]);
+ }
+ }
+
if ((command_group == 11) || (command_group == 0)) {
//HQ units
for (var v = 0; v < array_length(obj_ini.TTRPG[0]); v++) {
@@ -88,6 +100,8 @@ function scr_special_view(command_group) {
}
yep = !(_unit.IsSpecialist(SPECIALISTS_TECHS) || _unit.IsSpecialist(SPECIALISTS_CHAPLAINS) || _unit.IsSpecialist(SPECIALISTS_LIBRARIANS) || _unit.IsSpecialist(SPECIALISTS_APOTHECARIES));
+ // Auxilia mercs live in company 0 but belong to the Auxilia screen, not Headquarters.
+ yep = yep && !array_contains(auxilia_roles(), _unit.role());
if (yep) {
add_man_to_manage_arrays(_unit);
}
diff --git a/scripts/scr_start_load/scr_start_load.gml b/scripts/scr_start_load/scr_start_load.gml
index f35b38b27d..700fcc16b8 100644
--- a/scripts/scr_start_load/scr_start_load.gml
+++ b/scripts/scr_start_load/scr_start_load.gml
@@ -55,7 +55,7 @@ function scr_start_load(fleet, load_from_star, load_options) {
}
}
// i feel like there definatly is or should be a generic function for this????
- var _vehicles = ["Rhino", "Predator", "Land Speeder", "Land Raider", "Whirlwind"];
+ var _vehicles = ["Rhino", "Predator", "Land Speeder", "Land Raider", "Whirlwind", "Chimera", "Leman Russ", "Basilisk"];
function load_vehicles(_companies, _equip, _ship, size) {
obj_ini.veh_wid[_companies][_equip] = 0;
obj_ini.veh_lid[_companies][_equip] = _ship;
diff --git a/scripts/scr_string_functions/scr_string_functions.gml b/scripts/scr_string_functions/scr_string_functions.gml
index d0af0b475c..da1b68bbbf 100644
--- a/scripts/scr_string_functions/scr_string_functions.gml
+++ b/scripts/scr_string_functions/scr_string_functions.gml
@@ -127,6 +127,62 @@ function string_truncate(_string, _max_width) {
}
}
+/// @function string_truncate_planet
+/// @description Like string_truncate, but preserves a trailing planet numeral so a
+/// location such as "Mandragoran III" truncates to "Mandrag... III" instead
+/// of chopping the numeral off the end. Keeps squads assigned to different
+/// planets in the same system distinguishable in the management list.
+/// Strings with no planet numeral (ship names, "=Penitorium=", "=Event=")
+/// fall back to plain string_truncate.
+/// @param {string} _string
+/// @param {real} _max_width The maximum allowable pixel width for the string.
+/// @returns {string}
+function string_truncate_planet(_string, _max_width) {
+ _string = string(_string);
+ if (string_width(_string) <= _max_width) {
+ return _string;
+ }
+
+ // Detect a trailing planet numeral by matching the numerals the game actually
+ // appends (scr_roman). Longest match wins so "IV" is not mistaken for a bare
+ // trailing "V", and matching known tokens avoids splitting a name that merely ends
+ // in a roman-numeral letter.
+ var _numeral = "";
+ var _name = _string;
+ var _best_len = 0;
+ for (var n = 1; n <= 12; n++) {
+ var _r = scr_roman(n);
+ if (is_undefined(_r) || (_r == "")) {
+ continue;
+ }
+ var _rlen = string_length(_r);
+ if ((_rlen > _best_len) && (string_length(_string) > _rlen) && (string_copy(_string, string_length(_string) - _rlen + 1, _rlen) == _r)) {
+ _best_len = _rlen;
+ _numeral = _r;
+ _name = string_copy(_string, 1, string_length(_string) - _rlen);
+ }
+ }
+
+ if (_numeral == "") {
+ return string_truncate(_string, _max_width);
+ }
+
+ // Drop any spaces the name had before the numeral; a single separating space is
+ // re-added with the ellipsis.
+ while ((string_length(_name) > 0) && (string_char_at(_name, string_length(_name)) == " ")) {
+ _name = string_copy(_name, 1, string_length(_name) - 1);
+ }
+
+ var _tail = "... " + _numeral;
+ var _tail_width = string_width(_tail);
+ var _name_trunc = _name;
+ while (((string_width(_name_trunc) + _tail_width) > _max_width) && (string_length(_name_trunc) > 0)) {
+ _name_trunc = string_copy(_name_trunc, 1, string_length(_name_trunc) - 1);
+ }
+
+ return _name_trunc + _tail;
+}
+
/// @function integer_to_words
/// @description Converts an integer to an english word.
/// @param {real} _integer
diff --git a/scripts/scr_trade/scr_trade.gml b/scripts/scr_trade/scr_trade.gml
index 9fd3387175..812d94389c 100644
--- a/scripts/scr_trade/scr_trade.gml
+++ b/scripts/scr_trade/scr_trade.gml
@@ -17,6 +17,12 @@ function TradeAttempt(diplomacy) constructor {
"Inferno Bolts": 5,
"Sister of Battle": 40,
"Sister Hospitaler": 75,
+ "Guardsman": 0.5,
+ "Leman Russ": 300,
+ // A Basilisk had no entry here at all; the worth calculation is guarded by
+ // struct_exists, so missing entries contribute 0 and Basilisks negotiated as
+ // free once the 50-disposition gate was passed.
+ "Basilisk": 400,
"Eldar Power Sword": 50,
"Archeotech Laspistol": 150,
"Ranger": 100,
@@ -83,13 +89,86 @@ function TradeAttempt(diplomacy) constructor {
} else if (_opt.trade_type == "req") {
obj_controller.requisition += _opt.number;
} else if (_opt.trade_type == "merc") {
- if (!struct_exists(trading_object, "mercenaries")) {
- trading_object.mercenaries = {};
+ if (_type == "Guardsman") {
+ // The Sector Governor raises the regiment straight from his homeworld
+ // PDF, so they muster at the chapter's home planet at once with no
+ // convoy. An earlier patch dropped this and routed them onto the
+ // convoy stash instead, so they never arrived; this restores it.
+ repeat (_opt.number) {
+ scr_add_man("Guardsman", 0, "", "", 0, true, "home_planet", {skip_company_order: true});
+ }
+ // One Guard Sergeant musters with every full squad of GUARD_SQUAD_SIZE
+ // guardsmen, raised from the same homeworld levy and attached to his men. He
+ // rides the same path as a guardsman, just a tougher melee-focused profile.
+ repeat (floor(_opt.number / GUARD_SQUAD_SIZE)) {
+ scr_add_man("Guard Sergeant", 0, "", "", 0, true, "home_planet", {skip_company_order: true});
+ }
+ // One Chimera transport is assigned per 200 guardsmen to carry and screen the
+ // levy. They muster into the Auxilia company (company 0) alongside the guardsmen,
+ // not the transient new_vehicles staging company, so they stay grouped with the
+ // levy and persist after the battle instead of being reorganised out of the
+ // build-staging slot.
+ repeat (floor(_opt.number / 200)) {
+ scr_add_vehicle("Chimera", 0);
+ }
+ // One Heavy Weapons Team musters per 100 guardsmen, a Heavy Bolter crew
+ // drawn from the same homeworld levy to give the auxilia organic anti-heavy
+ // fire. This mirrors how the enemy Guard field Heavy Weapons Teams, scaled to
+ // the player at 1 per 100. They muster into the Auxilia company (company 0)
+ // alongside the guardsmen. The /100 ratio is tunable.
+ repeat (floor(_opt.number / 100)) {
+ scr_add_man("Heavy Weapons Team", 0, "", "", 0, true, "home_planet", {skip_company_order: true});
+ }
+ with (obj_ini) {
+ scr_company_order(0);
+ }
+ // Space Marines commandeering Imperial Guard is frowned upon since the
+ // Heresy and the reign of Goge Vandire, so the Sector Governor's regard dips
+ // with the size of the levy: about 1 per 200 raised (so a full 2000 costs 10),
+ // floored at 1 so even a token request costs a little standing.
+ alter_disposition(diplomacy_faction, -max(1, round(_opt.number / 200)));
+ } else if (_type == "Leman Russ") {
+ // The Adeptus Mechanicus forge and part with their tanks reluctantly. Each
+ // Leman Russ musters into the Auxilia company (company 0) alongside the guard
+ // levy so the force can field its own armour line, then the company is reordered
+ // so the tanks settle into their slots.
+ repeat (_opt.number) {
+ scr_add_vehicle("Leman Russ", 0);
+ }
+ with (obj_ini) {
+ scr_company_order(0);
+ }
+ // Handing over sacred war materiel costs the forge world some regard, one point
+ // per tank with a floor of one, so a larger order stings their disposition more.
+ alter_disposition(diplomacy_faction, -max(1, _opt.number));
+ } else if (_type == "Basilisk") {
+ // The Adeptus Mechanicus part with a Basilisk only grudgingly. Each musters
+ // into the Auxilia company (company 0) alongside the guard armour line so the
+ // force can field its own artillery. The Basilisk serves the Imperial Guard
+ // auxilia, not the Chapter, so it is tagged eFACTION.IMPERIUM to match the
+ // guardsmen it supports rather than the player's own colours.
+ repeat (_opt.number) {
+ scr_add_vehicle("Basilisk", 0);
+ }
+ for (var _bv = 1; _bv < array_length(obj_ini.veh_role[0]); _bv++) {
+ if (obj_ini.veh_role[0][_bv] == "Basilisk") {
+ obj_ini.veh_race[0][_bv] = eFACTION.IMPERIUM;
+ }
+ }
+ with (obj_ini) {
+ scr_company_order(0);
+ }
+ alter_disposition(diplomacy_faction, -max(1, _opt.number));
+ } else {
+ // Every other mercenary type arrives by trade convoy.
+ if (!struct_exists(trading_object, "mercenaries")) {
+ trading_object.mercenaries = {};
+ }
+ trading_object.mercenaries[$ _type] = {
+ quality: "standard",
+ number: _opt.number,
+ };
}
- trading_object.mercenaries[$ _type] = {
- quality: "standard",
- number: _opt.number,
- };
} else if (_opt.trade_type == "arti") {
scr_add_artifact("random", "minor", true);
} else if (_opt.trade_type == "vehic") {
@@ -372,6 +451,7 @@ function TradeAttempt(diplomacy) constructor {
switch (diplomacy_faction) {
case 2:
new_demand_buttons(0, "Requisition", "req");
+ new_demand_buttons(35, "Guardsman", "merc", 2000);
new_demand_buttons(0, "Recruiting Planet", "license", 1);
new_demand_buttons(0, "License: Repair", "license", 1);
new_demand_buttons(0, "License: Crusade", "license", 1);
@@ -380,6 +460,8 @@ function TradeAttempt(diplomacy) constructor {
new_demand_buttons(40, "Minor Artifact", "arti", 1);
new_demand_buttons(25, "Skitarii", "merc", 200);
new_demand_buttons(55, "Techpriest", "merc", 3);
+ new_demand_buttons(40, "Leman Russ", "merc", 10);
+ new_demand_buttons(50, "Basilisk", "merc", 5);
break;
case 4:
new_demand_buttons(25, "Crusader", "merc", 5);
@@ -671,6 +753,35 @@ function TradeAttempt(diplomacy) constructor {
deal_chance = (100 - penalty) - (their_worth - (my_worth * dif_penalty));
//LOGGER.debug($"{their_worth},{my_worth},{deal_chance}");
+
+ // Guardsmen are abundant Imperial line troops the Sector Governor hands over by the
+ // thousand, not a haggled rarity, so the base trade overhead does not apply to a
+ // pure Guard levy. They cost a flat 0.5 requisition each (500 per 1000), priced
+ // for the full combined-arms levy that arrives with them: a Guard Sergeant per
+ // squad, a Heavy Weapons Team per 100, and a Chimera per 200. If the
+ // offered requisition covers the guardsmen demanded the Governor obliges outright,
+ // otherwise he declines. Only triggers when guardsmen are the sole demand, so it
+ // cannot be used to slip other goods through cheaply.
+ var _guard_num = 0;
+ var _other_demand = false;
+ for (var gi = 0; gi < array_length(demand_options); gi++) {
+ if (demand_options[gi].number > 0) {
+ if (demand_options[gi].label == "Guardsman") {
+ _guard_num += demand_options[gi].number;
+ } else {
+ _other_demand = true;
+ }
+ }
+ }
+ if ((_guard_num > 0) && !_other_demand) {
+ var _req_offered = 0;
+ for (var ri = 0; ri < array_length(offer_options); ri++) {
+ if (offer_options[ri].label == "Requisition") {
+ _req_offered += offer_options[ri].number;
+ }
+ }
+ deal_chance = _req_offered >= ceil(_guard_num * 0.5) ? 100 : 0;
+ }
var _chance = clamp(floor((deal_chance / 20)), 0, 6);
trade_likely = chance_chart[_chance];
diff --git a/scripts/scr_trade_dep/scr_trade_dep.gml b/scripts/scr_trade_dep/scr_trade_dep.gml
index 58d5293fc8..6506d37f27 100644
--- a/scripts/scr_trade_dep/scr_trade_dep.gml
+++ b/scripts/scr_trade_dep/scr_trade_dep.gml
@@ -5,10 +5,19 @@ function scr_trade_dep() {
//LOGGER.debug($"trade goods : {_goods}");
if (struct_exists(_goods, "mercenaries")) {
var _mercs = struct_get_names(_goods.mercenaries);
+ var _spawned_any = false;
for (var m = 0; m < array_length(_mercs); m++) {
var _merc_type = _mercs[m];
repeat (_goods.mercenaries[$ _merc_type].number) {
- scr_add_man(_merc_type, 0, "", "", 0, true, "default");
+ // Skip the per-spawn company re-sort; one sort after the batch keeps a
+ // large levy (a thousand guardsmen) fast instead of O(n squared).
+ scr_add_man(_merc_type, 0, "", "", 0, true, "default", {skip_company_order: true});
+ _spawned_any = true;
+ }
+ }
+ if (_spawned_any) {
+ with (obj_ini) {
+ scr_company_order(0);
}
}
}
diff --git a/scripts/scr_ui_manage/scr_ui_manage.gml b/scripts/scr_ui_manage/scr_ui_manage.gml
index 290f42e08e..688efd0546 100644
--- a/scripts/scr_ui_manage/scr_ui_manage.gml
+++ b/scripts/scr_ui_manage/scr_ui_manage.gml
@@ -489,6 +489,9 @@ function company_specific_management() {
case 15:
_company_name = "Armamentarium";
break;
+ case 16:
+ _company_name = "Auxilia";
+ break;
}
}
// Draw the company followed by chapters name
@@ -660,7 +663,35 @@ function draw_sprite_and_unit_equip_data() {
// Draw unit image
draw_set_color(c_white);
- if (is_struct(obj_controller.unit_manage_image)) {
+ // ===== Vehicle portraits (mod) =====
+ // Vehicles never route through scr_draw_unit_image and set no unit_focus, so
+ // they had no portrait anywhere. Hovering a vehicle row (see
+ // scr_draw_management_unit) stores its role in vehicle_portrait_role, and the
+ // portrait slot draws a runtime PNG from images\units\ instead of the last
+ // unit's image, scaled to fit the frame. Adding a new vehicle portrait is one
+ // line in the struct below: role name on the left, PNG filename on the right.
+ var _veh_portrait_files = {
+ Chimera: "chimera",
+ };
+ var _veh_p_role = variable_instance_exists(id, "vehicle_portrait_role") ? vehicle_portrait_role : "";
+ var _veh_p_drawn = false;
+ if ((_veh_p_role != "") && struct_exists(_veh_portrait_files, _veh_p_role)) {
+ if (!variable_global_exists("vehicle_portraits")) {
+ global.vehicle_portraits = {};
+ }
+ if (!struct_exists(global.vehicle_portraits, _veh_p_role)) {
+ global.vehicle_portraits[$ _veh_p_role] = sprite_add(working_directory + "/images/units/" + _veh_portrait_files[$ _veh_p_role] + ".png", 1, false, false, 0, 0);
+ }
+ var _veh_spr = global.vehicle_portraits[$ _veh_p_role];
+ if (sprite_exists(_veh_spr)) {
+ var _vp_w = sprite_get_width(_veh_spr);
+ var _vp_h = sprite_get_height(_veh_spr);
+ var _vp_scale = min(1, 166 / _vp_w, 232 / _vp_h);
+ draw_sprite_ext(_veh_spr, 0, xx + 320 + ((166 - (_vp_w * _vp_scale)) / 2), yy + 109 + ((232 - (_vp_h * _vp_scale)) / 2), _vp_scale, _vp_scale, 0, c_white, 1);
+ _veh_p_drawn = true;
+ }
+ }
+ if (!_veh_p_drawn && is_struct(obj_controller.unit_manage_image)) {
obj_controller.unit_manage_image.draw(xx + 320, yy + 109);
}
@@ -809,7 +840,17 @@ function scr_ui_manage() {
squad_sel = -1;
squad_sel_action = -1;
}
- if (man_size < 1) {
+ // Only clear the selection when nothing is actually picked. The old check reset
+ // on any total below 1.0, which wiped fractional-size selections (guardsmen at
+ // 0.1) before they could reach a full marine's worth of space.
+ var _has_selection = false;
+ for (var _ms = 0; _ms < array_length(man_sel); _ms++) {
+ if (man_sel[_ms] == 1) {
+ _has_selection = true;
+ break;
+ }
+ }
+ if (man_size < 1 && !_has_selection) {
reset_manage_selections();
}
var unit;
@@ -1027,8 +1068,44 @@ function scr_ui_manage() {
};
if (!obj_controller.view_squad) {
+ // Auxilia squad collapse (managing 16): render Guard squads as single
+ // selectable rows instead of hundreds of individual guardsman rows, so ship
+ // loading is squad clicks rather than per-man clicks. Toggleable; defaults on.
+ if (!variable_instance_exists(id, "auxilia_squad_collapse")) {
+ auxilia_squad_collapse = true;
+ }
+ if (!variable_instance_exists(id, "marine_squad_collapse")) {
+ marine_squad_collapse = true;
+ }
+ if ((managing == 16) && auxilia_squad_collapse) {
+ draw_auxilia_squad_rows(xx, yy, stats_displayed);
+ } else if ((managing >= 1) && (managing <= 10) && marine_squad_collapse) {
+ // Marine squad collapse: same collapsed-row treatment as the Auxilia
+ // view, but grouped by the real squads system (unit.squad ids), with
+ // squad-type names ("Tactical Squad 2") instead of generic numbering.
+ draw_marine_squad_rows(xx, yy, stats_displayed, get_command_slots_data());
+ } else {
var repetitions = min(man_max, MANAGE_MAN_SEE);
man_count = 0;
+ if ((managing == 16) || ((managing >= 1) && (managing <= 10))) {
+ // First roster row becomes the view toggle, consuming a row slot the same
+ // way company command-slot prompts do. "Squad List" for marines to avoid
+ // clashing with the existing Squad View button in the unit panel.
+ var _sq_label = (managing == 16) ? "Switch to Squad View" : "Switch to Squad List View";
+ var _sq_btn = draw_unit_buttons([xx + 25, yy + 64, xx + 974, yy + 85], _sq_label, [1, 1], CM_GREEN_COLOR, fa_center, fnt_40k_14b);
+ draw_set_halign(fa_left);
+ draw_set_valign(fa_top);
+ if (point_and_click(_sq_btn)) {
+ if (managing == 16) {
+ auxilia_squad_collapse = true;
+ } else {
+ marine_squad_collapse = true;
+ }
+ man_current = 0;
+ }
+ yy += 20;
+ repetitions--;
+ }
var _command_slots_data = get_command_slots_data();
draw_set_font(fnt_40k_14);
@@ -1119,6 +1196,7 @@ function scr_ui_manage() {
draw_set_color(#3f7e5d);
scr_scrollbar(974, 172, 1005, 790, 34, man_max, man_current);
+ }
}
if (instance_exists(obj_controller) && is_struct(obj_controller.unit_focus)) {
var selected_unit = obj_controller.unit_focus;
@@ -1197,6 +1275,9 @@ function scr_ui_manage() {
case 15:
fx = "Armamentarium";
break;
+ case 16:
+ fx = "Auxilia";
+ break;
default:
fx = "Unknown";
break;
@@ -1611,3 +1692,483 @@ function draw_manage_selection_buttons(xx, yy) {
}
}
}
+
+/// @self Asset.GMObject.obj_controller
+/// @desc Collapsed squad rows for the Auxilia view (managing 16). Each Guard squad
+/// (one Guard Sergeant plus up to GUARD_SQUAD_SIZE Guardsmen at the same location)
+/// renders as a single row; clicking it selects or deselects every member, mirroring
+/// the eligibility gates and selection side effects of scr_draw_management_unit so
+/// the normal Load-to-ships flow (man_sel / man_size / selecting_location) just works.
+/// Heavy Weapons Teams, Veteran Guard, and vehicles keep their normal individual rows.
+function draw_auxilia_squad_rows(xx, yy, _stats_displayed = false) {
+ // Select-all buttons still work through the standard per-unit path.
+ if (sel_all != "" || squad_sel_count > 0) {
+ for (var i = 0; i < array_length(display_unit); i++) {
+ scr_draw_management_unit(i, yy, xx, false);
+ }
+ }
+ sel_all = "";
+
+ // ---- Bucket sergeants and guardsmen per location, in encounter order ----
+ var _locs = [];
+ var _buckets = {};
+ var _other_rows = [];
+ for (var i = 0; i < array_length(display_unit); i++) {
+ if (man[i] == "hide") {
+ continue;
+ }
+ var _is_unit = (man[i] == "man") && is_struct(display_unit[i]);
+ var _role = _is_unit ? display_unit[i].role() : "";
+ if ((_role == "Guard Sergeant") || (_role == "Guardsman")) {
+ var _loc = string(ma_loc[i]);
+ if (!struct_exists(_buckets, _loc)) {
+ _buckets[$ _loc] = {sgts: [], grds: []};
+ array_push(_locs, _loc);
+ }
+ if (_role == "Guard Sergeant") {
+ array_push(_buckets[$ _loc].sgts, i);
+ } else {
+ array_push(_buckets[$ _loc].grds, i);
+ }
+ } else {
+ array_push(_other_rows, i);
+ }
+ }
+
+ // ---- Pair into squads: one sergeant heads up to GUARD_SQUAD_SIZE guardsmen ----
+ var _rows = [];
+ for (var l = 0; l < array_length(_locs); l++) {
+ var _b = _buckets[$ _locs[l]];
+ var _g = 0;
+ var _sg = 0;
+ var _sq = 0;
+ while ((_g < array_length(_b.grds)) || (_sg < array_length(_b.sgts))) {
+ var _members = [];
+ if (_sg < array_length(_b.sgts)) {
+ array_push(_members, _b.sgts[_sg]);
+ _sg++;
+ }
+ var _take = 0;
+ while ((_take < GUARD_SQUAD_SIZE) && (_g < array_length(_b.grds))) {
+ array_push(_members, _b.grds[_g]);
+ _g++;
+ _take++;
+ }
+ _sq++;
+ array_push(_rows, {squad_members: _members, squad_no: _sq, loc: _locs[l]});
+ }
+ }
+ for (var o = 0; o < array_length(_other_rows); o++) {
+ array_push(_rows, {unit_row: _other_rows[o]});
+ }
+
+ var _row_max = array_length(_rows) + 1; // +1 for the toggle row
+ man_max = _row_max; // scrollbar drag math reads obj_controller.man_max directly
+ man_current = clamp(man_current, 0, max(0, _row_max - MANAGE_MAN_SEE));
+
+ // ---- Toggle row ----
+ var _yy = yy;
+ var _sq_btn = draw_unit_buttons([xx + 25, _yy + 64, xx + 974, _yy + 85], "Switch to Individual View", [1, 1], CM_GREEN_COLOR, fa_center, fnt_40k_14b);
+ draw_set_halign(fa_left);
+ draw_set_valign(fa_top);
+ if (point_and_click(_sq_btn)) {
+ auxilia_squad_collapse = false;
+ man_max = MANAGE_MAN_MAX;
+ man_current = 0;
+ exit;
+ }
+ _yy += 20;
+
+ // ---- Rows ----
+ draw_set_font(fnt_40k_14);
+ var _visible = min(MANAGE_MAN_SEE - 1, _row_max - 1 - man_current);
+ for (var r = 0; r < _visible; r++) {
+ var _row = _rows[man_current + r];
+ if (struct_exists(_row, "unit_row")) {
+ scr_draw_management_unit(_row.unit_row, _yy, xx, true);
+ draw_set_font(fnt_40k_14);
+ _yy += 20;
+ continue;
+ }
+
+ var _members = _row.squad_members;
+ var _count = array_length(_members);
+ var _sel_count = 0;
+ var _size_sum = 0;
+ for (var m = 0; m < _count; m++) {
+ if (man_sel[_members[m]] == 1) {
+ _sel_count++;
+ }
+ if (is_struct(display_unit[_members[m]])) {
+ _size_sum += display_unit[_members[m]].get_unit_size();
+ }
+ }
+ var _all_sel = (_count > 0) && (_sel_count == _count);
+ var _lead = display_unit[_members[0]];
+ var _has_sgt = is_struct(_lead) && (_lead.role() == "Guard Sergeant");
+
+ var _label = $"Squad {_row.squad_no}";
+ if (_has_sgt) {
+ _label += $" - Sgt. {_lead.name()}";
+ } else {
+ _label += " - no sergeant";
+ }
+ _label += $" | {_count} troops | {string_format(_size_sum, 1, 1)} space | {_row.loc}";
+ if ((_sel_count > 0) && !_all_sel) {
+ _label += $" ({_sel_count}/{_count} selected)";
+ }
+
+ var _rect = [xx + 25, _yy + 64, xx + 974, _yy + 85];
+ draw_set_color(c_black);
+ draw_rectangle(_rect[0], _rect[1], _rect[2], _rect[3], 0);
+ if (_sel_count > 0) {
+ draw_set_alpha(_all_sel ? 0.45 : 0.2);
+ draw_set_color(#1e5c40);
+ draw_rectangle(_rect[0], _rect[1], _rect[2], _rect[3], 0);
+ draw_set_alpha(1);
+ }
+ draw_set_color(_all_sel ? CM_GREEN_COLOR : c_gray);
+ draw_rectangle(_rect[0], _rect[1], _rect[2], _rect[3], 1);
+ draw_set_color(CM_GREEN_COLOR);
+ draw_text(xx + 35, _yy + 66, _label);
+
+ // Hover focus so the Load / selection buttons render, like unit rows do.
+ if ((mouse_x >= _rect[0]) && (mouse_y >= _rect[1]) && (mouse_x < _rect[2]) && (mouse_y < _rect[3]) && is_struct(_lead)) {
+ unit_focus = _lead;
+ }
+
+ // Click toggles the whole squad, mirroring scr_draw_management_unit's gates
+ // and side effects per member.
+ if (point_and_click(_rect) && (scrollbar_engaged == 0) && !instance_exists(obj_star_select)) {
+ var _target = _all_sel ? 0 : 1;
+ for (var m = 0; m < _count; m++) {
+ var _idx = _members[m];
+ if (man_sel[_idx] == _target) {
+ continue;
+ }
+ if (!ma_view[_idx]) {
+ continue;
+ }
+ var _u = display_unit[_idx];
+ if (!is_struct(_u)) {
+ continue;
+ }
+ if (_target == 1) {
+ if (!_u.controllable() || _u.in_jail()) {
+ continue;
+ }
+ if (selecting_location != "") {
+ var _wrong = false;
+ if (selecting_ship > -1) {
+ if (ma_lid[_idx] == -1) {
+ _wrong = true;
+ } else {
+ _wrong = obj_ini.ship_location[ma_lid[_idx]] != selecting_location;
+ }
+ } else {
+ _wrong = ma_loc[_idx] != selecting_location;
+ }
+ if (_wrong) {
+ continue;
+ }
+ }
+ if (selecting_location == "") {
+ selecting_location = ma_loc[_idx];
+ selecting_ship = ma_lid[_idx];
+ selecting_planet = ma_wid[_idx];
+ }
+ ma_loc[_idx] = selecting_location;
+ }
+ man_sel[_idx] = _target;
+ if (_target == 1) {
+ man_size += _u.get_unit_size();
+ } else {
+ man_size -= _u.get_unit_size();
+ }
+ }
+ }
+ _yy += 20;
+ }
+
+ // ---- Scroll furniture, selection buttons, scrollbar (mirrors the individual view tail) ----
+ draw_set_color(c_black);
+ draw_rectangle(xx + 974, yy + 165, xx + 1005, yy + 822, 0);
+ draw_set_color(c_gray);
+ draw_rectangle(xx + 974, yy + 165, xx + 1005, yy + 822, 1);
+ draw_rectangle(xx + 25, yy + 142, xx + 14 + 8, yy + 822, 1);
+ draw_set_color(0);
+ draw_rectangle(xx + 974, yy + 141, xx + 1005, yy + 172, 0);
+ draw_rectangle(xx + 974, yy + 790, xx + 1005, yy + 822, 0);
+ draw_set_color(c_gray);
+ draw_rectangle(xx + 974, yy + 141, xx + 1005, yy + 172, 1);
+ draw_rectangle(xx + 974, yy + 790, xx + 1005, yy + 822, 1);
+ draw_sprite_stretched(spr_arrow, 2, xx + 974, yy + 141, 31, 30);
+ draw_sprite_stretched(spr_arrow, 3, xx + 974, yy + 791, 31, 30);
+ if (point_and_click([xx + 974, yy + 141, xx + 1005, yy + 172])) {
+ man_current = max(0, man_current - 1);
+ }
+ if (point_and_click([xx + 974, yy + 790, xx + 1005, yy + 822])) {
+ man_current = min(max(0, _row_max - MANAGE_MAN_SEE), man_current + 1);
+ }
+
+ yy += 8;
+ // stats_displayed is a function-scoped static of the manage draw function, not an
+ // instance variable; reading it bare from this separate function crashed with an
+ // unset-variable error on the first Auxilia frame after loading. It is passed in
+ // as a parameter from the scope that owns it.
+ var _draw_selec_buttons = !obj_controller.unit_profile && !_stats_displayed;
+ if (_draw_selec_buttons && instance_exists(obj_popup)) {
+ _draw_selec_buttons = obj_popup.type != ePOPUP_TYPE.EQUIP;
+ }
+ if (_draw_selec_buttons && is_struct(obj_controller.unit_focus)) {
+ draw_manage_selection_buttons(xx, yy);
+ }
+ draw_set_color(#3f7e5d);
+ scr_scrollbar(974, 172, 1005, 790, 34, _row_max, man_current);
+}
+
+/// @self Asset.GMObject.obj_controller
+/// @desc Collapsed squad rows for marine companies (managing 1-10). Units are grouped
+/// by their real squad membership (unit.squad ids from obj_ini.squads) and location,
+/// rendered one row per squad named by type: "Tactical Squad 2", "Devastator Squad 1",
+/// etc., numbered per type in roster order, with the squad nickname when set. Clicking
+/// a row selects or deselects every member, mirroring the eligibility gates and
+/// selection side effects of scr_draw_management_unit so the normal Load-to-ships flow
+/// (man_sel / man_size / selecting_location) just works. Units without a squad
+/// (Captain, specialists, unassigned marines, vehicles) keep their normal individual
+/// rows in roster order. Command-slot prompts (Captain Required, etc.) still render.
+/// A squad split across locations shows one row per location so a row is always fully
+/// loadable. _stats_displayed is passed in because it is a function-scoped static of
+/// the manage draw function (see draw_auxilia_squad_rows).
+function draw_marine_squad_rows(xx, yy, _stats_displayed = false, _command_slots = []) {
+ // Select-all buttons still work through the standard per-unit path.
+ if (sel_all != "" || squad_sel_count > 0) {
+ for (var i = 0; i < array_length(display_unit); i++) {
+ scr_draw_management_unit(i, yy, xx, false);
+ }
+ }
+ sel_all = "";
+
+ // ---- Group roster rows by (squad id, location), preserving encounter order ----
+ var _rows = [];
+ var _groups = {};
+ var _squad_num = {}; // squad id -> per-type number
+ var _type_counts = {}; // display_name -> squads seen so far
+ for (var i = 0; i < array_length(display_unit); i++) {
+ if (man[i] == "hide") {
+ continue;
+ }
+ var _is_unit = (man[i] == "man") && is_struct(display_unit[i]);
+ var _sq_id = "none";
+ if (_is_unit && variable_struct_exists(display_unit[i], "squad")) {
+ _sq_id = display_unit[i].squad;
+ }
+ if ((_sq_id != "none") && is_string(_sq_id) && struct_exists(obj_ini.squads, _sq_id)) {
+ var _key = _sq_id + "|" + string(ma_loc[i]);
+ if (!struct_exists(_groups, _key)) {
+ var _grp = {squad_members: [], squad_id: _sq_id, loc: string(ma_loc[i])};
+ _groups[$ _key] = _grp;
+ array_push(_rows, _grp);
+ if (!struct_exists(_squad_num, _sq_id)) {
+ var _sq = fetch_squad(_sq_id);
+ var _tname = "Squad";
+ if (struct_exists(_sq, "display_name") && is_string(_sq.display_name) && (_sq.display_name != "")) {
+ _tname = _sq.display_name;
+ }
+ if (!struct_exists(_type_counts, _tname)) {
+ _type_counts[$ _tname] = 0;
+ }
+ _type_counts[$ _tname]++;
+ _squad_num[$ _sq_id] = {num: _type_counts[$ _tname], tname: _tname};
+ }
+ }
+ array_push(_groups[$ _key].squad_members, i);
+ } else {
+ array_push(_rows, {unit_row: i});
+ }
+ }
+
+ // ---- Fixed rows: toggle + any open command-slot prompts ----
+ var _empty_slots = [];
+ for (var r = 0; r < array_length(_command_slots); r++) {
+ if (company_data[$ _command_slots[r].unit_check] == "none") {
+ array_push(_empty_slots, _command_slots[r]);
+ }
+ }
+ var _fixed = 1 + array_length(_empty_slots);
+ var _row_max = array_length(_rows) + _fixed;
+ man_max = _row_max; // scrollbar drag math reads obj_controller.man_max directly
+ man_current = clamp(man_current, 0, max(0, _row_max - MANAGE_MAN_SEE));
+
+ // ---- Toggle row ----
+ var _yy = yy;
+ var _sq_btn = draw_unit_buttons([xx + 25, _yy + 64, xx + 974, _yy + 85], "Switch to Individual View", [1, 1], CM_GREEN_COLOR, fa_center, fnt_40k_14b);
+ draw_set_halign(fa_left);
+ draw_set_valign(fa_top);
+ if (point_and_click(_sq_btn)) {
+ marine_squad_collapse = false;
+ man_max = MANAGE_MAN_MAX;
+ man_current = 0;
+ exit;
+ }
+ _yy += 20;
+
+ // ---- Command-slot prompts (Captain Required, etc.) ----
+ draw_set_font(fnt_40k_14);
+ for (var r = 0; r < array_length(_empty_slots); r++) {
+ var _slot = _empty_slots[r];
+ if (command_slot_draw(xx, _yy, _slot.button_text)) {
+ command_slot_prompt(_slot.search_params, _slot.role_group_params, _slot.purpose, _slot.purpose_code);
+ }
+ _yy += 20;
+ }
+
+ // ---- Rows ----
+ var _visible = min(MANAGE_MAN_SEE - _fixed, array_length(_rows) - man_current);
+ for (var r = 0; r < _visible; r++) {
+ var _row = _rows[man_current + r];
+ if (struct_exists(_row, "unit_row")) {
+ scr_draw_management_unit(_row.unit_row, _yy, xx, true);
+ draw_set_font(fnt_40k_14);
+ _yy += 20;
+ continue;
+ }
+
+ var _members = _row.squad_members;
+ var _count = array_length(_members);
+ var _sel_count = 0;
+ var _size_sum = 0;
+ var _sgt = noone;
+ for (var m = 0; m < _count; m++) {
+ if (man_sel[_members[m]] == 1) {
+ _sel_count++;
+ }
+ var _mu = display_unit[_members[m]];
+ if (is_struct(_mu)) {
+ _size_sum += _mu.get_unit_size();
+ if ((_sgt == noone) && _mu.IsSpecialist(SPECIALISTS_SQUAD_LEADERS)) {
+ _sgt = _mu;
+ }
+ }
+ }
+ var _all_sel = (_count > 0) && (_sel_count == _count);
+ var _lead = is_struct(_sgt) ? _sgt : display_unit[_members[0]];
+
+ var _nd = _squad_num[$ _row.squad_id];
+ var _label = $"{_nd.tname} {_nd.num}";
+ var _sq = fetch_squad(_row.squad_id);
+ if (struct_exists(_sq, "nickname") && is_string(_sq.nickname) && (_sq.nickname != "")) {
+ _label += $" \"{_sq.nickname}\"";
+ }
+ if (is_struct(_sgt)) {
+ _label += $" - Sgt. {_sgt.name()}";
+ }
+ _label += $" | {_count} troops | {string_format(_size_sum, 1, 1)} space | {_row.loc}";
+ if ((_sel_count > 0) && !_all_sel) {
+ _label += $" ({_sel_count}/{_count} selected)";
+ }
+
+ var _rect = [xx + 25, _yy + 64, xx + 974, _yy + 85];
+ draw_set_color(c_black);
+ draw_rectangle(_rect[0], _rect[1], _rect[2], _rect[3], 0);
+ if (_sel_count > 0) {
+ draw_set_alpha(_all_sel ? 0.45 : 0.2);
+ draw_set_color(#1e5c40);
+ draw_rectangle(_rect[0], _rect[1], _rect[2], _rect[3], 0);
+ draw_set_alpha(1);
+ }
+ draw_set_color(_all_sel ? CM_GREEN_COLOR : c_gray);
+ draw_rectangle(_rect[0], _rect[1], _rect[2], _rect[3], 1);
+ draw_set_color(CM_GREEN_COLOR);
+ draw_text(xx + 35, _yy + 66, _label);
+
+ // Hover focus so the Load / selection buttons render, like unit rows do.
+ if ((mouse_x >= _rect[0]) && (mouse_y >= _rect[1]) && (mouse_x < _rect[2]) && (mouse_y < _rect[3]) && is_struct(_lead)) {
+ unit_focus = _lead;
+ }
+
+ // Click toggles the whole squad, mirroring scr_draw_management_unit's gates
+ // and side effects per member.
+ if (point_and_click(_rect) && (scrollbar_engaged == 0) && !instance_exists(obj_star_select)) {
+ var _target = _all_sel ? 0 : 1;
+ for (var m = 0; m < _count; m++) {
+ var _idx = _members[m];
+ if (man_sel[_idx] == _target) {
+ continue;
+ }
+ if (!ma_view[_idx]) {
+ continue;
+ }
+ var _u = display_unit[_idx];
+ if (!is_struct(_u)) {
+ continue;
+ }
+ if (_target == 1) {
+ if (!_u.controllable() || _u.in_jail()) {
+ continue;
+ }
+ if (selecting_location != "") {
+ var _wrong = false;
+ if (selecting_ship > -1) {
+ if (ma_lid[_idx] == -1) {
+ _wrong = true;
+ } else {
+ _wrong = obj_ini.ship_location[ma_lid[_idx]] != selecting_location;
+ }
+ } else {
+ _wrong = ma_loc[_idx] != selecting_location;
+ }
+ if (_wrong) {
+ continue;
+ }
+ }
+ if (selecting_location == "") {
+ selecting_location = ma_loc[_idx];
+ selecting_ship = ma_lid[_idx];
+ selecting_planet = ma_wid[_idx];
+ }
+ ma_loc[_idx] = selecting_location;
+ }
+ man_sel[_idx] = _target;
+ if (_target == 1) {
+ man_size += _u.get_unit_size();
+ } else {
+ man_size -= _u.get_unit_size();
+ }
+ }
+ }
+ _yy += 20;
+ }
+
+ // ---- Scroll furniture, selection buttons, scrollbar (mirrors the individual view tail) ----
+ draw_set_color(c_black);
+ draw_rectangle(xx + 974, yy + 165, xx + 1005, yy + 822, 0);
+ draw_set_color(c_gray);
+ draw_rectangle(xx + 974, yy + 165, xx + 1005, yy + 822, 1);
+ draw_rectangle(xx + 25, yy + 142, xx + 14 + 8, yy + 822, 1);
+ draw_set_color(0);
+ draw_rectangle(xx + 974, yy + 141, xx + 1005, yy + 172, 0);
+ draw_rectangle(xx + 974, yy + 790, xx + 1005, yy + 822, 0);
+ draw_set_color(c_gray);
+ draw_rectangle(xx + 974, yy + 141, xx + 1005, yy + 172, 1);
+ draw_rectangle(xx + 974, yy + 790, xx + 1005, yy + 822, 1);
+ draw_sprite_stretched(spr_arrow, 2, xx + 974, yy + 141, 31, 30);
+ draw_sprite_stretched(spr_arrow, 3, xx + 974, yy + 791, 31, 30);
+ if (point_and_click([xx + 974, yy + 141, xx + 1005, yy + 172])) {
+ man_current = max(0, man_current - 1);
+ }
+ if (point_and_click([xx + 974, yy + 790, xx + 1005, yy + 822])) {
+ man_current = min(max(0, _row_max - MANAGE_MAN_SEE), man_current + 1);
+ }
+
+ yy += 8;
+ var _draw_selec_buttons = !obj_controller.unit_profile && !_stats_displayed;
+ if (_draw_selec_buttons && instance_exists(obj_popup)) {
+ _draw_selec_buttons = obj_popup.type != ePOPUP_TYPE.EQUIP;
+ }
+ if (_draw_selec_buttons && is_struct(obj_controller.unit_focus)) {
+ draw_manage_selection_buttons(xx, yy);
+ }
+ draw_set_color(#3f7e5d);
+ scr_scrollbar(974, 172, 1005, 790, 34, _row_max, man_current);
+}
diff --git a/scripts/scr_unit_equip_functions/scr_unit_equip_functions.gml b/scripts/scr_unit_equip_functions/scr_unit_equip_functions.gml
index 092cca5ffd..70df34808c 100644
--- a/scripts/scr_unit_equip_functions/scr_unit_equip_functions.gml
+++ b/scripts/scr_unit_equip_functions/scr_unit_equip_functions.gml
@@ -177,6 +177,17 @@ function scr_update_unit_weapon_one(new_weapon, from_armoury = true, to_armoury
}
}
+ // Veteran-only weapon gate: some weapons are role-restricted (the Hellgun is Veteran
+ // Guard only). Reject and log before any armoury or slot mutation if the unit lacks the
+ // role. Skips unequip and artifacts. Mirrors the mobility tag-gate, keyed on role.
+ if (!unequipping && !is_artifact) {
+ var _wep_restrict_data = gear_weapon_data("weapon", new_weapon);
+ if (is_struct(_wep_restrict_data) && _wep_restrict_data.has_tag("veteran_guard_only") && role() != "Veteran Guard") {
+ LOGGER.error($"Failed to equip {new_weapon} for {name()} - restricted to Veteran Guard.");
+ return "restricted";
+ }
+ }
+
if (from_armoury && !unequipping && !is_artifact) {
var viability = weapon_viable(new_weapon, quality);
if (viability[0]) {
diff --git a/scripts/scr_unit_quick_find_pane/scr_unit_quick_find_pane.gml b/scripts/scr_unit_quick_find_pane/scr_unit_quick_find_pane.gml
index 13fb98ce31..1ae836dbb0 100644
--- a/scripts/scr_unit_quick_find_pane/scr_unit_quick_find_pane.gml
+++ b/scripts/scr_unit_quick_find_pane/scr_unit_quick_find_pane.gml
@@ -683,9 +683,37 @@ function jail_selection() {
alarm[6] = 7;
}
+/// @self Asset.GMObject.obj_controller
+// Returns true if at least one unit is actually selected. Used so loading works for
+// fractional-size units (guardsmen at 0.1) whose summed total can read as 0 even
+// though units are picked. The real per-unit loader checks each unit's own size.
+function selection_has_units() {
+ if (man_size > 0) {
+ return true;
+ }
+ for (var i = 0; i < array_length(man_sel); i++) {
+ if (man_sel[i] == 1) {
+ return true;
+ }
+ }
+ return false;
+}
+
/// @self Asset.GMObject.obj_controller
function load_selection() {
- if (man_size > 0 && !location_out_of_player_control(selecting_location)) {
+ // Recover the location anchor from the actual selection if it was lost. This keeps
+ // Load working for fractional-size units whose summed total can read 0.
+ if (selecting_location == "") {
+ for (var i = 0; i < array_length(man_sel); i++) {
+ if (man_sel[i] == 1) {
+ selecting_location = ma_loc[i];
+ selecting_planet = ma_wid[i];
+ selecting_ship = ma_lid[i];
+ break;
+ }
+ }
+ }
+ if (selection_has_units() && !location_out_of_player_control(selecting_location)) {
scr_company_load(selecting_location);
menu = 30;
top = 1;
@@ -694,7 +722,7 @@ function load_selection() {
/// @self Asset.GMObject.obj_controller
function unload_selection() {
- if (man_size > 0 && obj_controller.selecting_ship >= 0 && !instance_exists(obj_star_select) && !location_out_of_player_control(selecting_location) && selecting_location != "Warp") {
+ if (selection_has_units() && obj_controller.selecting_ship >= 0 && !instance_exists(obj_star_select) && !location_out_of_player_control(selecting_location) && selecting_location != "Warp") {
cooldown = 8000;
var boba = 0;
var unload_star = find_star_by_name(selecting_location);
diff --git a/scripts/scr_unit_size/scr_unit_size.gml b/scripts/scr_unit_size/scr_unit_size.gml
index 4386d9a6c5..8685d0600a 100644
--- a/scripts/scr_unit_size/scr_unit_size.gml
+++ b/scripts/scr_unit_size/scr_unit_size.gml
@@ -6,6 +6,8 @@ function get_vehicle_size_map() {
"Land Raider": 20,
"Land Speeder": 5,
"Whirlwind": 10,
+ "Chimera": 10,
+ "Leman Russ": 10,
"Harlequin Troupe": 5,
};
diff --git a/scripts/scr_vehicle_order/scr_vehicle_order.gml b/scripts/scr_vehicle_order/scr_vehicle_order.gml
index 8fa5e8c5e0..d46fe1c45e 100644
--- a/scripts/scr_vehicle_order/scr_vehicle_order.gml
+++ b/scripts/scr_vehicle_order/scr_vehicle_order.gml
@@ -48,7 +48,10 @@ function scr_vehicle_order(company_number) {
|| veh_role[company_number][i] == "Predator"
|| veh_role[company_number][i] == "Whirlwind"
|| veh_role[company_number][i] == "Land Speeder"
- || veh_role[company_number][i] == "Land Raider";
+ || veh_role[company_number][i] == "Land Raider"
+ || veh_role[company_number][i] == "Chimera"
+ || veh_role[company_number][i] == "Leman Russ"
+ || veh_role[company_number][i] == "Basilisk";
if (_is_vehicle_role) {
temp_race[company_number][vehicle_count] = veh_race[company_number][i];