Compare commits
11 commits
claude/mm-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a3dfb00bc | |||
|
|
db5893413f | ||
|
|
f373105120 | ||
|
|
55dc989f85 | ||
| 23ef606727 | |||
|
|
ac2e89c057 | ||
|
|
32878d825e | ||
|
|
84155c0d18 | ||
|
|
4b90aa8348 | ||
|
|
a9c6938f0e | ||
|
|
dde4411958 |
9 changed files with 219 additions and 10 deletions
103
.forgejo/workflows/ci.yml
Normal file
103
.forgejo/workflows/ci.yml
Normal 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
|
||||||
|
|
@ -10,12 +10,12 @@
|
||||||
<Deterministic>true</Deterministic>
|
<Deterministic>true</Deterministic>
|
||||||
|
|
||||||
<!-- The plugin's own version; plugin.json names the same number. -->
|
<!-- 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
|
<!-- The plugin contract, compile-only, from the .nupkg in ./packages-local
|
||||||
(downloaded from the OpenAC release of the same version). The host
|
(downloaded from the OpenAC release of the same version). The host
|
||||||
supplies the contract assembly at runtime. -->
|
supplies the contract assembly at runtime. -->
|
||||||
<PluginApiPackageVersion>0.1.19</PluginApiPackageVersion>
|
<PluginApiPackageVersion>0.1.22</PluginApiPackageVersion>
|
||||||
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
|
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -12,12 +12,12 @@ byte-for-byte what the DECAL plugin sent and accepted; the golden tests under
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
The plugin contract is `AcDream.Plugin.Abstractions` of the version named in
|
The plugin contract is `AcDream.Plugin.Abstractions` of the version named in
|
||||||
`Directory.Build.props` (`PluginApiPackageVersion`), currently 0.1.19, the
|
`Directory.Build.props` (`PluginApiPackageVersion`), currently 0.1.22, the
|
||||||
OpenAC 0.1.19 release. Put that release's package in `packages-local/`; an
|
OpenAC 0.1.22 release. Put that release's package in `packages-local/`; an
|
||||||
OpenAC client from 0.1.19 on loads the plugin.
|
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 build OpenAC.MosswartMassacre.slnx
|
||||||
dotnet test 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/
|
/mm ws url ws://localhost:18765/websocket/
|
||||||
dotnet run --project tools/FrameRecorder -- compare decal.jsonl port.jsonl
|
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.
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
|
||||||
private readonly MmContext _context;
|
private readonly MmContext _context;
|
||||||
private readonly PeerTable _peers = new();
|
private readonly PeerTable _peers = new();
|
||||||
private IDisposable? _timer;
|
private IDisposable? _timer;
|
||||||
|
private IDisposable? _peerSubscription;
|
||||||
|
|
||||||
private int _lastHealth = -1, _lastMaxHealth = -1;
|
private int _lastHealth = -1, _lastMaxHealth = -1;
|
||||||
private int _lastStamina = -1, _lastMaxStamina = -1;
|
private int _lastStamina = -1, _lastMaxStamina = -1;
|
||||||
|
|
@ -103,6 +104,8 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
|
||||||
if (IsActive)
|
if (IsActive)
|
||||||
return;
|
return;
|
||||||
IsActive = true;
|
IsActive = true;
|
||||||
|
_peerSubscription = _context.Automation.Network.Subscribe(
|
||||||
|
PluginPeerCapabilities.ClientState | PluginPeerCapabilities.Casts);
|
||||||
ResetOutgoingState();
|
ResetOutgoingState();
|
||||||
_timer = _context.Scheduler.Every(TickInterval, OnTick);
|
_timer = _context.Scheduler.Every(TickInterval, OnTick);
|
||||||
_context.Send(new
|
_context.Send(new
|
||||||
|
|
@ -121,6 +124,8 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
|
||||||
if (!IsActive)
|
if (!IsActive)
|
||||||
return;
|
return;
|
||||||
IsActive = false;
|
IsActive = false;
|
||||||
|
_peerSubscription?.Dispose();
|
||||||
|
_peerSubscription = null;
|
||||||
_timer?.Dispose();
|
_timer?.Dispose();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
_context.Send(new
|
_context.Send(new
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
{
|
{
|
||||||
"id": "openac.mosswartmassacre",
|
"id": "openac.mosswartmassacre",
|
||||||
"displayName": "Mosswart Massacre",
|
"displayName": "Mosswart Massacre",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"entryDll": "OpenAC.MosswartMassacre.dll",
|
"entryDll": "OpenAC.MosswartMassacre.dll",
|
||||||
"apiVersion": 1,
|
"apiVersion": 1,
|
||||||
"kinds": ["Gameplay"],
|
"kinds": ["Gameplay"],
|
||||||
"hosts": ["Graphical", "Headless"],
|
"hosts": ["Graphical", "Headless"],
|
||||||
"minHostVersion": "0.1.19",
|
"minHostVersion": "0.1.22",
|
||||||
"capabilitiesVersion": 1,
|
"capabilitiesVersion": 1,
|
||||||
"capabilities": [
|
"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": "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 the backend, and runs commands typed into the Overlord chat box." }
|
{ "name": "chat", "note": "Reads chat for kills, rares and combat stats, forwards it to Overlord, and runs commands sent from its chat box." }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ internal sealed class TestSocketServer : IDisposable
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private readonly Task _accept;
|
private readonly Task _accept;
|
||||||
private WebSocket? _current;
|
private WebSocket? _current;
|
||||||
|
private HttpListenerContext? _currentContext;
|
||||||
|
|
||||||
public TestSocketServer()
|
public TestSocketServer()
|
||||||
{
|
{
|
||||||
|
|
@ -53,7 +54,12 @@ internal sealed class TestSocketServer : IDisposable
|
||||||
{
|
{
|
||||||
WebSocket socket = await WaitForSocketAsync();
|
WebSocket socket = await WaitForSocketAsync();
|
||||||
socket.Abort();
|
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;
|
_current = null;
|
||||||
|
_currentContext = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string NextFrame(int timeoutMs = 5000)
|
public string NextFrame(int timeoutMs = 5000)
|
||||||
|
|
@ -111,6 +117,7 @@ internal sealed class TestSocketServer : IDisposable
|
||||||
Secrets.Enqueue(context.Request.Headers["X-Plugin-Secret"]);
|
Secrets.Enqueue(context.Request.Headers["X-Plugin-Secret"]);
|
||||||
HttpListenerWebSocketContext ws = await context.AcceptWebSocketAsync(null);
|
HttpListenerWebSocketContext ws = await context.AcceptWebSocketAsync(null);
|
||||||
_current = ws.WebSocket;
|
_current = ws.WebSocket;
|
||||||
|
_currentContext = context;
|
||||||
_ = Task.Run(() => ReadLoopAsync(ws.WebSocket));
|
_ = Task.Run(() => ReadLoopAsync(ws.WebSocket));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ namespace OpenAC.MosswartMassacre.Tests.Fakes;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FakeNetwork : INetworkAutomation
|
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 bool IsAvailable { get; set; } = true;
|
||||||
|
|
||||||
public List<PluginNetworkClient> ImportedClients { get; } = [];
|
public List<PluginNetworkClient> ImportedClients { get; } = [];
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,21 @@ public sealed class VitalSharingTests
|
||||||
|
|
||||||
private static VitalSharing Feature(PluginHarness harness) => harness.Feature<VitalSharing>();
|
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]
|
[Fact]
|
||||||
public void Subscribe_on_start_carries_the_player_id_and_tags()
|
public void Subscribe_on_start_carries_the_player_id_and_tags()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
63
tools/build-release-zip.sh
Executable file
63
tools/build-release-zip.sh
Executable 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue