Compare commits

..

11 commits

Author SHA1 Message Date
0a3dfb00bc Merge pull request 'Release combat sharing leases without interrupting dashboard vitals' (#3) from codex/release-0.1.1 into main
All checks were successful
CI / build-and-test (push) Successful in 52s
CI / release (push) Successful in 51s
2026-09-27 21:17:11 +00:00
Erik
db5893413f Require the peer ordering fix in OpenAC 0.1.22
All checks were successful
CI / build-and-test (pull_request) Successful in 41s
CI / release (pull_request) Has been skipped
2026-09-27 22:39:01 +02:00
Erik
f373105120 Release subscription-based combat sharing as 0.1.1 2026-09-27 22:07:42 +02:00
Erik
55dc989f85 Release local peer subscriptions when combat sharing stops 2026-09-27 20:47:52 +02:00
23ef606727 Merge pull request 'Build, test and release on the club Gitea' (#2) from claude/forgejo-ci into main
All checks were successful
CI / release (push) Successful in 41s
CI / build-and-test (push) Successful in 44s
2026-09-26 14:05:08 +00:00
Erik
ac2e89c057 Remove the temporary failure-report step
All checks were successful
CI / build-and-test (pull_request) Successful in 44s
CI / release (pull_request) Has been skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:04:16 +02:00
Erik
32878d825e Test server: a dropped connection closes on every platform
All checks were successful
CI / build-and-test (pull_request) Successful in 37s
CI / release (pull_request) Has been skipped
On Linux, aborting the server-side WebSocket left the TCP connection open,
so the client never saw the drop and the reconnect test timed out on the
Gitea runner. Ending the response closes it everywhere, as a real server's
drop does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:03:22 +02:00
Erik
84155c0d18 Temporary: fix the failure-report step
Some checks failed
CI / build-and-test (pull_request) Failing after 47s
CI / release (pull_request) Has been skipped
2026-09-26 16:01:46 +02:00
Erik
4b90aa8348 Temporary: post CI failure output to the PR 2026-09-26 16:01:38 +02:00
Erik
a9c6938f0e Build, test and release on the club Gitea
Some checks failed
CI / build-and-test (pull_request) Failing after 47s
CI / release (pull_request) Has been skipped
A Forgejo workflow builds and tests every push and pull request on the
club's docker runner. A v* tag also assembles the plugin zip, checks it
with OpenAC's own plugin checker, and publishes a Gitea release with the
zip, its checksum and plugin.json.

The checker refused the manifest: both permission notes were over the
launcher's 120-character limit. They are shorter now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:00:00 +02:00
Erik Nihlén
dde4411958
Merge pull request #1 from eriknihlen/claude/mm-ui-one-window
One MM button, tabbed window, and connection status in chat
2026-09-26 12:50:25 +02:00
9 changed files with 219 additions and 10 deletions

103
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,103 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
# The OpenAC release whose AcDream.Plugin.Abstractions package this plugin
# builds against. PluginApiPackageVersion in Directory.Build.props has to
# name the same version.
OPENAC_API_RELEASE_TAG: v0.1.22
OPENAC_API_VERSION: 0.1.22
jobs:
build-and-test:
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install the pinned .NET SDK
uses: https://github.com/actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Fetch the plugin contract package
run: |
set -euo pipefail
mkdir -p packages-local
curl -fsSL -o "packages-local/AcDream.Plugin.Abstractions.${OPENAC_API_VERSION}.nupkg" \
"https://github.com/eriknihlen/OpenAC/releases/download/${OPENAC_API_RELEASE_TAG}/AcDream.Plugin.Abstractions.${OPENAC_API_VERSION}.nupkg"
- name: Build
run: dotnet build OpenAC.MosswartMassacre.slnx -c Release --nologo
- name: Test
run: dotnet test OpenAC.MosswartMassacre.slnx -c Release --no-build --nologo
release:
# Tag pushes only, once the build and tests are green.
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-and-test]
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install the pinned .NET SDK
uses: https://github.com/actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Fetch the plugin contract package
run: |
set -euo pipefail
mkdir -p packages-local
curl -fsSL -o "packages-local/AcDream.Plugin.Abstractions.${OPENAC_API_VERSION}.nupkg" \
"https://github.com/eriknihlen/OpenAC/releases/download/${OPENAC_API_RELEASE_TAG}/AcDream.Plugin.Abstractions.${OPENAC_API_VERSION}.nupkg"
- name: Build
run: dotnet build OpenAC.MosswartMassacre.slnx -c Release --nologo
- name: Assemble the release assets
run: |
set -euo pipefail
bash tools/build-release-zip.sh Release artifacts | tee build-zip.out
cat build-zip.out >> "$GITHUB_ENV"
# The tag is the version the release is known by; the manifest's version
# is what an install reads. They have to agree.
- name: The tag has to name the manifest's version
run: |
set -euo pipefail
if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then
echo "Tag '${GITHUB_REF_NAME}' does not name the manifest version (v${version})." >&2
exit 1
fi
# acdream-plugincheck is the OpenAC launcher's own install code, built
# from the same OpenAC release the contract package comes from.
- name: Check the zip with the launcher's own install code
run: |
set -euo pipefail
git clone --quiet --depth 1 --branch "$OPENAC_API_RELEASE_TAG" https://github.com/eriknihlen/OpenAC.git .openac
dotnet run --project .openac/src/AcDream.PluginCheck -c Release -- "$zip"
- name: Publish the release
env:
TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
set -euo pipefail
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": "Mosswart Massacre " + sys.argv[1]}))' "$GITHUB_REF_NAME")
id=$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$body" "$api/releases" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
for file in artifacts/plugin.json artifacts/*.zip artifacts/*.zip.sha256; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@${file}" "$api/releases/${id}/assets?name=$(basename "$file")" > /dev/null
echo "uploaded $(basename "$file")"
done

View file

@ -10,12 +10,12 @@
<Deterministic>true</Deterministic>
<!-- The plugin's own version; plugin.json names the same number. -->
<Version>0.1.0</Version>
<Version>0.1.1</Version>
<!-- The plugin contract, compile-only, from the .nupkg in ./packages-local
(downloaded from the OpenAC release of the same version). The host
supplies the contract assembly at runtime. -->
<PluginApiPackageVersion>0.1.19</PluginApiPackageVersion>
<PluginApiPackageVersion>0.1.22</PluginApiPackageVersion>
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>

View file

@ -12,12 +12,12 @@ byte-for-byte what the DECAL plugin sent and accepted; the golden tests under
## Building
The plugin contract is `AcDream.Plugin.Abstractions` of the version named in
`Directory.Build.props` (`PluginApiPackageVersion`), currently 0.1.19, the
OpenAC 0.1.19 release. Put that release's package in `packages-local/`; an
OpenAC client from 0.1.19 on loads the plugin.
`Directory.Build.props` (`PluginApiPackageVersion`), currently 0.1.22, the
OpenAC 0.1.22 release. Put that release's package in `packages-local/`; an
OpenAC client from 0.1.22 on loads the plugin.
```
gh release download v0.1.19 --repo eriknihlen/OpenAC -p "AcDream.Plugin.Abstractions.0.1.19.nupkg" -D packages-local
gh release download v0.1.22 --repo eriknihlen/OpenAC -p "AcDream.Plugin.Abstractions.0.1.22.nupkg" -D packages-local
dotnet build OpenAC.MosswartMassacre.slnx
dotnet test OpenAC.MosswartMassacre.slnx
```
@ -56,3 +56,9 @@ dotnet run --project tools/FrameRecorder -- record 18765 frames.jsonl
/mm ws url ws://localhost:18765/websocket/
dotnet run --project tools/FrameRecorder -- compare decal.jsonl port.jsonl
```
## Local peer subscriptions
Requires OpenAC 0.1.22. Combat vital sharing acquires a local state/cast subscription only while enabled, and releases it on disable, logout or unload. Dashboard vitals still stream every five seconds directly from character state without enabling local peer communication. Existing remote WebSocket frames and timing are unchanged.
Before the new contract release exists, pack AcDream.Plugin.Abstractions 0.1.22 from the matching OpenAC checkout and copy its nupkg into packages-local before restore/build. CI downloads that release, so publish the OpenAC contract before dependent plugin releases.

View file

@ -56,6 +56,7 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
private readonly MmContext _context;
private readonly PeerTable _peers = new();
private IDisposable? _timer;
private IDisposable? _peerSubscription;
private int _lastHealth = -1, _lastMaxHealth = -1;
private int _lastStamina = -1, _lastMaxStamina = -1;
@ -103,6 +104,8 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
if (IsActive)
return;
IsActive = true;
_peerSubscription = _context.Automation.Network.Subscribe(
PluginPeerCapabilities.ClientState | PluginPeerCapabilities.Casts);
ResetOutgoingState();
_timer = _context.Scheduler.Every(TickInterval, OnTick);
_context.Send(new
@ -121,6 +124,8 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
if (!IsActive)
return;
IsActive = false;
_peerSubscription?.Dispose();
_peerSubscription = null;
_timer?.Dispose();
_timer = null;
_context.Send(new

View file

@ -1,15 +1,15 @@
{
"id": "openac.mosswartmassacre",
"displayName": "Mosswart Massacre",
"version": "0.1.0",
"version": "0.1.1",
"entryDll": "OpenAC.MosswartMassacre.dll",
"apiVersion": 1,
"kinds": ["Gameplay"],
"hosts": ["Graphical", "Headless"],
"minHostVersion": "0.1.19",
"minHostVersion": "0.1.22",
"capabilitiesVersion": 1,
"capabilities": [
{ "name": "network", "note": "Streams telemetry, vitals, chat, inventory and stats to the Mosswart Overlord backend, and receives shared vitals and chat-box commands from it." },
{ "name": "chat", "note": "Reads chat for kills, rares and combat stats, forwards it to the backend, and runs commands typed into the Overlord chat box." }
{ "name": "network", "note": "Streams stats, vitals, chat and inventory to Mosswart Overlord, and receives shared vitals and commands from it." },
{ "name": "chat", "note": "Reads chat for kills, rares and combat stats, forwards it to Overlord, and runs commands sent from its chat box." }
]
}

View file

@ -17,6 +17,7 @@ internal sealed class TestSocketServer : IDisposable
private readonly CancellationTokenSource _cts = new();
private readonly Task _accept;
private WebSocket? _current;
private HttpListenerContext? _currentContext;
public TestSocketServer()
{
@ -53,7 +54,12 @@ internal sealed class TestSocketServer : IDisposable
{
WebSocket socket = await WaitForSocketAsync();
socket.Abort();
// Aborting the socket alone leaves the TCP connection open on some
// platforms' listeners, so the client never sees the drop; ending the
// response closes the connection everywhere, as a real server's drop does.
_currentContext?.Response.Abort();
_current = null;
_currentContext = null;
}
public string NextFrame(int timeoutMs = 5000)
@ -111,6 +117,7 @@ internal sealed class TestSocketServer : IDisposable
Secrets.Enqueue(context.Request.Headers["X-Plugin-Secret"]);
HttpListenerWebSocketContext ws = await context.AcceptWebSocketAsync(null);
_current = ws.WebSocket;
_currentContext = context;
_ = Task.Run(() => ReadLoopAsync(ws.WebSocket));
}
}

View file

@ -8,6 +8,16 @@ namespace OpenAC.MosswartMassacre.Tests.Fakes;
/// </summary>
public sealed class FakeNetwork : INetworkAutomation
{
public bool SupportsSubscriptions => true;
public List<PluginPeerCapabilities> Requests { get; } = [];
public int ActiveLeases { get; private set; }
public IDisposable Subscribe(PluginPeerCapabilities capabilities)
{ Requests.Add(capabilities); ActiveLeases++; return new Lease(this); }
private sealed class Lease(FakeNetwork owner) : IDisposable
{
private bool _disposed;
public void Dispose() { if (_disposed) return; _disposed = true; owner.ActiveLeases--; }
}
public bool IsAvailable { get; set; } = true;
public List<PluginNetworkClient> ImportedClients { get; } = [];

View file

@ -42,6 +42,21 @@ public sealed class VitalSharingTests
private static VitalSharing Feature(PluginHarness harness) => harness.Feature<VitalSharing>();
[Fact]
public void Local_peer_subscription_exists_only_while_combat_sharing_is_enabled()
{
using var harness = Sharing();
var network = harness.Host.Automation.Network;
Assert.Empty(network.Requests);
Feature(harness).Start();
Feature(harness).Start();
Assert.Equal(1, network.ActiveLeases);
Assert.Equal(PluginPeerCapabilities.ClientState | PluginPeerCapabilities.Casts, Assert.Single(network.Requests));
Feature(harness).OnLogoff();
Feature(harness).Dispose();
Assert.Equal(0, network.ActiveLeases);
}
[Fact]
public void Subscribe_on_start_carries_the_player_id_and_tags()
{

63
tools/build-release-zip.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Assembles the plugin release zip and its .sha256 sidecar from a Release build.
#
# The layout is the one the OpenAC launcher's installer accepts:
# - plugin.json and the entry DLL sit at the zip root;
# - only these extensions: .dll .pdb .json .xml .txt .md .png .jpg .jpeg .ttf .otf;
# - no runtimes/ folder, and no AcDream assembly beside the plugin's own;
# - the plugin.json published as its own release asset is the same file as the
# zip's copy;
# - the sidecar is `sha256sum`'s line: the hash, two spaces, the zip's name.
# Asset names are <id>-<version>.zip and <id>-<version>.zip.sha256.
set -euo pipefail
configuration="${1:-Release}"
output="${2:-artifacts}"
repo="$(cd "$(dirname "$0")/.." && pwd)"
build="$repo/src/OpenAC.MosswartMassacre/bin/$configuration/net10.0"
[ -d "$build" ] || { echo "No build output at $build. Build the solution first." >&2; exit 1; }
read_field() {
python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8-sig"))[sys.argv[2]])' "$build/plugin.json" "$1"
}
id="$(read_field id)"
version="$(read_field version)"
entry="$(read_field entryDll)"
stem="${entry%.dll}"
staging="$(mktemp -d)"
trap 'rm -rf "$staging"' EXIT
for name in plugin.json "$entry" "$stem.pdb" "$stem.deps.json" Newtonsoft.Json.dll; do
[ -f "$build/$name" ] || { echo "The build output has no '$name'." >&2; exit 1; }
cp "$build/$name" "$staging/"
done
cp "$build"/mosswartmassacre-*.xml "$staging/"
for file in "$staging"/*; do
name="$(basename "$file")"
case "${name,,}" in
*.dll|*.pdb|*.json|*.xml|*.txt|*.md|*.png|*.jpg|*.jpeg|*.ttf|*.otf) ;;
*) echo "'$name' has an extension the launcher refuses." >&2; exit 1 ;;
esac
if [[ "$name" == AcDream.*.dll ]]; then
echo "'$name' is a client assembly beside the plugin." >&2; exit 1
fi
done
mkdir -p "$repo/$output"
zip="$repo/$output/$id-$version.zip"
rm -f "$zip"
python3 - "$staging" "$zip" <<'PY'
import os, sys, zipfile
src, dst = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z:
for name in sorted(os.listdir(src)):
z.write(os.path.join(src, name), name)
PY
(cd "$repo/$output" && sha256sum "$id-$version.zip" > "$id-$version.zip.sha256")
cp "$build/plugin.json" "$repo/$output/plugin.json"
echo "zip=$zip"
echo "version=$version"