Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e71ef1fa7 | ||
|
|
38fd8f7dd2 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user