Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ nearest aetheryte, and you add a name, a description, a screenshot and some tags
it: world visit or DC transfer via Lifestream, teleport to the nearest aetheryte, then walks the last
stretch with vnavmesh if you have it. An on-screen arrow points the rest of the way.

**Dress the place.** If you have built the spot out in
[Stagehand](https://github.com/UniversalConquistador/Stagehand), attach that stage to your beacon and
guests can see your camp the way you built it rather than an empty field. Loading one is always the
guest's choice: Beacon offers, remembers the answer per beacon, and creates it as a *temporary* stage,
so nothing is ever written into anybody's own Stagehand library.

**Light the beacon** when you are there and open to roleplay. The atlas shows, live, who is out there
right now and how long they have said they will be. Mark a beacon as a public commons and anyone
standing there can light it, so a place stays alive whether or not its keeper is online.
Expand Down Expand Up @@ -123,6 +129,7 @@ write outside the repo unless you asked it to.
|---|---|---|
| [Lifestream](https://github.com/NightmareXIV/Lifestream) | **Yes**, for travel | World visits, DC transfers, aetheryte teleports. Beacon does not reimplement any of it. |
| [vnavmesh](https://github.com/awgil/ffxiv_navmesh) | Optional | Walks the last stretch from the aetheryte to the beacon. Without it you arrive at the aetheryte and the overlay points the way. |
| [Stagehand](https://github.com/UniversalConquistador/Stagehand) | Optional | Shares and loads the scenery a keeper has placed at their beacon. Beacon talks to it over Dalamud IPC and does not link its libraries. |

Everything else (browsing, lighting, screenshots) works with neither installed.

Expand All @@ -137,6 +144,8 @@ Everything else (browsing, lighting, screenshots) works with neither installed.
| `/beacon light` | Light the nearest beacon you may light. |
| `/beacon out` | Put out the beacon you lit. |
| `/beacon go <code>` | Open a beacon by its share code. |
| `/beacon currently <line>` | Say what your character is doing right now. |
| `/beacon ic` / `/beacon ooc` | Mark yourself in or out of character. |
| `/beacon settings` | Open the settings. |

---
Expand Down Expand Up @@ -169,6 +178,20 @@ A few decisions worth knowing about before changing things:
decoded pixel count, not file size, because that is what actually stops a decompression bomb.
- **Screenshots are stored as WebP**, with on-demand PNG transcoding for machines whose imaging stack
cannot decode WebP. The plugin probes once and asks for whichever it can read.
- **A stage is never loaded without being asked for.** Someone else's scenery appearing on your
screen is a decision, not a detail, so the first encounter with a beacon's stage is always a
question. Answers are remembered per beacon, and every stage is created as a Stagehand *temporary*
stage that leaves nothing behind.
- **Stages are stored as opaque documents.** The server reads only what a guest needs in order to
decide -- its name, its weight, the zone it was built for -- and refuses the two shapes that cannot
work: stages over the size cap, and stages whose modpacks load files from the author's own disk.
Understanding the format any more deeply than that would tie the server's release cycle to a game
plugin's.
- **The live line keeps its own timestamp.** A card's "currently" is the one field that is expected
to be wrong if it is not maintained, so it records when it was written, the card shows that age, and
saving a biography does not make a month-old line look freshly typed. It is also set from a command
rather than only an editor, because a field that takes three clicks to change is set once and never
again.
- **The realtime hub drops events for slow clients** rather than buffering without limit. Losing a
stale flame update for one bad connection is fine; stalling every other client is not.

Expand Down
9 changes: 7 additions & 2 deletions deploy/backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ EOF
[ "$(sqlite3 "$work/beacon.db" "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = '__EFMigrationsHistory';")" = "1" ]
[ "$(sqlite3 "$work/beacon.db" 'SELECT COUNT(*) FROM __EFMigrationsHistory;')" -gt 0 ]

mkdir -p "$work/images"
mkdir -p "$work/images" "$work/stages"
if [ -d /data/images ]; then
cp -a /data/images/. "$work/images/"
fi

tar -C "$work" -czf "$partial" beacon.db images
# Stage definitions live beside the images and are just as unrecoverable if they are missed.
if [ -d /data/stages ]; then
cp -a /data/stages/. "$work/stages/"
fi

tar -C "$work" -czf "$partial" beacon.db images stages
mv "$partial" "$final"
sha256sum "$final" | sed 's# /backups/# #' > "${final}.sha256"

Expand Down
81 changes: 78 additions & 3 deletions src/Beacon.Plugin/Plugin.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Beacon.Services;
using Beacon.Shared.Beacons;
using Beacon.Shared.Profiles;
using Beacon.Windows;
using Dalamud.Game.Command;
using Dalamud.Interface.Windowing;
Expand Down Expand Up @@ -37,6 +38,10 @@ public sealed class Plugin : IDalamudPlugin

private readonly ProfileService profiles;

private readonly StagehandIpc stagehand;

private readonly StageDressing stages;

private readonly WindowSystem windows = new("Beacon");

private readonly AtlasWindow atlasWindow;
Expand Down Expand Up @@ -64,19 +69,22 @@ public Plugin(IDalamudPluginInterface pluginInterface)
hub = new BeaconHubClient(config);
atlas = new AtlasService(config, api, location, hub, images, notifications);
travel = new TravelService(config, location);
screenshots = new ScreenshotService();
screenshots = new ScreenshotService(config);
dtr = new DtrService(config, atlas, travel);
profiles = new ProfileService(config, api, location, atlas, notifications);
stagehand = new StagehandIpc();
stages = new StageDressing(config, api, stagehand, location);

editorWindow = new BeaconEditorWindow(config, atlas, location, screenshots, images, notifications);
settingsWindow = new SettingsWindow(config, api, atlas, hub, travel);
editorWindow = new BeaconEditorWindow(config, atlas, location, screenshots, images, notifications, stages);
settingsWindow = new SettingsWindow(config, api, atlas, hub, travel, stages);
atlasWindow = new AtlasWindow(
config,
atlas,
travel,
location,
images,
notifications,
stages,
editorWindow.Open,
() => settingsWindow.IsOpen = true,
OpenChronicle,
Expand Down Expand Up @@ -119,6 +127,8 @@ public Plugin(IDalamudPluginInterface pluginInterface)
+ "/beacon light - light your nearest beacon\n"
+ "/beacon out - put out the beacon you lit\n"
+ "/beacon go <code> - open a beacon by its share code\n"
+ "/beacon currently <line> - say what your character is doing right now\n"
+ "/beacon ic | /beacon ooc - mark yourself in or out of character\n"
+ "/beacon settings - open the settings",
});

Expand All @@ -135,18 +145,24 @@ public Plugin(IDalamudPluginInterface pluginInterface)
{
atlas.Start();
profiles.Start();
stages.Start();
}

Svc.Log.Information("Beacon loaded.");
}

private void Draw()
{
// Recorded here because the main viewport's id can only be read inside a draw callback, and
// a photograph can be triggered from a command or a background continuation.
Beacon.UI.Viewport.Record();

windows.Draw();

// The file dialog lives outside the window system and must be drawn every frame, even when
// the editor that opened it has since been closed.
screenshots.Draw();
stages.Draw();
}

private void OnFrameworkUpdate(Dalamud.Plugin.Services.IFramework framework)
Expand All @@ -155,6 +171,7 @@ private void OnFrameworkUpdate(Dalamud.Plugin.Services.IFramework framework)
{
atlas.Tick();
profiles.Tick();
stages.Tick(atlas.Beacons);
travel.Tick();
dtr.Tick();
}
Expand Down Expand Up @@ -209,6 +226,18 @@ private void OnCommand(string command, string arguments)
OpenChronicle();
break;

case "currently" or "doing":
SetCurrently(rest);
break;

case "ic":
SetStance(RpStance.InCharacter);
break;

case "ooc":
SetStance(RpStance.OutOfCharacter);
break;

case "settings" or "config":
OpenSettings();
break;
Expand All @@ -219,6 +248,51 @@ private void OnCommand(string command, string arguments)
}
}

/// <summary>
/// Rewrites the live line on the card of the character you are logged in as.
///
/// A command rather than only a window, because the whole value of a "currently" is that changing
/// it costs nothing. If it takes three clicks it is written once and never again, which is worse
/// than not having the field.
/// </summary>
private void SetCurrently(string text)
{
if (profiles.MyProfile is not { } profile)
{
notifications.Error("Write a card for this character first, with /beacon me.");
return;
}

var line = text.Trim();

if (line.Length > ProfileLimits.CurrentlyMaxLength)
line = line[..ProfileLimits.CurrentlyMaxLength];

if (line.Length == 0)
{
profiles.SetCurrently(profile, null);
notifications.Toast("Cleared what you are currently doing.", null);
return;
}

profiles.SetCurrently(profile, line);
notifications.Toast("Noted.", line);
}

private void SetStance(RpStance stance)
{
if (profiles.MyProfile is not { } profile)
{
notifications.Error("Write a card for this character first, with /beacon me.");
return;
}

profiles.SetCurrently(profile, profile.Moment.Currently, stance);
notifications.Toast(
stance is RpStance.InCharacter ? "Marked in character." : "Marked out of character.",
null);
}

/// <summary>
/// Lights whichever of your beacons you are standing at.
///
Expand Down Expand Up @@ -353,6 +427,7 @@ public void Dispose()
windows.RemoveAllWindows();

dtr.Dispose();
stages.Dispose();
profiles.Dispose();
profileEditorWindow.Dispose();
screenshots.Dispose();
Expand Down
45 changes: 45 additions & 0 deletions src/Beacon.Plugin/Services/AtlasService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,51 @@ public void Update(Guid id, UpdateBeaconRequest request, byte[]? screenshot, str
}, lifetime.Token);
}

/// <summary>
/// Publishes a Stagehand stage for a beacon, so guests can see the place as its keeper dressed it.
/// The definition is sent exactly as it sits on disk.
/// </summary>
public void AttachStage(Guid id, byte[] definition)
{
_ = Task.Run(async () =>
{
var result = await api.UploadStageAsync(id, definition, lifetime.Token);

Post(() =>
{
if (!result.Ok)
{
LastError = result.Error;
return;
}

notifications.Toast("Stage published.", null);
Refresh(quiet: true);
RefreshMine();
});
}, lifetime.Token);
}

public void RemoveStage(Guid id)
{
_ = Task.Run(async () =>
{
var result = await api.DeleteStageAsync(id, lifetime.Token);

Post(() =>
{
if (!result.Ok)
{
LastError = result.Error;
return;
}

Refresh(quiet: true);
RefreshMine();
});
}, lifetime.Token);
}

public void Delete(BeaconDto beacon)
{
_ = Task.Run(async () =>
Expand Down
68 changes: 68 additions & 0 deletions src/Beacon.Plugin/Services/BeaconApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,19 @@ public Task<ApiResult<ProfileDto>> SetAvailabilityAsync(Guid id, AvailabilityOve
true,
ct);

/// <summary>Rewrites the live line. Its own call so it can be driven from a chat command.</summary>
public Task<ApiResult<ProfileDto>> SetCurrentlyAsync(
Guid id,
string? currently,
RpStance? stance,
CancellationToken ct) =>
SendAsync<ProfileDto>(
HttpMethod.Put,
ApiRoutes.Profiles.Currently(id),
new SetCurrentlyRequest { Currently = currently, Stance = stance },
true,
ct);

public Task<ApiResult<ProfileDto>> UploadPortraitAsync(Guid id, byte[] bytes, string fileName, CancellationToken ct) =>
UploadAsync<ProfileDto>(ApiRoutes.Profiles.Images(id), bytes, fileName, ct);

Expand Down Expand Up @@ -244,6 +257,61 @@ private async Task<ApiResult<T>> UploadAsync<T>(string route, byte[] bytes, stri
public Task<ApiResult<bool>> DeleteImageAsync(Guid beaconId, CancellationToken ct) =>
SendAsync<bool>(HttpMethod.Delete, ApiRoutes.Beacons.Image(beaconId), null, true, ct);

// --- Stages ---------------------------------------------------------

/// <summary>
/// Publishes a Stagehand stage for a beacon. The definition is sent as it sits on disk: Beacon
/// does not rewrite somebody's stage, it only carries it.
/// </summary>
public async Task<ApiResult<BeaconStageInfo>> UploadStageAsync(Guid beaconId, byte[] definition, CancellationToken ct)
{
try
{
using var content = new ByteArrayContent(definition);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

using var request = new HttpRequestMessage(HttpMethod.Put, Url(ApiRoutes.Beacons.Stage(beaconId)))
{
Content = content,
};
ApplyHeaders(request, authenticated: true);

using var response = await http.SendAsync(request, ct);
return await ReadAsync<BeaconStageInfo>(response, ct);
}
catch (Exception ex)
{
return Unreachable<BeaconStageInfo>(ex);
}
}

public Task<ApiResult<bool>> DeleteStageAsync(Guid beaconId, CancellationToken ct) =>
SendAsync<bool>(HttpMethod.Delete, ApiRoutes.Beacons.Stage(beaconId), null, true, ct);

/// <summary>
/// Downloads a beacon's stage definition. Returns the raw document, because it is Stagehand's
/// format and not Beacon's to interpret.
/// </summary>
public async Task<string?> DownloadStageAsync(Guid beaconId, CancellationToken ct)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, Url(ApiRoutes.Beacons.Stage(beaconId)));
ApplyHeaders(request, authenticated: true);

using var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
return null;

return await response.Content.ReadAsStringAsync(ct);
}
catch (Exception ex)
{
Svc.Log.Debug(ex, "Could not download the stage for beacon {BeaconId}.", beaconId);
return null;
}
}

/// <summary>
/// Downloads image bytes for the texture cache. Null on any failure; the caller draws a placeholder.
/// Screenshots are stored as WebP, so <paramref name="preferPng"/> asks the server to transcode for
Expand Down
Loading
Loading