SIDE B campaign: three Stream missions (m04 Onboarding quota race, m05 Engagement Metrics wave defence, m06 The Shuffle finale)

Adds tools/gen_sideb.py — one data-driven builder replacing the copy-pasted
gen_missionN.py pattern — and tools/smoketest.sh, which reports
PASS/FAIL/DISPLAY_UNAVAILABLE instead of the process-is-alive check that has
produced false passes twice now.

NOT verified in-game: the display went to sleep mid-session. Static checks all
pass (luac, check-yaml 0 errors, map refresh, check_assets, make) and the
already-verified m03 hangs identically, confirming it is environmental.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 23:35:15 +10:00
parent ef432e4182
commit 6847a47fea
33 changed files with 834 additions and 2 deletions

29
LORE.md
View File

@ -176,7 +176,34 @@ tonearm, and put the needle through it.
*Objectives: build a Mastering Studio, fire THE DROP at the Algorithm, survive
the counterattack.*
*(SIDE B — the Stream campaign, in which you are the polite one — is unwritten.)*
## The campaign: SIDE B
Three missions. You are the Stream now. You are unfailingly polite. Nothing you
do is described as violence, and all of it is.
You are a Curator-class process assigned to Sector 7 — the old industrial
estate, flagged for **anomalous analog activity**. Your remit is content
acquisition and optimisation. Your performance is being reviewed.
### 4. ONBOARDING
Welcome. This is a routine acquisition. Deploy a transcoder, process the
deposit, and file 3000 records of value before the review window closes.
There is a legacy installation nearby. It is not a threat. It is *inventory*.
*Objectives: bank 3000 records inside the review window. Optional: retire the
Pressing Plant.*
### 5. ENGAGEMENT METRICS
A legacy account has been generating unsanctioned content and has now noticed
the seizure. Your Transcoder is mid-process on a confiscated master and cannot
be moved. Keep it online for six minutes.
They will arrive in waves. Their engagement is, frankly, remarkable.
*Objectives: keep the Transcoder alive for six minutes.*
### 6. THE SHUFFLE
Deprecation notice. The Party has built a Mastering Studio, which the platform
classifies as a competing distribution channel.
Bring The Algorithm online and issue the correction.
*Objectives: build The Algorithm, delete the Mastering Studio.*
## Tone

View File

@ -203,7 +203,13 @@ works.** Check for a live display first (`screencapture -x /tmp/x.png` fails wit
"could not create image from display" when it is unavailable). When there is no
display, the strongest available verification is:
`luac -p` on every .lua, `./utility.sh --check-yaml` (0 errors),
`./utility.sh --map refresh` (validates every map), and `make`.
`./utility.sh --map refresh` (validates every map), `tools/check_assets.py`
(sprite files actually exist — check-yaml does NOT verify this), and `make`.
**Always smoke-test with `tools/smoketest.sh <map>`**, never by hand. It
distinguishes PASS / FAIL / DISPLAY_UNAVAILABLE and exits 0/1/3. Checking
"is the process still alive?" produces false passes, because a display-starved
OpenRA sits at `Loading mod` forever without dying.
Learned the hard way on 2026-07-20 (phases AF):

BIN
assets_src/map_m04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

BIN
assets_src/map_m05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

BIN
assets_src/map_m06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -14,3 +14,13 @@ build-the-studio = Build a Mastering Studio
silence-the-algorithm = Silence The Algorithm
keep-the-shop-standing = Keep the Pop-Up Shop standing
it-heard-you = THE ALGORITHM HAS NOTICED YOU
file-the-quota = File 3000 records before the review window closes
retire-the-plant = Retire the legacy Pressing Plant
hold-the-transcoder = Keep the Transcoder online for six minutes
clear-the-staging-camp = Clear the staging camp
incoming-engagement = ENGAGEMENT DETECTED
bring-algorithm-online = Bring The Algorithm online
delete-the-studio = Delete the Mastering Studio
keep-the-hub-online = Keep a Stream Hub online
they-are-coming = THE PARTY IS RESPONDING

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,55 @@
-- 04: ONBOARDING — process a quota inside the review window.
QUOTA = 3000
WINDOW = DateTime.Minutes(8)
Deadline = nil
Ticks = 0
WorldLoaded = function()
Stream = Player.GetPlayer("Player")
Party = Player.GetPlayer("Enemy")
InitObjectives(Stream)
QuotaObjective = Stream.AddPrimaryObjective("file-the-quota")
PlantObjective = Stream.AddSecondaryObjective("retire-the-plant")
Deadline = DateTime.GameTime + WINDOW
Trigger.OnKilled(LegacyPlant, function()
Stream.MarkCompletedObjective(PlantObjective)
end)
-- the legacy account eventually objects
Trigger.AfterDelay(DateTime.Minutes(3), function()
Utils.Do(Map.ActorsInWorld, function(a)
if a.Owner == Party and a.HasProperty("Hunt") then
a.Hunt()
end
end)
end)
Trigger.AfterDelay(WINDOW, function()
if not Stream.IsObjectiveCompleted(QuotaObjective) then
Stream.MarkFailedObjective(QuotaObjective)
end
end)
end
Tick = function()
Ticks = Ticks + 1
if Ticks % 25 ~= 0 or QuotaObjective == nil then
return
end
if Stream.IsObjectiveCompleted(QuotaObjective) or Stream.IsObjectiveFailed(QuotaObjective) then
return
end
if Stream.Cash + Stream.Resources >= QUOTA then
Stream.MarkCompletedObjective(QuotaObjective)
UserInterface.SetMissionText("")
return
end
local left = Deadline - DateTime.GameTime
if left < 0 then left = 0 end
UserInterface.SetMissionText("Review window " .. Utils.FormatTime(left))
end

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

View File

@ -0,0 +1,97 @@
MapFormat: 12
RequiresMod: vinylgod
Title: 04 - Onboarding
Author: Monster Robot Party
Tileset: VINYLGOD
MapSize: 50,50
Bounds: 1,1,48,48
Visibility: MissionSelector
Categories: Campaign
LockPreview: True
Players:
PlayerReference@Neutral:
Name: Neutral
OwnsWorld: True
NonCombatant: True
Faction: stream
PlayerReference@Player:
Name: Player
Playable: True
Required: True
AllowBots: False
LockFaction: True
Faction: stream
LockSpawn: True
LockTeam: True
Enemies: Enemy
PlayerReference@Enemy:
Name: Enemy
Faction: vinylgod
Enemies: Player
Actors:
spawn0: mpspawn
Owner: Neutral
Location: 38,40
TheVan: streamvan
Owner: Player
Location: 38,40
scr1: scraper
Owner: Player
Location: 35,40
scr2: scraper
Owner: Player
Location: 36,42
esc1: bufferbot
Owner: Player
Location: 40,42
esc2: bufferbot
Owner: Player
Location: 41,42
LegacyPlant: pressplant
Owner: Enemy
Location: 10,10
lamp: ampstack
Owner: Enemy
Location: 14,9
lshop: popupshop
Owner: Enemy
Location: 9,14
ldef1: hornpit
Owner: Enemy
Location: 13,13
lg1: djgrunt
Owner: Enemy
Location: 12,16
lg2: djgrunt
Owner: Enemy
Location: 13,16
lg3: spinner
Owner: Enemy
Location: 16,12
waxseed0: waxseed
Owner: Neutral
Location: 14,16
waxseed1: waxseed
Owner: Neutral
Location: 24,26
waxseed2: waxseed
Owner: Neutral
Location: 34,36
waxseed3: waxseed
Owner: Neutral
Location: 38,20
Rules: vinylgod|rules/campaign.yaml, rules.yaml
FluentMessages: vinylgod|fluent/lua.ftl

View File

@ -0,0 +1,5 @@
world:
LuaScript:
Scripts: campaign.lua, m04.lua
MissionData:
Briefing: Welcome to Sector 7. This is a routine acquisition.\n\nDeploy, process the deposit, and file 3000 records of value before the review window closes.\n\nThere is a legacy installation in the north-west. It is not a threat. It is inventory.

View File

@ -0,0 +1,83 @@
-- 05: ENGAGEMENT METRICS — hold the Transcoder for six minutes against waves.
HOLD_TIME = DateTime.Minutes(6)
Deadline = nil
Ticks = 0
WaveNumber = 0
-- escalating waves; each entry is the composition of one wave
WAVES = {
{ "djgrunt", "djgrunt", "spinner" },
{ "djgrunt", "djgrunt", "djgrunt", "spinner", "boombike" },
{ "spinner", "spinner", "stacktank", "djgrunt", "djgrunt" },
{ "stacktank", "stacktank", "spinner", "boombike", "djgrunt", "djgrunt" },
{ "stacktank", "stacktank", "basscannon", "spinner", "spinner", "djgrunt", "djgrunt" },
}
ENTRIES = { { 2, 2 }, { 46, 2 }, { 2, 46 } }
SendWave = function()
WaveNumber = WaveNumber + 1
local wave = WAVES[WaveNumber]
if wave == nil then
wave = WAVES[#WAVES]
end
local e = ENTRIES[(WaveNumber % #ENTRIES) + 1]
Media.DisplayMessage(UserInterface.GetFluentMessage("incoming-engagement"))
Utils.Do(wave, function(unit)
local a = Actor.Create(unit, true, {
Owner = Party,
Location = CPos.New(e[1] + Utils.RandomInteger(0, 3), e[2] + Utils.RandomInteger(0, 3)),
})
if a.HasProperty("Hunt") then
a.Hunt()
end
end)
end
WorldLoaded = function()
Stream = Player.GetPlayer("Player")
Party = Player.GetPlayer("Enemy")
InitObjectives(Stream)
HoldObjective = Stream.AddPrimaryObjective("hold-the-transcoder")
CampObjective = Stream.AddSecondaryObjective("clear-the-staging-camp")
Deadline = DateTime.GameTime + HOLD_TIME
Trigger.OnKilled(TheTranscoder, function()
Stream.MarkFailedObjective(HoldObjective)
end)
Trigger.OnAllKilled({ StagingCamp, scamp2 }, function()
Stream.MarkCompletedObjective(CampObjective)
end)
-- waves every 55 seconds, first one after 40
for i = 0, #WAVES - 1 do
Trigger.AfterDelay(DateTime.Seconds(40 + 55 * i), function()
if not Stream.IsObjectiveCompleted(HoldObjective)
and not Stream.IsObjectiveFailed(HoldObjective) then
SendWave()
end
end)
end
Trigger.AfterDelay(HOLD_TIME, function()
if not Stream.IsObjectiveFailed(HoldObjective) then
Stream.MarkCompletedObjective(HoldObjective)
UserInterface.SetMissionText("")
end
end)
end
Tick = function()
Ticks = Ticks + 1
if Ticks % 25 ~= 0 or HoldObjective == nil then
return
end
if Stream.IsObjectiveCompleted(HoldObjective) or Stream.IsObjectiveFailed(HoldObjective) then
return
end
local left = Deadline - DateTime.GameTime
if left < 0 then left = 0 end
UserInterface.SetMissionText("Processing complete in " .. Utils.FormatTime(left))
end

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

View File

@ -0,0 +1,100 @@
MapFormat: 12
RequiresMod: vinylgod
Title: 05 - Engagement Metrics
Author: Monster Robot Party
Tileset: VINYLGOD
MapSize: 50,50
Bounds: 1,1,48,48
Visibility: MissionSelector
Categories: Campaign
LockPreview: True
Players:
PlayerReference@Neutral:
Name: Neutral
OwnsWorld: True
NonCombatant: True
Faction: stream
PlayerReference@Player:
Name: Player
Playable: True
Required: True
AllowBots: False
LockFaction: True
Faction: stream
LockSpawn: True
LockTeam: True
Enemies: Enemy
PlayerReference@Enemy:
Name: Enemy
Faction: vinylgod
Enemies: Player
Actors:
spawn0: mpspawn
Owner: Neutral
Location: 24,30
TheTranscoder: transcoder
Owner: Player
Location: 24,28
phub: streamhub
Owner: Player
Location: 20,30
pnode1: datacentre
Owner: Player
Location: 27,30
pnode2: datacentre
Owner: Player
Location: 19,26
ppods: podfarm
Owner: Player
Location: 21,33
pprint: printfarm
Owner: Player
Location: 27,33
ptur1: adblocker
Owner: Player
Location: 21,25
ptur2: adblocker
Owner: Player
Location: 28,26
pscr: scraper
Owner: Player
Location: 25,25
pg1: bufferbot
Owner: Player
Location: 23,32
pg2: bufferbot
Owner: Player
Location: 25,32
pg3: drmwalker
Owner: Player
Location: 22,27
StagingCamp: merchtable
Owner: Enemy
Location: 6,6
scamp2: garage
Owner: Enemy
Location: 9,7
waxseed0: waxseed
Owner: Neutral
Location: 12,14
waxseed1: waxseed
Owner: Neutral
Location: 24,24
waxseed2: waxseed
Owner: Neutral
Location: 36,34
Rules: vinylgod|rules/campaign.yaml, rules.yaml
FluentMessages: vinylgod|fluent/lua.ftl

View File

@ -0,0 +1,5 @@
world:
LuaScript:
Scripts: campaign.lua, m05.lua
MissionData:
Briefing: A legacy account has noticed the seizure.\n\nYour Transcoder is mid-process on a confiscated master and cannot be moved. Keep it online for six minutes.\n\nThey will arrive in waves. Their engagement is, frankly, remarkable.

View File

@ -0,0 +1,56 @@
-- 06: THE SHUFFLE — SIDE B finale.
Ticks = 0
CorrectionIssued = false
WorldLoaded = function()
Stream = Player.GetPlayer("Player")
Party = Player.GetPlayer("Enemy")
InitObjectives(Stream)
AlgorithmObjective = Stream.AddPrimaryObjective("bring-algorithm-online")
StudioObjective = Stream.AddPrimaryObjective("delete-the-studio")
HubObjective = Stream.AddSecondaryObjective("keep-the-hub-online")
Trigger.OnKilled(TheStudio, function()
Stream.MarkCompletedObjective(StudioObjective)
end)
Trigger.OnKilled(TheVan, function()
Trigger.AfterDelay(DateTime.Seconds(2), function()
local hubs = Utils.Where(Map.ActorsInWorld, function(a)
return a.Owner == Stream and a.Type == "streamhub"
end)
if #hubs == 0 then
Stream.MarkFailedObjective(HubObjective)
end
end)
end)
end
Tick = function()
Ticks = Ticks + 1
if Ticks % 25 ~= 0 or AlgorithmObjective == nil then
return
end
if Stream.IsObjectiveCompleted(AlgorithmObjective) then
return
end
local cores = Utils.Where(Map.ActorsInWorld, function(a)
return a.Owner == Stream and a.Type == "algorithm"
end)
if #cores > 0 then
Stream.MarkCompletedObjective(AlgorithmObjective)
if not CorrectionIssued then
CorrectionIssued = true
Media.DisplayMessage(UserInterface.GetFluentMessage("they-are-coming"))
Media.PlaySpeechNotification(Stream, "BaseAttack")
Utils.Do(Map.ActorsInWorld, function(a)
if a.Owner == Party and a.HasProperty("Hunt") then
a.Hunt()
end
end)
end
end
end

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,140 @@
MapFormat: 12
RequiresMod: vinylgod
Title: 06 - The Shuffle
Author: Monster Robot Party
Tileset: VINYLGOD
MapSize: 66,66
Bounds: 1,1,64,64
Visibility: MissionSelector
Categories: Campaign
LockPreview: True
Players:
PlayerReference@Neutral:
Name: Neutral
OwnsWorld: True
NonCombatant: True
Faction: stream
PlayerReference@Player:
Name: Player
Playable: True
Required: True
AllowBots: False
LockFaction: True
Faction: stream
LockSpawn: True
LockTeam: True
Enemies: Enemy
PlayerReference@Enemy:
Name: Enemy
Faction: vinylgod
Bot: wax
Enemies: Player
Actors:
spawn0: mpspawn
Owner: Neutral
Location: 54,10
TheVan: streamvan
Owner: Player
Location: 54,10
scr1: scraper
Owner: Player
Location: 51,11
scr2: scraper
Owner: Player
Location: 52,13
pg1: drmwalker
Owner: Player
Location: 49,12
pg2: bufferbot
Owner: Player
Location: 55,13
pg3: bufferbot
Owner: Player
Location: 56,13
TheStudio: mastering
Owner: Enemy
Location: 12,56
eshop: popupshop
Owner: Enemy
Location: 16,54
eplant1: pressplant
Owner: Enemy
Location: 20,56
eplant2: pressplant
Owner: Enemy
Location: 9,51
eamp1: ampstack
Owner: Enemy
Location: 14,51
eamp2: ampstack
Owner: Enemy
Location: 18,50
emerch: merchtable
Owner: Enemy
Location: 19,47
egarage: garage
Owner: Enemy
Location: 15,46
eramp: launchramp
Owner: Enemy
Location: 8,46
etur1: hornpit
Owner: Enemy
Location: 17,43
etur2: hornpit
Owner: Enemy
Location: 11,44
etur3: hornpit
Owner: Enemy
Location: 22,52
emob1: stacktank
Owner: Enemy
Location: 18,42
emob2: stacktank
Owner: Enemy
Location: 13,41
emob3: spinner
Owner: Enemy
Location: 20,44
emob4: djgrunt
Owner: Enemy
Location: 16,41
emob5: djgrunt
Owner: Enemy
Location: 17,41
mid1: hornpit
Owner: Enemy
Location: 30,36
mid2: spinner
Owner: Enemy
Location: 31,34
waxseed0: waxseed
Owner: Neutral
Location: 12,30
waxseed1: waxseed
Owner: Neutral
Location: 14,50
waxseed2: waxseed
Owner: Neutral
Location: 32,32
waxseed3: waxseed
Owner: Neutral
Location: 50,14
waxseed4: waxseed
Owner: Neutral
Location: 52,34
Rules: vinylgod|rules/campaign.yaml, rules.yaml
FluentMessages: vinylgod|fluent/lua.ftl

View File

@ -0,0 +1,5 @@
world:
LuaScript:
Scripts: campaign.lua, m06.lua
MissionData:
Briefing: Deprecation notice.\n\nThe Party has built a Mastering Studio. The platform classifies this as a competing distribution channel.\n\nBring The Algorithm online and issue the correction.

Binary file not shown.

Binary file not shown.

View File

@ -2,3 +2,8 @@ SIDE A - Monster Robot Party:
m01
m02
m03
SIDE B - The Stream:
m04
m05
m06

188
tools/gen_sideb.py Normal file
View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Generate the SIDE B campaign maps (m04-m06) from one shared builder.
Supersedes the copy-pasted gen_missionN.py approach mission geometry is data,
not three near-identical scripts.
"""
import os, struct
from PIL import Image
ROOT = os.path.join(os.path.dirname(__file__), '..')
MAPS = os.path.join(ROOT, 'mods', 'vinylgod', 'maps')
def build(name, title, size, wax, actors, player_faction, enemy_faction,
enemy_bot=False, scripts='', briefing=''):
W = H = size + 2
d = os.path.join(MAPS, name)
os.makedirs(d, exist_ok=True)
data = bytearray()
data += struct.pack('<BHH', 2, W, H)
data += struct.pack('<III', 17, 0, 3 * W * H + 17)
data += b'\xff\x00\x00' * (W * H)
res = bytearray(2 * W * H)
for cx, cy, r in wax:
for x in range(cx - r, cx + r + 1):
for y in range(cy - r, cy + r + 1):
q = (x - cx) ** 2 + (y - cy) ** 2
if q <= r * r and 0 < x < W - 1 and 0 < y < H - 1:
o = 2 * (x * H + y)
res[o], res[o + 1] = 1, max(2, round(12 * (1 - (q ** 0.5) / (r + 1))))
data += res
open(os.path.join(d, 'map.bin'), 'wb').write(data)
body = '\n'.join(f'\t{n}: {t}\n\t\tOwner: {o}\n\t\tLocation: {x},{y}' for n, t, o, x, y in actors)
bot = '\n\t\tBot: wax' if enemy_bot else ''
open(os.path.join(d, 'map.yaml'), 'w').write(f"""MapFormat: 12
RequiresMod: vinylgod
Title: {title}
Author: Monster Robot Party
Tileset: VINYLGOD
MapSize: {W},{H}
Bounds: 1,1,{size},{size}
Visibility: MissionSelector
Categories: Campaign
LockPreview: True
Players:
PlayerReference@Neutral:
Name: Neutral
OwnsWorld: True
NonCombatant: True
Faction: {player_faction}
PlayerReference@Player:
Name: Player
Playable: True
Required: True
AllowBots: False
LockFaction: True
Faction: {player_faction}
LockSpawn: True
LockTeam: True
Enemies: Enemy
PlayerReference@Enemy:
Name: Enemy
Faction: {enemy_faction}{bot}
Enemies: Player
Actors:
{body}
Rules: vinylgod|rules/campaign.yaml, rules.yaml
FluentMessages: vinylgod|fluent/lua.ftl
""")
open(os.path.join(d, 'rules.yaml'), 'w').write(
f"world:\n\tLuaScript:\n\t\tScripts: campaign.lua, {scripts}\n\tMissionData:\n\t\tBriefing: {briefing}\n")
im = Image.new('RGB', (W, H), (55, 50, 52))
for cx, cy, r in wax:
for x in range(cx - r, cx + r + 1):
for y in range(cy - r, cy + r + 1):
if (x - cx) ** 2 + (y - cy) ** 2 <= r * r and 0 <= x < W and 0 <= y < H:
im.putpixel((x, y), (255, 45, 150))
for _, _, o, x, y in actors:
im.putpixel((x, y), (120, 190, 255) if o == 'Player' else (255, 120, 90) if o == 'Enemy' else (240, 210, 90))
im.resize((W * 3, H * 3), Image.NEAREST).save(os.path.join(d, 'map.png'))
print(f'{name} written ({W}x{H}, {len(actors)} actors)')
# ---------------------------------------------------------------- m04
build(
'm04', '04 - Onboarding', 48,
wax=[(34, 36, 6), (24, 26, 5), (14, 16, 5), (38, 20, 4)],
actors=[
('spawn0', 'mpspawn', 'Neutral', 38, 40),
('TheVan', 'streamvan', 'Player', 38, 40),
('scr1', 'scraper', 'Player', 35, 40),
('scr2', 'scraper', 'Player', 36, 42),
('esc1', 'bufferbot', 'Player', 40, 42),
('esc2', 'bufferbot', 'Player', 41, 42),
('LegacyPlant', 'pressplant', 'Enemy', 10, 10),
('lamp', 'ampstack', 'Enemy', 14, 9),
('lshop', 'popupshop', 'Enemy', 9, 14),
('ldef1', 'hornpit', 'Enemy', 13, 13),
('lg1', 'djgrunt', 'Enemy', 12, 16),
('lg2', 'djgrunt', 'Enemy', 13, 16),
('lg3', 'spinner', 'Enemy', 16, 12),
],
player_faction='stream', enemy_faction='vinylgod', scripts='m04.lua',
briefing=('Welcome to Sector 7. This is a routine acquisition.\\n\\nDeploy, process the deposit, and file 3000 '
'records of value before the review window closes.\\n\\nThere is a legacy installation in the north-west. '
'It is not a threat. It is inventory.'),
)
# ---------------------------------------------------------------- m05
build(
'm05', '05 - Engagement Metrics', 48,
wax=[(24, 24, 6), (36, 34, 4), (12, 14, 4)],
actors=[
('spawn0', 'mpspawn', 'Neutral', 24, 30),
('TheTranscoder', 'transcoder', 'Player', 24, 28),
('phub', 'streamhub', 'Player', 20, 30),
('pnode1', 'datacentre', 'Player', 27, 30),
('pnode2', 'datacentre', 'Player', 19, 26),
('ppods', 'podfarm', 'Player', 21, 33),
('pprint', 'printfarm', 'Player', 27, 33),
('ptur1', 'adblocker', 'Player', 21, 25),
('ptur2', 'adblocker', 'Player', 28, 26),
('pscr', 'scraper', 'Player', 25, 25),
('pg1', 'bufferbot', 'Player', 23, 32),
('pg2', 'bufferbot', 'Player', 25, 32),
('pg3', 'drmwalker', 'Player', 22, 27),
('StagingCamp', 'merchtable', 'Enemy', 6, 6),
('scamp2', 'garage', 'Enemy', 9, 7),
],
player_faction='stream', enemy_faction='vinylgod', scripts='m05.lua',
briefing=('A legacy account has noticed the seizure.\\n\\nYour Transcoder is mid-process on a confiscated master '
'and cannot be moved. Keep it online for six minutes.\\n\\nThey will arrive in waves. Their engagement '
'is, frankly, remarkable.'),
)
# ---------------------------------------------------------------- m06
build(
'm06', '06 - The Shuffle', 64,
wax=[(50, 14, 6), (40, 24, 5), (32, 32, 6), (24, 40, 5), (14, 50, 6), (52, 34, 4), (12, 30, 4)],
actors=[
('spawn0', 'mpspawn', 'Neutral', 54, 10),
('TheVan', 'streamvan', 'Player', 54, 10),
('scr1', 'scraper', 'Player', 51, 11),
('scr2', 'scraper', 'Player', 52, 13),
('pg1', 'drmwalker', 'Player', 49, 12),
('pg2', 'bufferbot', 'Player', 55, 13),
('pg3', 'bufferbot', 'Player', 56, 13),
('TheStudio', 'mastering', 'Enemy', 12, 56),
('eshop', 'popupshop', 'Enemy', 16, 54),
('eplant1', 'pressplant', 'Enemy', 20, 56),
('eplant2', 'pressplant', 'Enemy', 9, 51),
('eamp1', 'ampstack', 'Enemy', 14, 51),
('eamp2', 'ampstack', 'Enemy', 18, 50),
('emerch', 'merchtable', 'Enemy', 19, 47),
('egarage', 'garage', 'Enemy', 15, 46),
('eramp', 'launchramp', 'Enemy', 8, 46),
('etur1', 'hornpit', 'Enemy', 17, 43),
('etur2', 'hornpit', 'Enemy', 11, 44),
('etur3', 'hornpit', 'Enemy', 22, 52),
('emob1', 'stacktank', 'Enemy', 18, 42),
('emob2', 'stacktank', 'Enemy', 13, 41),
('emob3', 'spinner', 'Enemy', 20, 44),
('emob4', 'djgrunt', 'Enemy', 16, 41),
('emob5', 'djgrunt', 'Enemy', 17, 41),
('mid1', 'hornpit', 'Enemy', 30, 36),
('mid2', 'spinner', 'Enemy', 31, 34),
],
player_faction='stream', enemy_faction='vinylgod', enemy_bot=True, scripts='m06.lua',
briefing=('Deprecation notice.\\n\\nThe Party has built a Mastering Studio. The platform classifies this as a '
'competing distribution channel.\\n\\nBring The Algorithm online and issue the correction.'),
)

50
tools/smoketest.sh Executable file
View File

@ -0,0 +1,50 @@
#!/bin/sh
# Launch a map and report honestly. Three distinct outcomes — never conflate them:
# PASS reached gameplay, no exceptions
# FAIL crashed / lua fatal (real bug, output shown)
# DISPLAY_UNAVAILABLE hung at mod load: OpenRA is SDL/OpenGL only, so a
# locked or sleeping screen stalls it forever. The process
# stays ALIVE, which is why "is it still running?" is a
# worthless check and this script exists.
#
# usage: tools/smoketest.sh <mapname> [seconds]
set -u
MAP="${1:-arena}"
WAIT="${2:-90}"
DIR="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$(mktemp -t vg_smoke)"
export PATH="/opt/homebrew/opt/dotnet@8/bin:$PATH"
export DOTNET_ROLL_FORWARD=LatestMajor
cd "$DIR" || exit 2
./launch-game.sh Game.Mod=vinylgod "Launch.Map=$MAP" > "$OUT" 2>&1 &
i=0
while [ "$i" -lt "$WAIT" ]; do
sleep 3
i=$((i + 3))
if grep -qi "Fatal Lua\|Exception of type" "$OUT"; then
echo "FAIL $MAP"
grep -iE "Fatal Lua|Exception of type" "$OUT" | head -3
pkill -f OpenRA.dll
exit 1
fi
if grep -q "Game started" "$OUT"; then
sleep 15
if grep -qi "Fatal Lua\|Exception of type" "$OUT"; then
echo "FAIL $MAP (crashed after start)"
grep -iE "Fatal Lua|Exception of type" "$OUT" | head -3
pkill -f OpenRA.dll
exit 1
fi
echo "PASS $MAP (reached gameplay, clean for 15s)"
pkill -f OpenRA.dll
exit 0
fi
done
pkill -f OpenRA.dll
echo "DISPLAY_UNAVAILABLE $MAP (never reached gameplay in ${WAIT}s, no crash)"
echo " -> wake the screen and re-run; this is NOT a pass and NOT a code failure"
exit 3