Compare commits

..
2 Commits
Author SHA1 Message Date
jakub 0e71ef1fa7 Make Sarka special-forces cars per anger point configurable
continuous-integration/drone/push Build is passing
2026-10-17 17:42:41 +02:00
jakub 38fd8f7dd2 Record Workshop versions in Drone changelogs and retire local sending 2026-10-17 17:42:39 +02:00
22 changed files with 346 additions and 114 deletions
+1
View File
@@ -11,6 +11,7 @@ steps:
commands:
- apk add --no-cache git
- python -m unittest discover -s tests -p test_discord_changelog_ci.py
- python -m unittest discover -s tests -p test_workshop_changelog.py
- name: notify-discord
image: python:3.13-alpine
+26 -31
View File
@@ -1,37 +1,32 @@
# Push changelogs
The user authorized Discord changelogs after new changes are successfully pushed.
Maintain a concise, player-facing newest entry in `CHANGELOG.md` for each update.
The `.drone.yml` pipeline can deliver it automatically for pushes to `main` from
any checkout. See `docs/discord-changelog-ci.md` for activation and secret setup.
Drone is the sole Discord changelog sender. Its first delivery was verified in
build #1 on 2026-10-17, and the user confirmed receipt and disabled local sending.
Do not run a local/manual Discord sender after pushes or as an automatic fallback.
`tools/discord_changelog.py` is disabled and cannot send messages.
On 2026-10-17 the user confirmed Drone is prepared with `discord_webhook_url`
and requested a test push. Drone now owns delivery; do not run the manual helper
for that push or subsequent pushes unless the user explicitly requests a fallback.
For player-facing changes, maintain the newest entry in `CHANGELOG.md` before
pushing to `origin/main`. Keep it concise and describe only fixes/features, without
server, deployment, Git push status, operational disclaimers or footers. Do not
backfill old changes unless requested. Internal workflow-only changes need no
announcement.
Once Drone activation and its `discord_webhook_url` secret are confirmed, let
Drone deliver the changelog and do not also run the manual helper. Check its
delivery result when available; do not claim delivery from Git push success alone.
After a confirmed successful Workbench Workshop upload, run
`python3 tools/workshop_changelog.py --published` before committing the changelog.
This reads the local publishing manifest and records the version in the changelog
preamble. Compilation/bundling alone is not confirmation of publication; check
the successful upload result first. With no flag the helper only displays the
bundled version. Drone includes the recorded version with new changelog entries;
a version-only edit does not announce an old entry again.
Until Drone activation is confirmed, after each successful push to `origin/main`
from this checkout:
The `.drone.yml` pipeline posts the latest changed entry using Drone's
`discord_webhook_url` repository secret. See `docs/discord-changelog-ci.md`.
Check the Drone result and notification log when available; a successful Git push
alone does not prove delivery. Report delivery failures honestly. Inspect Discord
before rerunning an uncertain delivery or rebuilding the same push, since builds
do not share delivery state and retries can produce duplicates.
1. Prepare a concise, player-facing changelog covering the commits in that push.
Include only the changelog: describe fixes/features accurately, without server,
deployment, or Git push status. Do not add operational disclaimers or footers.
Phrase gameplay fixes without mentioning the server (for example, "Fixed radio
station generation after an empty-session wait").
2. Save the message to a temporary file and run
`python3 tools/discord_changelog.py --commit <pushed-sha> --message-file <path>`.
3. Report a delivery failure honestly. Do not blindly retry a pending delivery;
check Discord first to avoid duplicates.
The webhook credential is in `.local/discord-webhook.url` (ignored, owner-only).
Never print it, commit it, or put it into changelogs or command-line arguments.
The helper verifies the remote commit, disables mentions, and records delivery in
`.local/discord-changelog-state.json`. Do not backfill old commits unless requested.
The manual helper is a fallback assistant workflow, not a Git hook. The Drone
pipeline replaces it after activation; do not configure a second Discord push
webhook alongside it. Inspect Discord before retrying an uncertain CI delivery
or rebuilding the same push, since builds do not share delivery state.
The saved local webhook in `.local/discord-webhook.url` is no longer used for
post-push notifications. Never print it, commit it, or put it into changelogs or
command-line arguments. Do not configure an additional Discord push webhook
alongside Drone.
+7
View File
@@ -1,5 +1,12 @@
# Changelog
Latest published Workshop version: **0.0.110**
## 2026-10-17 — Configurable Šárka hunters
- Added a mission-menu setting for the number of Šárka special-forces cars per anger point.
- Set cars per anger point to zero to disable new waves. Deployed teams remain.
## 2026-10-17 — Discord changelog test
- Test notification for automatic mission changelogs.
@@ -8,11 +8,12 @@ class ERR_AdminMenuFields
{
static array<string> Keys()
{
return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "sarkaCarsPerAnger", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
}
static bool Available(string key, bool hq, bool intel)
{
if (key == "aiDoctrine" || key == "aiShadow") return hq;
if (key == "sarkaCarsPerAnger") return hq;
if (key == "aiIntelChancePercent") return intel;
if (key == "automaticEquipmentChancePercent" || key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
return Keys().Contains(key);
@@ -50,6 +51,7 @@ class ERR_AdminMenuFields
if (key == "aiDoctrine") prefix = "mission doctrine";
else if (key == "aiShadow") prefix = "mission aishadow";
else if (key == "automaticEquipmentChancePercent") prefix = "hq equipment";
else if (key == "sarkaCarsPerAnger") prefix = "mission sarkacars";
else if (key == "motorbikeCount") prefix = "mission motorbikes";
else if (key == "planeReconEnabled") prefix = "mission plane";
else if (key == "planeAltitudeMetres") prefix = "mission planealtitude";
@@ -135,6 +137,7 @@ class ERR_AdminMenu : ChimeraMenuBase
names.Insert("RandomDay_startTimeMinutes");
names.Insert("Default_viewDistanceMetres");
names.Insert("OpenForces");
names.Insert("OpenSarka"); names.Insert("SarkaBack");
names.Insert("RestartEmptyOn"); names.Insert("RestartEmptyOff");
names.Insert("OpenGuards"); names.Insert("GuardsBack");
names.Insert("OpenSites"); names.Insert("SitesBack");
@@ -236,8 +239,8 @@ class ERR_AdminMenu : ChimeraMenuBase
if (widget) widget.SetText(value);
}
// Stable page order is also used by the shared navigation bar and native probe.
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter"}; }
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage", "SarkaPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter", "OpenSarka"}; }
protected void ShowPage(int page)
{
array<string> pages = Pages();
@@ -438,7 +441,7 @@ class ERR_AdminMenu : ChimeraMenuBase
if (name == "RecommendedWeapons") { ShowPage(9); return; }
if (name == "PlaneBack") { ShowPage(1); return; }
if (name == "AIBack") { ShowPage(1); return; }
if (name == "GuardsBack" || name == "SitesBack") { ShowPage(11); return; }
if (name == "GuardsBack" || name == "SitesBack" || name == "SarkaBack") { ShowPage(11); return; }
int page = Navigation().Find(name);
if (page >= 0) { ShowPage(page); return; }
if (name == "JournalDetails")
@@ -6,6 +6,7 @@ class ERR_CommandArguments
{
if (section == "mission" && option == "doctrine") return "aiDoctrine";
if (section == "mission" && option == "aishadow") return "aiShadow";
if (section == "mission" && option == "sarkacars") return "sarkaCarsPerAnger";
if (section == "mission" && option == "motorbikes") return "motorbikeCount";
if (section == "mission" && option == "planealtitude") return "planeAltitudeMetres";
if (section == "mission" && option == "planereplacement") return "planeReplacementSeconds";
@@ -170,6 +171,7 @@ class ERR_AdminService
if (key == "aiShadow") return "On (1) records coordinated proposals without issuing their orders; Off (0) uses the selected doctrine. Saved for next round.";
if (key == "startWeaponCount") return "0..12 pistols in the shared prison/crash supply backpack; default 5. Applies next round.";
if (key == "startMagazineCount") return "0..48 spare pistol magazines in the shared prison/crash supply backpack; default 8. Applies next round.";
if (key == "sarkaCarsPerAnger") return "0-5 cars per anger point; 0 disables new waves. Default 1; maximum 30 cars per wave, four special-forces crew per car. Applies to new waves; existing teams remain.";
if (key == "motorbikeCount") return "0-6; default 2; 0 disabled. Optional WCS Motorbikes, two response specialists per bike. Applies next round; losses are not replaced.";
if (key == "planeAltitudeMetres") return "150-500; default 250. Applies next round.";
if (key == "planeReplacementSeconds") return "30-1800; default 60. Applies next round.";
@@ -1,7 +1,20 @@
#ifdef WORKBENCH
modded class ERR_AdminMenu
{
bool ERR_SarkaProbeReady() { return m_Ready && m_AvailableFields.Contains("sarkaCarsPerAnger"); }
bool ERR_SarkaProbeSet(int cars)
{
ShowPage(Navigation().Find("OpenSarka"));
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("sarkaCarsPerAnger", GetRootWidget());
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("Apply_sarkaCarsPerAnger", GetRootWidget());
if (!edit || !button || !button.IsEnabled()) return false;
edit.SetValue(cars.ToString()); OnButton(button); return true;
}
}
modded class ERR_HunterService
{
void ERR_ProbeSarkaDue(float now) { m_SarkaWaves.NextWave = now; }
bool ERR_ProbeSarkaDisabled() { return m_SarkaWaves.CarsPerAnger == 0 && m_SarkaWaves.Pending == 0; }
void ERR_ProbeSarkas(out array<ERR_HunterVehicle> cars)
{
cars.Clear();
@@ -52,6 +65,23 @@ modded class ERR_GameMode
float now = GetWorld().GetWorldTime();
if (m_ERRSarkaStage == 0)
{
if (!ERR_SarkaProbeCheck(Settings().Get("sarkaCarsPerAnger") == 1, "default one car per anger point")) return;
ERR_AdminMenu.Open(); m_ERRSarkaStage = 10; return;
}
if (m_ERRSarkaStage == 10)
{
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu();
if (!menu || !menu.ERR_SarkaProbeReady()) return;
if (!ERR_SarkaProbeCheck(menu.ERR_SarkaProbeSet(2), "real menu submits two cars per anger point")) return;
m_ERRSarkaStage = 11; return;
}
if (m_ERRSarkaStage == 11)
{
if (Settings().Get("sarkaCarsPerAnger") != 2) return;
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (!menu || !menu.ERR_SarkaProbeReady()) return;
System.MakeScreenshot("$logs:err-sarka-settings"); menu.Close();
ERR_Settings reloaded = new ERR_Settings();
if (!ERR_SarkaProbeCheck(reloaded.Get("sarkaCarsPerAnger") == 2, "menu owner RPC persists multiplier")) return;
if (!ERR_SarkaProbeCheck(UnlockPrison(player) || Phase() >= ERR_Phase.RADIO_SEARCH, "prison released")) return;
hq.ERR_ProbeSarkaBodies(now);
if (!ERR_SarkaProbeCheck(hq.Watch().Aggression() == 2, "six delivered bodies produce two anger points")) return;
@@ -59,10 +89,26 @@ modded class ERR_GameMode
hq.Hunters().ERR_ProbeSarkaDue(now); m_ERRSarkaStage = 1;
}
array<ERR_HunterVehicle> cars = {}; hq.Hunters().ERR_ProbeSarkas(cars);
if (cars.Count() < 2) return;
if (cars.Count() < 4) return;
if (m_ERRSarkaStage == 20)
{
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu();
if (!menu || !menu.ERR_SarkaProbeReady()) return;
if (!ERR_SarkaProbeCheck(menu.ERR_SarkaProbeSet(0), "real menu submits disable")) return;
m_ERRSarkaStage = 21; return;
}
if (m_ERRSarkaStage == 21)
{
if (Settings().Get("sarkaCarsPerAnger") != 0 || !hq.Hunters().ERR_ProbeSarkaDisabled()) return;
foreach (ERR_HunterVehicle retained : cars)
if (!ERR_SarkaProbeCheck(retained.Entity && retained.Members.Count() == 4, "disabling retains deployed cars and teams")) return;
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (menu) menu.Close();
GetGame().GetCallqueue().Remove(ERR_SarkaProbeTick);
Print("ERR SMOKE COMPLETE: sarka menu save, multiplier, four SF cars, radio, driving and disable"); return;
}
if (m_ERRSarkaStage == 1)
{
if (!ERR_SarkaProbeCheck(cars.Count() == 2, "two physical cars for two anger points")) return;
if (!ERR_SarkaProbeCheck(cars.Count() == 4, "two cars per anger point produce four physical cars")) return;
array<vector> positions = {player.GetOrigin()};
foreach (ERR_HunterVehicle car : cars)
{
@@ -87,8 +133,7 @@ modded class ERR_GameMode
if (!m_ERRSarkaOrder || vector.DistanceXZ(cars[0].PatrolHome, cars[0].Entity.GetOrigin()) < 30) return;
if (!ERR_SarkaProbeCheck(hq.Hunters().Deployed() <= hq.Watch().HunterTarget(), "cars do not spend ordinary hunter allowance")) return;
ERR_SarkaProbeCheck(true, "native patrol order and over 30 metres driven; all crew enhanced");
GetGame().GetCallqueue().Remove(ERR_SarkaProbeTick);
Print("ERR SMOKE COMPLETE: sarka hunters, two anger points, two four-person SF cars, road distance, radio and driving");
ERR_AdminMenu.Open(); m_ERRSarkaStage = 20;
}
}
#endif
@@ -9,12 +9,13 @@ class ERR_Settings
static array<string> Keys()
{
return {"aiDoctrine", "aiShadow", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
return {"aiDoctrine", "aiShadow", "sarkaCarsPerAnger", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
}
static bool Valid(string key, int value)
{
if (key == "aiDoctrine" || key == "aiShadow") return value == 0 || value == 1;
if (key == "sarkaCarsPerAnger") return value >= 0 && value <= 5;
if (key == "motorbikeCount") return value >= 0 && value <= 6;
if (key == "planeAltitudeMetres") return value >= 150 && value <= 500;
if (key == "planeReplacementSeconds") return value >= 30 && value <= 1800;
@@ -117,6 +118,7 @@ class ERR_Settings
static int AddedDefault(string key)
{
if (key == "aiDoctrine" || key == "aiShadow") return 0;
if (key == "sarkaCarsPerAnger") return 1;
if (key == "motorbikeCount") return 2;
if (key == "planeAltitudeMetres") return 250;
if (key == "planeReplacementSeconds") return 60;
@@ -152,6 +154,7 @@ class ERR_Settings
m_Path = path;
m_Values.Set("aiDoctrine", AddedDefault("aiDoctrine"));
m_Values.Set("aiShadow", AddedDefault("aiShadow"));
m_Values.Set("sarkaCarsPerAnger", AddedDefault("sarkaCarsPerAnger"));
m_Values.Set("motorbikeCount", AddedDefault("motorbikeCount"));
m_Values.Set("planeAltitudeMetres", AddedDefault("planeAltitudeMetres"));
m_Values.Set("planeReplacementSeconds", AddedDefault("planeReplacementSeconds"));
@@ -173,6 +173,7 @@ class ERR_HQ : ERR_CommandService
m_Hunters = new ERR_HunterService(mode, null);
m_Hunters.ConfigureOpening(mode.Settings().Get("hunterSpawnDistanceMetres"));
m_Hunters.ConfigureMotorbikes(mode.Settings().Get("motorbikeCount"));
m_Hunters.ConfigureSarka();
m_ObjectiveDefence = new ERR_ObjectiveDefence(mode, this);
m_Towns = new ERR_TownGarrisons(mode);
m_Roadblocks = new ERR_Roadblocks(mode);
@@ -8,15 +8,22 @@ class ERR_SarkaWaves
int Target;
int Wave;
int Spawned;
int CarsPerAnger = 1;
void Configure(int cars)
{
if (CarsPerAnger == cars) return;
CarsPerAnger = cars;
if (cars == 0) Pause();
}
void Pause() { NextWave = -1; Pending = 0; Target = 0; }
bool Due(float now, int aggression, bool playing)
{
if (!playing) { Pause(); return false; }
if (!playing || CarsPerAnger == 0) { Pause(); return false; }
if (NextWave < 0) { NextWave = now + 600000; return false; }
if (now >= NextWave)
{
NextWave = now + 600000; NextAttempt = now;
Target = Math.Clamp(aggression, 0, 30); Pending = Target; Wave++;
Target = Math.Clamp(Math.Clamp(aggression, 0, 30) * CarsPerAnger, 0, 30); Pending = Target; Wave++;
}
if (Pending == 0 || now < NextAttempt) return false;
NextAttempt = now + 5000;
@@ -138,10 +138,18 @@ class ERR_HunterService : ERR_ConvoyService
protected float m_NextReserve;
protected ref ERR_HunterVehicle m_PendingReserve;
protected ref ERR_SarkaWaves m_SarkaWaves = new ERR_SarkaWaves();
protected string m_SarkaStatus = "first wave ten minutes after prison escape";
protected string m_SarkaStatus = "waiting for first wave after prison escape";
void ConfigureSarka()
{
int cars = m_Mode.Settings().Get("sarkaCarsPerAnger");
bool changed = cars != m_SarkaWaves.CarsPerAnger;
m_SarkaWaves.Configure(cars);
if (changed) m_SarkaStatus = "saved car count applies to the next wave";
if (cars == 0) m_SarkaStatus = "new waves disabled; existing teams remain";
}
string SarkaStatus()
{
return string.Format(" Sarka waves: %1 deployed, %2/%3 pending in wave %4; %5.", m_SarkaWaves.Spawned, m_SarkaWaves.Pending, m_SarkaWaves.Target, m_SarkaWaves.Wave, m_SarkaStatus);
return string.Format(" Sarka waves: %1 deployed, %2/%3 pending in wave %4; %5.", m_SarkaWaves.Spawned, m_SarkaWaves.Pending, m_SarkaWaves.Target, m_SarkaWaves.Wave, m_SarkaStatus) + string.Format(" %1 cars per anger point (30 per wave maximum).", m_SarkaWaves.CarsPerAnger);
}
// HQ maintenance runs even while tactical ticks pause in an empty round.
void PauseSarkaIfEmpty()
@@ -152,6 +160,7 @@ class ERR_HunterService : ERR_ConvoyService
}
protected void TickSarka(ERR_HQ hq, float now)
{
ConfigureSarka();
// Released crews keep their physical/HQ ownership; discard only wave tracking.
for (int retired = m_Hunters.Count() - 1; retired >= 0; retired--)
if (m_Hunters[retired].Sarka && m_Hunters[retired].Released) m_Hunters.Remove(retired);
@@ -161,7 +170,7 @@ class ERR_HunterService : ERR_ConvoyService
if (previousWave != m_SarkaWaves.Wave)
{
m_SarkaStatus = "placing four-person special-forces teams on roads";
if (m_SarkaWaves.Target == 0) m_SarkaStatus = "no aggression; next wave check in ten minutes";
if (m_SarkaWaves.Target == 0) m_SarkaStatus = "no aggression; waiting for the next interval";
ERR_HQ.Event("SARKA WAVE", string.Format("wave=%1 aggression=%2 cars=%3 identified bodies=%4", m_SarkaWaves.Wave, hq.Watch().Aggression(), m_SarkaWaves.Target, hq.Watch().IdentifiedBodies()));
}
if (!due) return;
@@ -192,7 +201,7 @@ class ERR_HunterService : ERR_ConvoyService
else m_SarkaStatus = "vehicle or crew setup failed; retrying this wave";
return;
}
m_SarkaStatus = "waiting for a clear road 1-1.25 km from the nearest player";
m_SarkaStatus = "waiting for a clear road away from players";
}
protected bool SpawnSarka(float now, vector position, vector angles)
{
+14 -1
View File
@@ -67,7 +67,7 @@ TextWidgetClass { Name "Help_emptyRestartHours" Slot FrameWidgetSlot { Anchor 0
} }
FrameWidgetClass { Name "ForcesPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } {
TextWidgetClass {
Name "ForcesHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 }
Name "ForcesHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 710 OffsetRight -738 SizeY 50 OffsetBottom -56 }
Color 0.9 0.92 0.94 1 Text "Enemy patrols, site guards and equipment. Equipment changes apply to newly equipped soldiers." "Font Size" 19 "Min Font Size" 19 Wrap 1
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
}
@@ -210,6 +210,19 @@ TextWidgetClass {
Color 0.72 0.81 0.87 1 Text "Chance of anti-personnel mines for newly equipped soldiers." "Font Size" 14 "Min Font Size" 14 Wrap 1
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
}
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "OpenSarka" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 788 OffsetLeft 788 PositionY 4 OffsetTop 4 SizeX 176 OffsetRight -964 SizeY 38 OffsetBottom -42 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Šárka hunters" } } }
ImageWidgetClass { Name "Selected_OpenSarka" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 788 OffsetLeft 788 PositionY 45 OffsetTop 45 SizeX 176 OffsetRight -964 SizeY 3 OffsetBottom -48 } Color 0.9 0.66 0.25 1 }
} }
FrameWidgetClass { Name "SarkaPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } {
TextWidgetClass { Name "SarkaHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 710 OffsetRight -738 SizeY 32 OffsetBottom -38 } Color 0.9 0.92 0.94 1 Text "Šárka special-forces hunters" "Font Size" 22 "Min Font Size" 22 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "SarkaBack" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 788 OffsetLeft 788 PositionY 4 OffsetTop 4 SizeX 176 OffsetRight -964 SizeY 38 OffsetBottom -42 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Back to Forces" } } }
TextWidgetClass { Name "SarkaHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 50 OffsetTop 50 SizeX 936 OffsetRight -964 SizeY 44 OffsetBottom -94 } Color 0.9 0.92 0.94 1 Text "Four soldiers per car, including a medic. Anger rises from identified enemy casualties." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "Label_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 590 OffsetRight -618 SizeY 32 OffsetBottom -144 } Color 0.9 0.92 0.94 1 Text "Šárkas per anger point (0-5; 0 disables waves)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 108 OffsetTop 108 SizeX 112 OffsetRight -752 SizeY 38 OffsetBottom -146 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 764 OffsetLeft 764 PositionY 108 OffsetTop 108 SizeX 200 OffsetRight -964 SizeY 38 OffsetBottom -146 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } }
TextWidgetClass { Name "Saved_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 148 OffsetTop 148 SizeX 324 OffsetRight -964 SizeY 24 OffsetBottom -172 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "Help_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 176 OffsetTop 176 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -202 } Color 0.9 0.92 0.94 1 Text "Default 1; up to 30 cars per wave. Existing teams remain when disabled." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "SarkaSettingsHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 424 OffsetTop 424 SizeX 936 OffsetRight -964 SizeY 52 OffsetBottom -476 } Color 0.9 0.92 0.94 1 Text "Apply saves this setting for new waves. Existing teams remain. Empty sessions do not build up extra waves." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
} }
FrameWidgetClass { Name "SitesPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } {
TextWidgetClass { Name "SitesHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 710 OffsetRight -738 SizeY 32 OffsetBottom -38 } Color 0.9 0.92 0.94 1 Text "Camps and hospitals - next round" "Font Size" 22 "Min Font Size" 22 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
+29 -4
View File
@@ -1,9 +1,14 @@
# Discord changelogs through Drone
Active since 2026-10-17: [build #1](https://ci.internet-master.cz/escapists/EscapistsReintroduced/1)
confirmed delivery, and the user confirmed receipt. Drone is the sole sender.
The retired local `tools/discord_changelog.py` exits without sending, including
when invoked with its old arguments.
`.drone.yml` runs a Docker pipeline for pushes to `main`. It tests the notifier,
then reads `CHANGELOG.md` from the pushed commit. Discord receives only the newest
entry's heading and body, with mentions disabled and no build, Git or hosting
status added.
entry's heading and body, plus the last recorded published Workshop version when
available, with mentions disabled and no build, Git or hosting status added.
## One-time setup
@@ -15,8 +20,8 @@ status added.
3. Push these files to `main`, then verify the `notify-discord` step and its
corresponding Discord message. Creating the YAML alone does not activate Drone
or install its Gitea webhook.
4. Once Drone delivery is enabled, stop the manual post-push notifier. Both
delivery paths running for the same push would produce duplicate messages.
4. Keep local/manual notification sending disabled. Do not add a second sender
or automatically fall back to local delivery after a failed Drone build.
Drone's repository activation manages the Gitea event webhook. This notification
pipeline does not compile, publish or update the mission.
@@ -39,6 +44,24 @@ headings inside code examples within an entry. Keep the formatted newest entry
within Discord's 2000-character limit; oversized entries fail without truncation.
The dated files in `publishing/` remain separate Workshop release notes.
After compiling/bundling and successfully publishing in Workbench, run:
```sh
python3 tools/workshop_changelog.py --published
```
The helper reads `asset.version` from Workbench's local publishing manifest and
updates `Latest published Workshop version: **0.0.109**` above the changelog
entries. It verifies the addon ID against `addons/core/addon.gproj`. Use
`--manifest /path/to/manifest.json` on another installation. Without `--published`
it only displays the bundle version. The flag confirms that you observed a
successful upload; the helper does not upload or query the Workshop.
Commit that record together with the new player-facing entry and source changes,
then push. Drone reads the version from Git, without needing Workbench files.
Updating only the version does not resend the previous entry. The message labels
it as the last published version, since a Git push can contain newer changes.
The notifier compares `DRONE_COMMIT_BEFORE` with `DRONE_COMMIT_SHA`, covering the
whole push, even when a documentation commit follows the changelog commit.
Unchanged newest entries, historical edits and removing an entry to expose an
@@ -53,6 +76,8 @@ previous revision; an exact-SHA fetch is attempted for a missing before commit.
## Verification and delivery failures
Run `python3 -m unittest discover -s tests -p test_discord_changelog_ci.py` locally.
Also run `python3 -m unittest discover -s tests -p test_workshop_changelog.py`
for the Workbench version sync.
The tests use temporary Git repositories and mocked HTTP; they send no messages.
`python3 tools/discord_changelog_ci.py --dry-run` inside a Drone checkout with its
normal commit environment prints the selected message without using the secret.
+19
View File
@@ -38,3 +38,22 @@ dialog. Select it before publishing.
This directory holds publishing artwork separately from game resources, so the
PNG does not need Enfusion texture metadata or inclusion in the runtime PAK.
## Recording the published version
The project `.gproj` does not store the Workshop release version. Workbench stores
it as `asset.version` in its local publishing `manifest.json`; the top-level
`version` is the manifest schema. The latest recorded published version is in
the preamble of [CHANGELOG.md](../CHANGELOG.md).
After a successful Workshop upload, run
`python3 tools/workshop_changelog.py --published` to copy the version into
`CHANGELOG.md`. The default manifest path
targets this Linux/Proton Workbench installation; `--manifest PATH` overrides it.
Running without `--published` only shows the bundled version. The helper does not
publish or verify the online Workshop revision, so compilation alone is not enough
to record a release as published.
Add the player-facing changes to the newest changelog entry, commit all intended
source and changelog changes, then push. Drone includes the recorded version in
the Discord changelog. See [the notification workflow](../docs/discord-changelog-ci.md).
+1 -1
View File
@@ -53,7 +53,7 @@ assert 'Apply_planeReconEnabled' not in names # Saved-state toggle pair, not a
assert len(names) == len(set(names)), 'Menu widget names must be unique'
for key in keys:
assert all(name in names for name in (key, 'Apply_' + key, 'Saved_' + key, 'Help_' + key))
if key not in ('emptyRestartHours','aiDoctrine','aiShadow','motorbikeCount','planeAltitudeMetres','planeReplacementSeconds','planeSectorSeconds','fieldCampCount','fieldHospitalCount','startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres','objectGuardCount','objectGuardEvery','objectGuardHQMode','startWeaponCount','startMagazineCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names
if key not in ('sarkaCarsPerAnger','emptyRestartHours','aiDoctrine','aiShadow','motorbikeCount','planeAltitudeMetres','planeReplacementSeconds','planeSectorSeconds','fieldCampCount','fieldHospitalCount','startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres','objectGuardCount','objectGuardEvery','objectGuardHQMode','startWeaponCount','startMagazineCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names
else: assert 'Random_startMode' not in names
for name in ('Close', 'SettingsPage', 'HQPage', 'JournalPage', 'JournalText', 'JournalContent', 'JournalScroll', 'PauseJournal', 'LatestJournal', 'Status'):
assert name in names
+3
View File
@@ -21,6 +21,9 @@ int main(){
assert(!Valid("emptyRestartHours",-1)&&!Valid("emptyRestartHours",169));
for(std::string key:{"aiDoctrine","aiShadow"}){ assert(Valid(key,0)&&Valid(key,1)); assert(!Valid(key,-1)&&!Valid(key,2)); }
assert(CoreSetting("mission","motorbikes")=="motorbikeCount");
assert(CoreSetting("mission","sarkacars")=="sarkaCarsPerAnger");
assert(Valid("sarkaCarsPerAnger",0)&&Valid("sarkaCarsPerAnger",1)&&Valid("sarkaCarsPerAnger",5));
assert(!Valid("sarkaCarsPerAnger",-1)&&!Valid("sarkaCarsPerAnger",6));
assert(Valid("motorbikeCount",0)&&Valid("motorbikeCount",2)&&Valid("motorbikeCount",6));
assert(!Valid("motorbikeCount",-1)&&!Valid("motorbikeCount",7));
assert(CoreSetting("mission","planealtitude")=="planeAltitudeMetres");
+14
View File
@@ -35,6 +35,20 @@ class SelectionTests(unittest.TestCase):
def test_edit_current_entry(self):
self.assertIn('Corrected feature.', ci.latest_changes(NEW, NEW.replace('New feature.', 'Corrected feature.')))
def test_workshop_version_context_and_no_metadata_only_repost(self):
with_version = NEW.replace('# Changelog\n', '# Changelog\n\nLatest published Workshop version: **0.0.109**\n')
self.assertIn('Last published Workshop version: **0.0.109**', ci.latest_changes(OLD, with_version))
self.assertIsNone(ci.latest_changes(NEW, with_version))
self.assertIsNone(ci.latest_changes(with_version, with_version.replace('0.0.109', '0.0.110')))
for bad in ('not-a-version', '99999.0.0'):
with self.assertRaises(ci.ChangelogError):
ci.latest_changes(OLD, with_version.replace('0.0.109', bad))
def test_version_note_counts_toward_discord_limit(self):
text = '# Changelog\n\nLatest published Workshop version: **0.0.109**\n\n## A\n' + 'x' * 1993
with self.assertRaises(ci.ChangelogError):
ci.latest_changes('', text)
def test_invalid_entries(self):
for text in ('', '# Changelog', '## Empty\n', '## A\nOne\n## A\nTwo', '## Huge\n' + 'x' * 2000,
'## Emoji\n' + '\U0001f680' * 1000):
+14 -1
View File
@@ -38,6 +38,19 @@ int main(){
assert(!w.Due(10600001,30,false)&&w.Pending==0&&w.NextWave==-1);
assert(!w.Due(20000000,0,true));assert(!w.Due(20600000,0,true)&&w.Target==0);
assert(!w.Due(21200000,-1,true)&&w.Pending==0);
Waves configured{};
configured.Configure(2);
assert(!configured.Due(0,3,true));
assert(configured.Due(600000,3,true)&&configured.Target==6);
configured.Commit();assert(configured.Pending==5&&configured.Spawned==1);
configured.Configure(5); // Already sampled wave and deadline stay unchanged.
assert(configured.Pending==5&&configured.NextWave==1200000);
assert(configured.Due(1200000,30,true)&&configured.Target==30); // Keep the formation cap.
configured.Configure(0);assert(configured.Pending==0&&configured.Target==0);
assert(!configured.Due(9999999,30,true)&&configured.Spawned==1); // No waves; retain deployed count.
configured.Configure(2);assert(!configured.Due(10000000,3,true));
assert(!configured.Due(10599999,3,true)); // Re-enable waits normally, no backlog.
assert(configured.Due(10600000,3,true)&&configured.Target==6);
Array<vector> players{{0,10,0}};
assert(!Rules::InBand({999,0,0},players));
assert(Rules::InBand({1000,900,0},players)&&Rules::InBand({1250,0,0},players));
@@ -48,4 +61,4 @@ int main(){
assert(Rules::Seats(1,0,3,4)&&!Rules::Seats(1,1,2,4)&&!Rules::Seats(1,0,4,5));
}
''')
print('PASS: ten-minute anger waves, staged retries, empty-round reset, no backlog, four seats and nearest-player road distance')
print('PASS: configurable anger multiplier, disable/re-enable, wave cap, staged retries, empty-round reset, no backlog, four seats and nearest-player road distance')
+44
View File
@@ -0,0 +1,44 @@
"""Version extraction uses the addon asset, not the manifest schema or game version."""
import importlib.util
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('workshop_changelog', ROOT / 'tools/workshop_changelog.py')
tool = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tool)
PROJECT = 'GameProject { GUID "2528466DE220D91C" }'
TEXT = '# Changelog\n\n## Update\n\n- Gameplay fix.\n'
class WorkshopTests(unittest.TestCase):
def test_asset_version(self):
manifest = {'version': 1, 'asset': {'id': '2528466DE220D91C', 'version': '0.0.109', 'gameVersion': '1.8.0.13'}}
self.assertEqual(tool.bundled_version(manifest, PROJECT), '0.0.109')
def test_reject_wrong_addon_and_invalid_manifest(self):
for manifest in (None, [], {}, {'asset': None}, {'asset': {'id': None}},
{'asset': {'id': 'OTHER', 'version': '0.0.109'}},
{'asset': {'id': '2528466DE220D91C', 'version': '99999.0.0'}},
{'version': '0.0.109', 'asset': {'id': '2528466DE220D91C'}}):
with self.subTest(manifest=manifest), self.assertRaises(ValueError):
tool.bundled_version(manifest, PROJECT)
def test_update_preserves_notes_and_is_idempotent(self):
updated = tool.update_changelog(TEXT, '0.0.109')
self.assertIn('Latest published Workshop version: **0.0.109**', updated)
self.assertTrue(updated.endswith('## Update\n\n- Gameplay fix.\n'))
self.assertEqual(updated, tool.update_changelog(updated, '0.0.109'))
next_release = tool.update_changelog(updated, '0.0.110')
self.assertNotIn('0.0.109', next_release)
self.assertEqual(next_release.count('Latest published Workshop version:'), 1)
def test_bad_layout_refused(self):
for text in ('Notes only', TEXT + 'Latest published Workshop version: **0.0.1**\n',
'# Changelog\nLatest published Workshop version: **0.0.1**\nLatest published Workshop version: **0.0.2**\n'):
with self.subTest(text=text), self.assertRaises(ValueError):
tool.update_changelog(text, '0.0.109')
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -8,6 +8,7 @@ from prefab_identity import check_prefabs
ROOT=Path(__file__).resolve().parents[1]
BASE='58D0FB3206B6F859'
def check():
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_workshop_changelog.py'],check=True)
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_discord_changelog_ci.py'],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_controller_init.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True)
+3 -60
View File
@@ -1,68 +1,11 @@
#!/usr/bin/env python3
"""Post a prepared changelog after verifying a successful push to origin/main.
The webhook is local configuration, never a command-line argument or tracked file.
API: https://docs.discord.com/developers/resources/webhook#execute-webhook
"""
import argparse
import json
import shlex
import subprocess
"""Retired local sender: Discord changelogs are delivered exclusively by Drone."""
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--commit', required=True, help='Commit just pushed to origin/main')
parser.add_argument('--message-file', required=True, type=Path, help='Prepared player-facing changelog, at most 2000 characters')
args = parser.parse_args()
commit = subprocess.check_output(['git', 'rev-parse', '--verify', args.commit + '^{commit}'], cwd=ROOT, text=True).strip()
ssh = 'ssh -F ' + shlex.quote(str(Path.home() / '.ssh/config'))
remote = subprocess.check_output(['git', '-c', 'core.sshCommand=' + ssh, 'ls-remote', '--exit-code', 'origin', 'refs/heads/main'], cwd=ROOT, text=True).split()[0]
if commit != remote:
parser.error('Requested commit is not the current origin/main tip; no message sent.')
message = args.message_file.read_text().strip()
if not message or len(message) > 2000:
parser.error('Changelog must contain 1..2000 characters.')
state_path = ROOT / '.local/discord-changelog-state.json'
state = json.loads(state_path.read_text()) if state_path.exists() else {}
if state.get('commit') == commit:
print('This commit is already recorded; no duplicate sent. Status: ' + state.get('status', 'unknown'))
return
url = (ROOT / '.local/discord-webhook.url').read_text().strip()
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != 'https' or parsed.netloc != 'discord.com' or not parsed.path.startswith('/api/webhooks/') or parsed.query or parsed.fragment:
parser.error('Local configuration must contain a Discord HTTPS webhook URL.')
payload = json.dumps({'content': message, 'allowed_mentions': {'parse': []}}).encode()
request = urllib.request.Request(url + '?wait=true', data=payload, headers={
'Content-Type': 'application/json', 'User-Agent': 'EscapistsReintroduced-Changelog/1.0'})
# Preserve an uncertain attempt so a timeout cannot silently cause duplicates.
state_path.write_text(json.dumps({'commit': commit, 'status': 'pending'}) + '\n')
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
result = json.load(response)
if not result.get('id'):
raise ValueError('Missing message confirmation')
except urllib.error.HTTPError as error:
print('Discord returned HTTP ' + str(error.code) + '; inspect delivery before retrying.', file=sys.stderr)
return 1
except (urllib.error.URLError, TimeoutError, ValueError):
print('Discord delivery could not be confirmed; inspect delivery before retrying.', file=sys.stderr)
return 1
state_path.write_text(json.dumps({'commit': commit, 'status': 'sent', 'message_id': result['id']}) + '\n')
print('Discord changelog delivered for ' + commit[:7])
return 0
print('Local Discord sending is disabled. Use the Drone changelog pipeline.', file=sys.stderr)
return 1
if __name__ == '__main__':
+9 -1
View File
@@ -45,7 +45,15 @@ def latest_changes(before, after):
latest = new[0]
if old and (latest == old[0] or latest[0] in [title for title, _ in old[1:]]):
return None # Unchanged newest entry, or removal/reordering of old entries.
message = '**' + latest[0] + '**\n\n' + latest[1]
version_note = ''
preamble = after.split('\n## ', 1)[0]
versions = re.findall(r'^Latest published Workshop version: \*\*(\d+\.\d+\.\d+)\*\*\r?$',
preamble, re.MULTILINE)
if 'Latest published Workshop version:' in preamble:
if len(versions) != 1 or any(int(part) > 32000 for part in versions[0].split('.')):
raise ChangelogError('Invalid Workshop version record in CHANGELOG.md.')
version_note = 'Last published Workshop version: **' + versions[0] + '**\n\n'
message = '**' + latest[0] + '**\n\n' + version_note + latest[1]
if len(message.encode('utf-16-le')) // 2 > 2000:
raise ChangelogError('Newest changelog entry exceeds Discord’s 2000-character limit.')
return message
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Read Workbench's bundled version; record it after a confirmed Workshop upload."""
import argparse
import json
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION_LINE = 'Latest published Workshop version:'
DEFAULT_MANIFEST = (Path.home() / '.local/share/Steam/steamapps/compatdata/1874910/pfx'
/ 'drive_c/users/steamuser/AppData/Local/Temp/Arma Reforger Workbench'
/ 'Publishing/2528466DE220D91C/manifest.json')
def valid_version(value):
return (isinstance(value, str) and re.fullmatch(r'\d+\.\d+\.\d+', value) is not None
and all(int(part) <= 32000 for part in value.split('.')))
def bundled_version(manifest, project):
guid = re.search(r'\bGUID\s+"([A-Fa-f0-9]{16})"', project)
asset = manifest.get('asset', {}) if isinstance(manifest, dict) else {}
if (not guid or not isinstance(asset, dict) or not isinstance(asset.get('id'), str)
or asset['id'].upper() != guid[1].upper()):
raise ValueError('Publishing manifest does not belong to this addon.')
version = asset.get('version') # Top-level "version" is the manifest schema, not the mod version.
if not valid_version(version):
raise ValueError('Publishing manifest has no valid addon version.')
return version
def update_changelog(text, version):
if not valid_version(version):
raise ValueError('Invalid Workshop version.')
if not text.startswith('# Changelog\n'):
raise ValueError('Expected # Changelog at the start of CHANGELOG.md.')
line = VERSION_LINE + ' **' + version + '**'
existing = list(re.finditer(r'^Latest published Workshop version:.*$', text, re.MULTILINE))
if len(existing) > 1:
raise ValueError('Changelog contains multiple Workshop version records.')
if existing:
match = existing[0]
if match.start() > text.find('\n## ') >= 0:
raise ValueError('Workshop version record must be above the changelog entries.')
return text[:match.start()] + line + text[match.end():]
return text.replace('# Changelog\n', '# Changelog\n\n' + line + '\n', 1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, default=DEFAULT_MANIFEST)
parser.add_argument('--published', action='store_true',
help='Confirm the bundle was successfully uploaded; update CHANGELOG.md')
args = parser.parse_args()
try:
manifest = json.loads(args.manifest.read_text(encoding='utf-8-sig'))
version = bundled_version(manifest, (ROOT / 'addons/core/addon.gproj').read_text())
if args.published:
path = ROOT / 'CHANGELOG.md'
before = path.read_text()
after = update_changelog(before, version)
if before != after:
path.write_text(after)
print('Recorded published Workshop version ' + version + ' in CHANGELOG.md.')
else:
print('Workbench bundle version: ' + version)
print('Bundling alone does not confirm publication. Use --published after a successful upload.')
except (OSError, ValueError, AttributeError) as error:
print('Cannot read/record Workshop version: ' + str(error), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())