From 13f55441cd8de291f1cda53714c34572cf02ab39 Mon Sep 17 00:00:00 2001 From: Shaala <156393271+ShaalaXIV@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:20:00 -0500 Subject: [PATCH] Add Stagehand stages, fix viewport capture, extend the Chronicle Four changes, all additive to the wire contract so the protocol version is unchanged. Stagehand stages on beacons --------------------------- A keeper can attach the Stagehand stage they dressed a place with, so guests see the camp as it was built rather than an empty field. Beacon talks to Stagehand over Dalamud IPC by channel name rather than linking its API library. Every call it needs takes and returns primitives, so this costs nothing in capability and keeps a licence boundary between the two projects instead of pulling one into the other. The guest decides. Nothing loads silently the first time: the beacon page offers the stage, remembers the answer for that beacon, and only skips the question for beacons already agreed to or when auto-loading has been turned on for everything (off by default). Every stage is created as a Stagehand *temporary* stage, so nothing is written into a guest's own library and refusing later leaves nothing behind. The server stores a stage as an opaque document. It reads only what a guest needs in order to decide -- name, weight, intended zone -- and refuses the two shapes that cannot work for anybody but their author: over the size cap, and modpacks that load files from the author's own disk. Understanding the format more deeply than that would tie the server's release cycle to a game plugin's. Stage definitions live beside the images under the data directory, and the backup sidecar now captures them. Photographs ----------- Capture never worked: ImGuiViewportTextureArgs.ViewportId was left at zero, and the main viewport's id is a hash that can only be read inside a draw callback. Uv0/Uv1 were also unset, which describes an empty rectangle. The id is now recorded once a frame and passed at the point of use. The game's interface is hidden while the shutter is open and given time to finish fading, so a photograph is of the scene rather than the HUD. Both are configurable. The Chronicle ------------- The part of a card that is true today rather than true in general: - "Currently", the live in-character line, carrying its own timestamp so the card can show its age. Saving a biography no longer makes a month-old line look freshly written. - An in-character / out-of-character flag. - An out-of-character note: the player speaking as themselves. - "At first glance": five label-and-line slots for what a stranger notices before a word is exchanged. The live line has its own route and its own commands, /beacon currently, /beacon ic and /beacon ooc. A field that takes three clicks to change is written once and never again, which is worse than not having it. Example content --------------- Removed the placeholder names, epithets, quotes and boundaries from the profile and beacon editors. The hints now describe their field instead of filling it. Co-Authored-By: Claude Opus 5 --- README.md | 23 + deploy/backup.sh | 9 +- src/Beacon.Plugin/Plugin.cs | 81 +- src/Beacon.Plugin/Services/AtlasService.cs | 45 + src/Beacon.Plugin/Services/BeaconApi.cs | 68 ++ src/Beacon.Plugin/Services/Configuration.cs | 28 + src/Beacon.Plugin/Services/ProfileService.cs | 18 + .../Services/ScreenshotService.cs | 59 +- src/Beacon.Plugin/Services/StageDressing.cs | 345 +++++++ src/Beacon.Plugin/Services/StagehandIpc.cs | 66 ++ src/Beacon.Plugin/UI/Ornament.cs | 8 + src/Beacon.Plugin/UI/Viewport.cs | 18 + src/Beacon.Plugin/Windows/AtlasWindow.cs | 83 ++ .../Windows/BeaconEditorWindow.cs | 114 ++- src/Beacon.Plugin/Windows/ChronicleWindow.cs | 70 ++ .../Windows/ProfileEditorWindow.cs | 163 +++- src/Beacon.Plugin/Windows/SettingsWindow.cs | 34 +- src/Beacon.Server/BeaconOptions.cs | 3 + src/Beacon.Server/Data/BeaconDbContext.cs | 23 + src/Beacon.Server/Data/BeaconMapper.cs | 21 +- .../Data/Entities/BeaconStageEntity.cs | 34 + .../Data/Entities/ProfileEntity.cs | 19 + .../Data/Entities/ProfileGlanceEntity.cs | 22 + ...20260922230017_AddBeaconStages.Designer.cs | 818 ++++++++++++++++ .../20260922230017_AddBeaconStages.cs | 42 + ...60922231111_AddChronicleMoment.Designer.cs | 874 ++++++++++++++++++ .../20260922231111_AddChronicleMoment.cs | 91 ++ .../BeaconDbContextModelSnapshot.cs | 100 ++ src/Beacon.Server/Data/ProfileMapper.cs | 11 + src/Beacon.Server/Endpoints/ImageEndpoints.cs | 4 +- .../Endpoints/ProfileEndpoints.cs | 12 + src/Beacon.Server/Endpoints/StageEndpoints.cs | 138 +++ src/Beacon.Server/Program.cs | 2 + src/Beacon.Server/Services/BeaconService.cs | 12 +- src/Beacon.Server/Services/ProfileService.cs | 74 +- src/Beacon.Server/Services/StageService.cs | 195 ++++ src/Beacon.Shared/ApiRoutes.cs | 6 + src/Beacon.Shared/Beacons/BeaconDto.cs | 9 + src/Beacon.Shared/Beacons/BeaconLimits.cs | 12 + src/Beacon.Shared/Beacons/BeaconStage.cs | 38 + src/Beacon.Shared/Profiles/ProfileDto.cs | 62 +- src/Beacon.Shared/Profiles/ProfileEnums.cs | 17 + src/Beacon.Shared/Profiles/ProfileLimits.cs | 28 + src/Beacon.Shared/Profiles/ProfileRequests.cs | 27 + 44 files changed, 3903 insertions(+), 23 deletions(-) create mode 100644 src/Beacon.Plugin/Services/StageDressing.cs create mode 100644 src/Beacon.Plugin/Services/StagehandIpc.cs create mode 100644 src/Beacon.Plugin/UI/Viewport.cs create mode 100644 src/Beacon.Server/Data/Entities/BeaconStageEntity.cs create mode 100644 src/Beacon.Server/Data/Entities/ProfileGlanceEntity.cs create mode 100644 src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.Designer.cs create mode 100644 src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.cs create mode 100644 src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.Designer.cs create mode 100644 src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.cs create mode 100644 src/Beacon.Server/Endpoints/StageEndpoints.cs create mode 100644 src/Beacon.Server/Services/StageService.cs create mode 100644 src/Beacon.Shared/Beacons/BeaconStage.cs diff --git a/README.md b/README.md index 0be5922..1691a74 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 ` | Open a beacon by its share code. | +| `/beacon currently ` | Say what your character is doing right now. | +| `/beacon ic` / `/beacon ooc` | Mark yourself in or out of character. | | `/beacon settings` | Open the settings. | --- @@ -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. diff --git a/deploy/backup.sh b/deploy/backup.sh index a5a0964..a8fb195 100644 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -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" diff --git a/src/Beacon.Plugin/Plugin.cs b/src/Beacon.Plugin/Plugin.cs index d14d7da..164450c 100644 --- a/src/Beacon.Plugin/Plugin.cs +++ b/src/Beacon.Plugin/Plugin.cs @@ -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; @@ -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; @@ -64,12 +69,14 @@ 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, @@ -77,6 +84,7 @@ public Plugin(IDalamudPluginInterface pluginInterface) location, images, notifications, + stages, editorWindow.Open, () => settingsWindow.IsOpen = true, OpenChronicle, @@ -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 - open a beacon by its share code\n" + + "/beacon currently - 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", }); @@ -135,6 +145,7 @@ public Plugin(IDalamudPluginInterface pluginInterface) { atlas.Start(); profiles.Start(); + stages.Start(); } Svc.Log.Information("Beacon loaded."); @@ -142,11 +153,16 @@ public Plugin(IDalamudPluginInterface pluginInterface) 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) @@ -155,6 +171,7 @@ private void OnFrameworkUpdate(Dalamud.Plugin.Services.IFramework framework) { atlas.Tick(); profiles.Tick(); + stages.Tick(atlas.Beacons); travel.Tick(); dtr.Tick(); } @@ -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; @@ -219,6 +248,51 @@ private void OnCommand(string command, string arguments) } } + /// + /// 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. + /// + 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); + } + /// /// Lights whichever of your beacons you are standing at. /// @@ -353,6 +427,7 @@ public void Dispose() windows.RemoveAllWindows(); dtr.Dispose(); + stages.Dispose(); profiles.Dispose(); profileEditorWindow.Dispose(); screenshots.Dispose(); diff --git a/src/Beacon.Plugin/Services/AtlasService.cs b/src/Beacon.Plugin/Services/AtlasService.cs index f1caa49..a6ab729 100644 --- a/src/Beacon.Plugin/Services/AtlasService.cs +++ b/src/Beacon.Plugin/Services/AtlasService.cs @@ -424,6 +424,51 @@ public void Update(Guid id, UpdateBeaconRequest request, byte[]? screenshot, str }, lifetime.Token); } + /// + /// 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. + /// + 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 () => diff --git a/src/Beacon.Plugin/Services/BeaconApi.cs b/src/Beacon.Plugin/Services/BeaconApi.cs index ffebdc4..880c0af 100644 --- a/src/Beacon.Plugin/Services/BeaconApi.cs +++ b/src/Beacon.Plugin/Services/BeaconApi.cs @@ -177,6 +177,19 @@ public Task> SetAvailabilityAsync(Guid id, AvailabilityOve true, ct); + /// Rewrites the live line. Its own call so it can be driven from a chat command. + public Task> SetCurrentlyAsync( + Guid id, + string? currently, + RpStance? stance, + CancellationToken ct) => + SendAsync( + HttpMethod.Put, + ApiRoutes.Profiles.Currently(id), + new SetCurrentlyRequest { Currently = currently, Stance = stance }, + true, + ct); + public Task> UploadPortraitAsync(Guid id, byte[] bytes, string fileName, CancellationToken ct) => UploadAsync(ApiRoutes.Profiles.Images(id), bytes, fileName, ct); @@ -244,6 +257,61 @@ private async Task> UploadAsync(string route, byte[] bytes, stri public Task> DeleteImageAsync(Guid beaconId, CancellationToken ct) => SendAsync(HttpMethod.Delete, ApiRoutes.Beacons.Image(beaconId), null, true, ct); + // --- Stages --------------------------------------------------------- + + /// + /// 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. + /// + public async Task> 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(response, ct); + } + catch (Exception ex) + { + return Unreachable(ex); + } + } + + public Task> DeleteStageAsync(Guid beaconId, CancellationToken ct) => + SendAsync(HttpMethod.Delete, ApiRoutes.Beacons.Stage(beaconId), null, true, ct); + + /// + /// Downloads a beacon's stage definition. Returns the raw document, because it is Stagehand's + /// format and not Beacon's to interpret. + /// + public async Task 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; + } + } + /// /// Downloads image bytes for the texture cache. Null on any failure; the caller draws a placeholder. /// Screenshots are stored as WebP, so asks the server to transcode for diff --git a/src/Beacon.Plugin/Services/Configuration.cs b/src/Beacon.Plugin/Services/Configuration.cs index 021a7f0..1eef75e 100644 --- a/src/Beacon.Plugin/Services/Configuration.cs +++ b/src/Beacon.Plugin/Services/Configuration.cs @@ -103,6 +103,34 @@ public sealed class Configuration : IPluginConfiguration public bool LastLitOnlyFilter { get; set; } + // --- Stages --------------------------------------------------------- + + /// + /// Load a beacon's stage on arrival without asking first. + /// + /// Off by default on purpose. A stage is another player's scenery appearing on your screen, so the + /// first time is always a question. Turn this on once you would rather see every place as its + /// keeper built it. + /// + public bool AutoLoadStages { get; set; } + + /// Beacons whose stage you have already agreed to. Loaded on arrival without asking again. + public HashSet TrustedStageBeacons { get; set; } = []; + + /// Beacons whose stage you have declined. Never offered again. + public HashSet RefusedStageBeacons { get; set; } = []; + + // --- Photographs ---------------------------------------------------- + + /// Hide the game's interface while the shutter is open, so a photo is of the scene, not the HUD. + public bool HideHudForPhoto { get; set; } = true; + + /// + /// How long to wait after hiding the interface before grabbing the frame. The HUD fades rather + /// than vanishing, so a photo taken immediately catches it half-way out. + /// + public int PhotoSettleMs { get; set; } = 250; + /// True once the first-run flow has been completed or dismissed. public bool OnboardingComplete { get; set; } diff --git a/src/Beacon.Plugin/Services/ProfileService.cs b/src/Beacon.Plugin/Services/ProfileService.cs index 1a49f02..cbd9172 100644 --- a/src/Beacon.Plugin/Services/ProfileService.cs +++ b/src/Beacon.Plugin/Services/ProfileService.cs @@ -352,6 +352,24 @@ public void SetAvailability(ProfileDto profile, AvailabilityOverride availabilit }); }, lifetime.Token); + /// + /// Rewrites the live line on a card. Used by the card itself and by /beacon currently, + /// which is the whole point: a line you have to open an editor to change is a line nobody changes. + /// + public void SetCurrently(ProfileDto profile, string? currently, RpStance? stance = null) => + _ = Task.Run(async () => + { + var result = await api.SetCurrentlyAsync(profile.Id, currently, stance, lifetime.Token); + + Post(() => + { + if (result.Ok && result.Value is { } updated) + Upsert(updated); + else + LastError = result.Error; + }); + }, lifetime.Token); + public void RemoveImage(ProfileDto profile, Guid imageId) => _ = Task.Run(async () => { diff --git a/src/Beacon.Plugin/Services/ScreenshotService.cs b/src/Beacon.Plugin/Services/ScreenshotService.cs index 0805e6e..d544e74 100644 --- a/src/Beacon.Plugin/Services/ScreenshotService.cs +++ b/src/Beacon.Plugin/Services/ScreenshotService.cs @@ -1,8 +1,10 @@ using System.Numerics; +using Beacon.UI; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Beacon.Shared.Beacons; +using FFXIVClientStructs.FFXIV.Client.UI; namespace Beacon.Services; @@ -13,7 +15,7 @@ namespace Beacon.Services; /// Both matter. The capture button is what makes posting a beacon a ten-second job; the file picker /// is what lets somebody who cares about their screenshots post the good one. /// -public sealed class ScreenshotService : IDisposable +public sealed class ScreenshotService(Configuration config) : IDisposable { private readonly FileDialogManager dialogs = new(); @@ -40,14 +42,41 @@ public sealed class ScreenshotService : IDisposable Capturing = true; LastError = null; + var hidHud = false; + try { + if (Viewport.MainId is 0) + { + // Capture can be triggered from a command before a single frame has been drawn. + LastError = "Open a Beacon window once before taking a photograph."; + return null; + } + + if (config.HideHudForPhoto) + hidHud = await Svc.Framework.RunOnFrameworkThread(() => Hud.Set(false)); + + // The HUD fades rather than vanishing, so a frame grabbed immediately still contains it. + if (config.PhotoSettleMs > 0) + await Svc.Framework.RunOnTick( + () => true, + TimeSpan.FromMilliseconds(Math.Clamp(config.PhotoSettleMs, 0, 3000)), + cancellationToken: ct); + var args = new ImGuiViewportTextureArgs { + // Without this the call has no viewport to read: the main viewport's id is a hash, + // not zero, and it can only be read from inside a draw callback. + ViewportId = Viewport.MainId, + // Grab the frame before ImGui draws, so the atlas window is not in the picture. TakeBeforeImGuiRender = true, KeepTransparency = false, AutoUpdate = false, + + // The whole screen. Left at their defaults these describe an empty rectangle. + Uv0 = Vector2.Zero, + Uv1 = Vector2.One, }; using var texture = await Svc.Textures.CreateFromImGuiViewportAsync(args, "Beacon capture", ct); @@ -91,6 +120,9 @@ await Svc.TextureReadback.SaveToStreamAsync( } finally { + if (hidHud) + await Svc.Framework.RunOnFrameworkThread(() => Hud.Set(true)); + Capturing = false; } } @@ -256,3 +288,28 @@ await Svc.TextureReadback.SaveToStreamAsync( public void Dispose() => dialogs.Reset(); } + +/// +/// Showing and hiding the game's interface needs a pointer, which cannot live in a class that awaits. +/// +internal static unsafe class Hud +{ + /// Show or hide the game's interface. Returns whether this call actually changed it. + public static bool Set(bool visible) + { + try + { + var module = RaptureAtkModule.Instance(); + if (module is null || module->IsUiVisible == visible) + return false; + + module->SetUiVisibility(visible); + return true; + } + catch (Exception ex) + { + Svc.Log.Warning(ex, "Beacon: could not toggle the game interface."); + return false; + } + } +} diff --git a/src/Beacon.Plugin/Services/StageDressing.cs b/src/Beacon.Plugin/Services/StageDressing.cs new file mode 100644 index 0000000..b535656 --- /dev/null +++ b/src/Beacon.Plugin/Services/StageDressing.cs @@ -0,0 +1,345 @@ +using System.Text.Json; +using Beacon.Shared.Beacons; +using Dalamud.Interface.ImGuiFileDialog; + +namespace Beacon.Services; + +/// What Beacon is currently doing about a beacon's stage. +public enum StageState +{ + /// No stage, or Stagehand is not installed. + None, + + /// There is a stage and nobody has said yes or no to it yet. + Offered, + + Loading, + + Showing, + + /// Loaded once and since hidden, so showing it again costs nothing. + Hidden, + + Failed, +} + +/// +/// Loads the stage a keeper dressed their place with, for guests who run Stagehand. +/// +/// The rule this service exists to enforce is that the guest decides. A stage is somebody else's +/// content arriving on your screen, so Beacon never loads one silently the first time: it offers, +/// remembers the answer, and skips the question only for beacons you have already said yes to, or if +/// you have turned auto-loading on for everything. Every stage is created as a Stagehand temporary +/// stage, so nothing is written into the guest's own library and refusing later leaves no trace. +/// +public sealed class StageDressing( + Configuration config, + BeaconApi api, + StagehandIpc stagehand, + LocationService location) : IDisposable +{ + private readonly FileDialogManager dialogs = new(); + + /// Definitions already fetched this session, so walking in and out is not a download each time. + private readonly Dictionary cache = []; + + private readonly HashSet loading = []; + + /// The beacon whose stage is currently created inside Stagehand. At most one at a time. + private Guid? dressed; + + private bool visible; + + private Guid? failed; + + private string? lastError; + + /// True when a guest could load stages at all. + public bool Available => stagehand.Available; + + /// The last failure, for the beacon page to show inline. + public string? LastError => lastError; + + public void Start() => Svc.ClientState.TerritoryChanged += OnTerritoryChanged; + + /// Draws the stage file picker, if one is open. Must be called every frame from the UI callback. + public void Draw() => dialogs.Draw(); + + /// + /// Opens a picker over the keeper's own Stagehand library so they can choose the stage this place + /// is dressed with. The callback runs on the UI thread with the file's bytes and name. + /// + public void PickStage(Action onPicked) + { + lastError = null; + + dialogs.OpenFileDialog( + "Choose a stage", + "Stagehand stage{.json}", + (confirmed, paths) => + { + if (!confirmed || paths.Count == 0) + return; + + var path = paths[0]; + + try + { + var info = new FileInfo(path); + + if (!info.Exists) + { + lastError = "That file is no longer there."; + return; + } + + if (info.Length > BeaconLimits.MaxStageBytes) + { + lastError = $"That stage is larger than {BeaconLimits.MaxStageBytes / (1024 * 1024)} MB."; + return; + } + + onPicked(File.ReadAllBytes(path), Path.GetFileName(path)); + } + catch (Exception ex) + { + Svc.Log.Warning(ex, "Beacon: could not read the chosen stage."); + lastError = "Could not read that file."; + } + }, + selectionCountMax: 1, + startPath: LibraryFolder(), + isModal: true); + } + + /// + /// Where the keeper keeps their stages, read from Stagehand's own settings so the picker opens + /// somewhere useful. Best effort: a missing or changed config just means starting in Documents. + /// + public static string? LibraryFolder() + { + try + { + var config = Path.Combine( + Svc.PluginInterface.ConfigDirectory.Parent?.FullName ?? string.Empty, + "Stagehand.json"); + + if (File.Exists(config)) + { + using var document = JsonDocument.Parse(File.ReadAllText(config)); + if (document.RootElement.TryGetProperty("DefinitionLibraryPath", out var path) + && path.GetString() is { Length: > 0 } folder + && Directory.Exists(folder)) + return folder; + } + } + catch (Exception ex) + { + Svc.Log.Debug(ex, "Could not read Stagehand's library path."); + } + + var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return Directory.Exists(documents) ? documents : null; + } + + public StageState StateFor(BeaconDto beacon) + { + if (!beacon.HasStage || !stagehand.Available) + return StageState.None; + + if (loading.Contains(beacon.Id)) + return StageState.Loading; + + if (dressed == beacon.Id) + return visible ? StageState.Showing : StageState.Hidden; + + return failed == beacon.Id ? StageState.Failed : StageState.Offered; + } + + /// + /// Shows a beacon's stage, fetching the definition if this is the first time. Safe to call again + /// while it is already showing. + /// + public void Show(BeaconDto beacon, bool remember) + { + if (!beacon.HasStage || !stagehand.Available || loading.Contains(beacon.Id)) + return; + + if (remember) + { + config.TrustedStageBeacons.Add(beacon.Id); + config.RefusedStageBeacons.Remove(beacon.Id); + config.Save(); + } + + if (dressed == beacon.Id && visible) + return; + + lastError = null; + failed = null; + + if (cache.TryGetValue(beacon.Id, out var cached)) + { + Apply(beacon, cached); + return; + } + + loading.Add(beacon.Id); + + _ = Task.Run(async () => + { + var definition = await api.DownloadStageAsync(beacon.Id, CancellationToken.None); + + await Svc.Framework.RunOnFrameworkThread(() => + { + loading.Remove(beacon.Id); + + if (definition is null) + { + failed = beacon.Id; + lastError = "Could not fetch that stage from the server."; + return; + } + + cache[beacon.Id] = definition; + Apply(beacon, definition); + }); + }); + } + + /// Hides the stage without forgetting it, so showing it again is instant. + public void Hide() + { + if (dressed is not { } id || !visible) + return; + + stagehand.SetVisible(id, false); + visible = false; + } + + /// Tears the stage down entirely and records that this beacon's stage is not wanted. + public void Refuse(BeaconDto beacon) + { + config.RefusedStageBeacons.Add(beacon.Id); + config.TrustedStageBeacons.Remove(beacon.Id); + config.Save(); + + if (dressed == beacon.Id) + Clear(); + } + + /// + /// Called every tick. Loads the stage of a beacon the player is standing at, when they have + /// already agreed to that beacon or turned auto-loading on, and takes it down again when they walk + /// away. Nothing here loads a stage that has not been consented to. + /// + public void Tick(IReadOnlyList known) + { + if (!stagehand.Available) + return; + + var here = Nearest(known); + + if (here is null) + { + // Out of range of anything dressed: put it away rather than leave a camp standing in a + // field the player has already walked out of. + if (dressed is not null) + Clear(); + + return; + } + + if (dressed == here.Id) + { + // Stagehand hides every temporary stage on a location change, so re-show rather than + // assume it is still up. + if (!visible) + visible = stagehand.SetVisible(here.Id, true); + + return; + } + + if (config.RefusedStageBeacons.Contains(here.Id)) + return; + + if (config.AutoLoadStages || config.TrustedStageBeacons.Contains(here.Id)) + Show(here, remember: false); + } + + /// The nearest beacon in range that actually has a stage to load. + private BeaconDto? Nearest(IReadOnlyList known) + { + BeaconDto? best = null; + var bestDistance = float.MaxValue; + + foreach (var beacon in known) + { + if (!beacon.HasStage) + continue; + + if (location.DistanceTo(beacon) is not { } distance || distance > BeaconLimits.LightingRangeYalms) + continue; + + if (distance >= bestDistance) + continue; + + best = beacon; + bestDistance = distance; + } + + return best; + } + + private void Apply(BeaconDto beacon, string definition) + { + // Only one at a time: two camps' worth of scenery in one field is nobody's intent. + if (dressed is { } previous && previous != beacon.Id) + Clear(); + + var name = beacon.Stage?.Name is { Length: > 0 } stageName ? stageName : beacon.Name; + + if (!stagehand.CreateOrUpdate(beacon.Id, definition, $"Beacon: {name}")) + { + failed = beacon.Id; + lastError = "Stagehand could not read that stage. It may have been built by a newer version."; + return; + } + + dressed = beacon.Id; + visible = stagehand.SetVisible(beacon.Id, true); + + if (!visible) + { + failed = beacon.Id; + lastError = "Stagehand loaded that stage but would not show it."; + } + } + + private void Clear() + { + if (dressed is { } id) + stagehand.Destroy(id); + + dressed = null; + visible = false; + } + + /// + /// Stagehand hides temporary stages when the player's location changes. Beacon drops its stage + /// outright instead: the zone it was built for is the only place it belongs. + /// + private void OnTerritoryChanged(uint territory) + { + visible = false; + + if (dressed is not null) + Clear(); + } + + public void Dispose() + { + Svc.ClientState.TerritoryChanged -= OnTerritoryChanged; + Clear(); + } +} diff --git a/src/Beacon.Plugin/Services/StagehandIpc.cs b/src/Beacon.Plugin/Services/StagehandIpc.cs new file mode 100644 index 0000000..9861918 --- /dev/null +++ b/src/Beacon.Plugin/Services/StagehandIpc.cs @@ -0,0 +1,66 @@ +using Dalamud.Plugin.Ipc; + +namespace Beacon.Services; + +/// +/// Stagehand's public IPC, as Beacon uses it. +/// +/// Stagehand ships an API library for this, and Beacon deliberately does not link it. Every call it +/// needs takes and returns primitives, so the call gates can be subscribed to by name exactly as +/// Lifestream's and vnavmesh's are. That keeps Stagehand a genuinely optional dependency -- missing, +/// disabled, or a version with a changed signature all degrade into a disabled button -- and it keeps +/// a licence boundary between the two projects rather than pulling one into the other. +/// +/// Every stage Beacon loads is a *temporary* stage. Nothing is ever written to the guest's own +/// library, so accepting a stage is entirely undoable and leaves nothing behind. +/// +public sealed class StagehandIpc +{ + private const string PluginName = "Stagehand"; + + private readonly ICallGateSubscriber createOrUpdate = + Svc.PluginInterface.GetIpcSubscriber("Stagehand.TryCreateOrUpdateTemporaryStage"); + + private readonly ICallGateSubscriber setVisible = + Svc.PluginInterface.GetIpcSubscriber("Stagehand.TrySetTemporaryStageVisible"); + + private readonly ICallGateSubscriber destroy = + Svc.PluginInterface.GetIpcSubscriber("Stagehand.TryDestroyTemporaryStage"); + + /// True when Stagehand is installed and loaded. + public bool Available => LifestreamIpc.IsPluginLoaded(PluginName); + + /// + /// Hands a stage definition to Stagehand under an id of Beacon's own. The id is prefixed so it can + /// never collide with one of the guest's own stages, which Stagehand refuses to let us touch anyway. + /// + public bool CreateOrUpdate(Guid beaconId, string definition, string debugName) => + Try(() => createOrUpdate.InvokeFunc(definition, StageId(beaconId), debugName), false); + + /// + /// Shows or hides a stage Beacon created. Stagehand hides every temporary stage when the player + /// changes location, so arriving somewhere means showing it again rather than assuming it is up. + /// + public bool SetVisible(Guid beaconId, bool visible) => + Try(() => setVisible.InvokeFunc(StageId(beaconId), visible), false); + + /// Removes a stage entirely. Called when a guest leaves, refuses it, or Beacon unloads. + public bool Destroy(Guid beaconId) => + Try(() => destroy.InvokeFunc(StageId(beaconId)), false); + + /// Beacon's own namespace inside Stagehand's temporary stage ids. + private static string StageId(Guid beaconId) => $"Beacon.{beaconId:N}"; + + private static T Try(Func call, T fallback) + { + try + { + return call(); + } + catch (Exception ex) + { + Svc.Log.Debug(ex, "Stagehand IPC call failed."); + return fallback; + } + } +} diff --git a/src/Beacon.Plugin/UI/Ornament.cs b/src/Beacon.Plugin/UI/Ornament.cs index 45921c7..96bf9f1 100644 --- a/src/Beacon.Plugin/UI/Ornament.cs +++ b/src/Beacon.Plugin/UI/Ornament.cs @@ -403,6 +403,14 @@ public static string TruncateToWidth(string text, float maxWidth) } /// Formats a remaining duration the way a person would say it. + /// A download size in the units a person actually thinks in. + public static string Bytes(long bytes) => bytes switch + { + >= 1024 * 1024 => $"{bytes / (1024f * 1024f):0.#} MB", + >= 1024 => $"{bytes / 1024f:0} KB", + _ => $"{bytes} bytes", + }; + public static string Remaining(TimeSpan span) => span.TotalMinutes switch { < 1 => "moments left", diff --git a/src/Beacon.Plugin/UI/Viewport.cs b/src/Beacon.Plugin/UI/Viewport.cs new file mode 100644 index 0000000..75ab93f --- /dev/null +++ b/src/Beacon.Plugin/UI/Viewport.cs @@ -0,0 +1,18 @@ +using Dalamud.Bindings.ImGui; + +namespace Beacon.UI; + +/// +/// The id ImGui gave the game's main viewport, recorded once a frame. +/// +/// Capturing the screen needs that id, it is a hash rather than zero, and it can only be read from +/// inside a draw callback. A capture is started from a button, a chat command or a background +/// continuation, so the value is remembered here rather than looked up at the point of use. +/// +public static class Viewport +{ + /// Zero until the first frame has been drawn. + public static uint MainId { get; private set; } + + public static void Record() => MainId = ImGui.GetMainViewport().ID; +} diff --git a/src/Beacon.Plugin/Windows/AtlasWindow.cs b/src/Beacon.Plugin/Windows/AtlasWindow.cs index 9711ade..3f07ff7 100644 --- a/src/Beacon.Plugin/Windows/AtlasWindow.cs +++ b/src/Beacon.Plugin/Windows/AtlasWindow.cs @@ -26,6 +26,8 @@ public sealed class AtlasWindow : Window private readonly NotificationService notifications; + private readonly StageDressing stages; + private readonly Action openEditor; private readonly Action openSettings; @@ -53,6 +55,7 @@ public AtlasWindow( LocationService location, ImageCache images, NotificationService notifications, + StageDressing stages, Action openEditor, Action openSettings, Action openChronicle, @@ -65,6 +68,7 @@ public AtlasWindow( this.location = location; this.images = images; this.notifications = notifications; + this.stages = stages; this.openEditor = openEditor; this.openSettings = openSettings; this.openChronicle = openChronicle; @@ -535,6 +539,8 @@ private void DrawPageContents(BeaconDto beacon, float scale) ImGui.NewLine(); } + DrawStage(beacon, scale); + ImGui.Spacing(); DrawRoute(beacon, scale); @@ -542,6 +548,83 @@ private void DrawPageContents(BeaconDto beacon, float scale) DrawActions(beacon, scale); } + /// + /// The stage a keeper dressed this place with, and the guest's decision about it. + /// + /// Nothing here loads anything on its own. A stage is another player's scenery appearing on your + /// screen, so the first time is always a question, and the answer is remembered per beacon. + /// + private void DrawStage(BeaconDto beacon, float scale) + { + if (beacon.Stage is not { } stage) + return; + + ImGui.Spacing(); + Ornament.PageLabel("Dressed with a stage"); + + var title = stage.Name.Length > 0 ? stage.Name : "A stage"; + var credit = stage.AuthorName.Length > 0 ? $" · built by {stage.AuthorName}" : string.Empty; + Ornament.Text(Theme.Ink, $"{title}{credit}"); + + Ornament.Text(Theme.InkFaint, $"{stage.Weight} · {Ornament.Bytes(stage.SizeBytes)}"); + + if (!stages.Available) + { + Ornament.TextWrapped( + Theme.InkFaint, + "Install Stagehand to see this place the way its keeper built it."); + return; + } + + if (stage.IntendedTerritoryType != 0 && stage.IntendedTerritoryType != beacon.Location.TerritoryId) + Ornament.TextWrapped(Theme.Wax, "This stage was built for a different zone and may not sit where it should."); + + switch (stages.StateFor(beacon)) + { + case StageState.Loading: + Ornament.Text(Theme.InkFaint, "Fetching the stage..."); + break; + + case StageState.Showing: + if (ImGui.Button("Put the stage away", new Vector2(190f * scale, 0))) + stages.Hide(); + + Ornament.Tooltip("Hides it for now. Nothing was ever added to your own Stagehand library."); + break; + + case StageState.Failed: + Ornament.TextWrapped(Theme.Wax, stages.LastError ?? "That stage could not be loaded."); + ImGui.Spacing(); + if (ImGui.Button("Try again", new Vector2(120f * scale, 0))) + stages.Show(beacon, remember: false); + + break; + + default: + if (ImGui.Button("Show me the stage", new Vector2(190f * scale, 0))) + stages.Show(beacon, remember: false); + + Ornament.Tooltip( + "Loads it as a temporary stage. It is never saved into your own Stagehand library," + + " and it goes away when you leave or put it away."); + + ImGui.SameLine(0, 6f * scale); + + if (ImGui.Button("Always here", new Vector2(120f * scale, 0))) + stages.Show(beacon, remember: true); + + Ornament.Tooltip("Load this beacon's stage whenever you arrive, without asking."); + + ImGui.SameLine(0, 6f * scale); + + if (ImGui.Button("Never", new Vector2(90f * scale, 0))) + stages.Refuse(beacon); + + Ornament.Tooltip("Do not offer this beacon's stage again."); + break; + } + } + private void DrawScreenshot(BeaconDto beacon, float scale) { var width = ImGui.GetContentRegionAvail().X; diff --git a/src/Beacon.Plugin/Windows/BeaconEditorWindow.cs b/src/Beacon.Plugin/Windows/BeaconEditorWindow.cs index e9a01ba..13aee3a 100644 --- a/src/Beacon.Plugin/Windows/BeaconEditorWindow.cs +++ b/src/Beacon.Plugin/Windows/BeaconEditorWindow.cs @@ -26,6 +26,16 @@ public sealed class BeaconEditorWindow : Window private readonly ImageCache images; + private readonly StageDressing stages; + + /// The stage picked in this editing session, not yet published. + private byte[]? pendingStage; + + private string? pendingStageName; + + /// The stage already published for the beacon being edited, if any. + private BeaconStageInfo? existingStage; + private readonly NotificationService notifications; private Guid? editingId; @@ -64,7 +74,8 @@ public BeaconEditorWindow( LocationService location, ScreenshotService screenshots, ImageCache images, - NotificationService notifications) + NotificationService notifications, + StageDressing stages) : base("Raise a beacon###BeaconEditor") { this.config = config; @@ -73,6 +84,7 @@ public BeaconEditorWindow( this.screenshots = screenshots; this.images = images; this.notifications = notifications; + this.stages = stages; SizeConstraints = new WindowSizeConstraints { @@ -107,6 +119,9 @@ public void Open(BeaconDto? beacon) visibility = BeaconVisibility.Public; allowPublicLighting = false; existingImageId = null; + existingStage = null; + pendingStage = null; + pendingStageName = null; WindowName = "Raise a beacon###BeaconEditor"; CaptureHere(); @@ -121,6 +136,9 @@ public void Open(BeaconDto? beacon) visibility = beacon.Visibility; allowPublicLighting = beacon.AllowPublicLighting; existingImageId = beacon.ImageId; + existingStage = beacon.Stage; + pendingStage = null; + pendingStageName = null; // Keep the beacon where it is unless the keeper explicitly moves it. capturedLocation = beacon.Location; @@ -145,6 +163,9 @@ public override void Draw() DrawScreenshotSection(scale); Ornament.FleuronDivider(Theme.BrassDim); + DrawStageSection(scale); + Ornament.FleuronDivider(Theme.BrassDim); + DrawVisibility(scale); ImGui.Spacing(); @@ -205,7 +226,7 @@ private void DrawFields(float scale) Ornament.Text(Theme.BrassBright, "What it is"); ImGui.SetNextItemWidth(-1); - ImGui.InputTextWithHint("##name", "The Bramblewood Camp", ref name, BeaconLimits.NameMaxLength); + ImGui.InputTextWithHint("##name", "What this place is called", ref name, BeaconLimits.NameMaxLength); ImGui.SetNextItemWidth(200f * scale); if (ImGui.BeginCombo("##kind", BeaconLabels.Describe(kind))) @@ -237,7 +258,7 @@ private void DrawFields(float scale) Ornament.Text(remaining < 100 ? Theme.Wax : Theme.MutedDeep, $"{remaining} characters left"); ImGui.SetNextItemWidth(-1); - ImGui.InputTextWithHint("##tags", "tags, comma separated: tavern, drop-in, lore-friendly", ref tagsText, 200); + ImGui.InputTextWithHint("##tags", "tags, comma separated", ref tagsText, 200); var tags = ParseTags(tagsText); if (tags.Count > BeaconLimits.MaxTags) @@ -303,6 +324,86 @@ private void DrawScreenshotSection(float scale) } } + // --- Stage ----------------------------------------------------------- + + /// + /// Attaching the Stagehand stage this place is dressed with, so a guest can see it the way it was + /// built rather than an empty field. + /// + private void DrawStageSection(float scale) + { + Ornament.Text(Theme.BrassBright, "Stage"); + + if (editingId is null) + { + Ornament.TextWrapped( + Theme.MutedDeep, + "Raise the beacon first, then reopen it to attach the stage you built here."); + return; + } + + if (!stages.Available) + { + Ornament.TextWrapped( + Theme.MutedDeep, + "Install Stagehand to share the scenery you have placed here."); + return; + } + + if (pendingStage is { Length: > 0 }) + { + Ornament.Text(Theme.Cream, $"{pendingStageName} · {Ornament.Bytes(pendingStage.Length)}"); + Ornament.TextWrapped(Theme.MutedDeep, "Published when you save."); + + if (ImGui.Button("Choose a different stage")) + stages.PickStage(OnStagePicked); + + ImGui.SameLine(); + + if (ImGui.Button("Clear")) + { + pendingStage = null; + pendingStageName = null; + } + } + else if (existingStage is { } stage) + { + var title = stage.Name.Length > 0 ? stage.Name : "A stage"; + Ornament.Text(Theme.Cream, $"{title} · {stage.Weight} · {Ornament.Bytes(stage.SizeBytes)}"); + + if (ImGui.Button("Replace the stage")) + stages.PickStage(OnStagePicked); + + ImGui.SameLine(); + + if (ImGui.Button("Remove it")) + { + atlas.RemoveStage(editingId.Value); + existingStage = null; + } + } + else + { + if (ImGui.Button("Attach a stage")) + stages.PickStage(OnStagePicked); + + Ornament.Tooltip("Pick a stage from your Stagehand library."); + + Ornament.TextWrapped( + Theme.MutedDeep, + "Guests who run Stagehand will be offered it when they arrive. They choose whether to load it."); + } + + if (stages.LastError is { } error) + Ornament.TextWrapped(Theme.WaxText, error); + } + + private void OnStagePicked(byte[] bytes, string fileName) + { + pendingStage = bytes; + pendingStageName = fileName; + } + private async Task CaptureScreenshotAsync() { var bytes = await screenshots.CaptureAsync(); @@ -415,6 +516,13 @@ private void Submit() pendingImage, pendingImageName); + if (pendingStage is { Length: > 0 }) + { + atlas.AttachStage(id, pendingStage); + pendingStage = null; + pendingStageName = null; + } + notifications.Toast($"{trimmedName} updated.", null); submitting = false; IsOpen = false; diff --git a/src/Beacon.Plugin/Windows/ChronicleWindow.cs b/src/Beacon.Plugin/Windows/ChronicleWindow.cs index 20beedf..b4654f7 100644 --- a/src/Beacon.Plugin/Windows/ChronicleWindow.cs +++ b/src/Beacon.Plugin/Windows/ChronicleWindow.cs @@ -463,6 +463,10 @@ private void DrawCardContents(ProfileDto profile, float scale) DrawPresence(profile, scale); + DrawMoment(profile, scale); + + DrawAtFirstGlance(profile, scale); + if (profile.Personality.Count > 0) { Ornament.PageLabel("PERSONALITY"); @@ -513,6 +517,72 @@ private void DrawCardContents(ProfileDto profile, float scale) DrawActions(profile, scale); } + /// + /// The part of the card that is true today: the live line, the IC/OOC flag, and the player's own + /// note. + /// + /// The age of the live line is shown rather than hidden. A "currently" written three weeks ago is + /// not current, and presenting it as though it were is the exact failure that makes people stop + /// trusting the field at all. + /// + private void DrawMoment(ProfileDto profile, float scale) + { + var moment = profile.Moment; + if (!moment.HasAnything) + return; + + if (moment.Stance is not RpStance.Unstated) + { + var inCharacter = moment.Stance is RpStance.InCharacter; + Ornament.Tag( + inCharacter ? "In character" : "Out of character", + inCharacter ? Theme.Wax : Theme.Brass, + Theme.Ink); + + ImGui.Spacing(); + } + + if (!string.IsNullOrWhiteSpace(moment.Currently)) + { + Ornament.PageLabel("CURRENTLY"); + Ornament.TextWrapped(Theme.Ink, moment.Currently!); + + if (moment.UpdatedAt is { } when) + Ornament.Text(moment.IsFresh ? Theme.InkFaint : Theme.Wax, Ornament.Ago(when)); + + ImGui.Spacing(); + } + + if (!string.IsNullOrWhiteSpace(moment.OutOfCharacter)) + { + Ornament.PageLabel("OUT OF CHARACTER"); + Ornament.TextWrapped(Theme.InkSoft, moment.OutOfCharacter!); + ImGui.Spacing(); + } + } + + /// What a stranger notices before a word is exchanged. + private void DrawAtFirstGlance(ProfileDto profile, float scale) + { + if (profile.AtFirstGlance.Count == 0) + return; + + Ornament.PageLabel("AT FIRST GLANCE"); + + foreach (var note in profile.AtFirstGlance) + { + if (note.Label.Length > 0) + { + Ornament.Text(Theme.InkFaint, note.Label); + ImGui.SameLine(0, 6f * scale); + } + + Ornament.TextWrapped(Theme.InkSoft, note.Text); + } + + ImGui.Spacing(); + } + private void DrawPortrait(ProfileDto profile, float size, float scale) { ImGui.BeginGroup(); diff --git a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs index e9a33a7..807e6ee 100644 --- a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs +++ b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs @@ -60,6 +60,14 @@ public sealed class ProfileEditorWindow : Window, IDisposable private readonly HashSet matureThemes = []; private readonly List hooks = []; + + /// The "at first glance" slots, as label and line pairs. + private readonly List<(string Label, string Text)> glances = []; + + private string outOfCharacter = string.Empty; + private string currently = string.Empty; + private RpStance stance = RpStance.Unstated; + private string overview = string.Empty; private string history = string.Empty; private string goals = string.Empty; @@ -128,6 +136,7 @@ public void Open(ProfileDto? profile) activities.Clear(); matureThemes.Clear(); hooks.Clear(); + glances.Clear(); if (profile is null) { @@ -147,6 +156,9 @@ public void Open(ProfileDto? profile) walkups = true; overview = history = goals = string.Empty; + outOfCharacter = string.Empty; + currently = string.Empty; + stance = RpStance.Unstated; timezone = playtimes = contact = string.Empty; visibility = ProfileVisibility.Public; availability = AvailabilityOverride.Derived; @@ -185,6 +197,10 @@ public void Open(ProfileDto? profile) matureThemes.Add(theme); hooks.AddRange(profile.Hooks.Select(h => h.Text)); + glances.AddRange(profile.AtFirstGlance.Select(g => (g.Label, g.Text))); + outOfCharacter = profile.Moment.OutOfCharacter ?? string.Empty; + currently = profile.Moment.Currently ?? string.Empty; + stance = profile.Moment.Stance; overview = profile.Overview ?? string.Empty; history = profile.History ?? string.Empty; goals = profile.Goals ?? string.Empty; @@ -235,6 +251,12 @@ public override void Draw() ImGui.EndTabItem(); } + if (ImGui.BeginTabItem("Right now")) + { + DrawRightNow(scale); + ImGui.EndTabItem(); + } + if (ImGui.BeginTabItem("The player")) { DrawPlayer(scale); @@ -260,11 +282,11 @@ private void DrawEssentials(float scale) Ornament.PageLabel("Their name, as you want it written"); ImGui.SetNextItemWidth(-1); - ImGui.InputTextWithHint("##name", "Shaala Avarr", ref name, ProfileLimits.NameMaxLength); + ImGui.InputTextWithHint("##name", "Their name", ref name, ProfileLimits.NameMaxLength); Ornament.PageLabel("An epithet, if they have one"); ImGui.SetNextItemWidth(-1); - ImGui.InputTextWithHint("##title", "The Wolf Without a Den", ref title, ProfileLimits.TitleMaxLength); + ImGui.InputTextWithHint("##title", "An epithet", ref title, ProfileLimits.TitleMaxLength); Ornament.PageLabel("Three words"); var third = (ImGui.GetContentRegionAvail().X - (8f * scale)) / 3f; @@ -274,9 +296,9 @@ private void DrawEssentials(float scale) ImGui.SetNextItemWidth(third); ImGui.InputTextWithHint($"##arch{i}", i switch { - 0 => "Wild", - 1 => "Wanderer", - _ => "Independent", + 0 => "First word", + 1 => "Second", + _ => "Third", }, ref archetype[i], ProfileLimits.ArchetypeWordMaxLength); if (i < archetype.Length - 1) @@ -437,7 +459,43 @@ private void DrawCharacter(float scale) ImGui.Spacing(); Ornament.PageLabel("A line they might say"); ImGui.SetNextItemWidth(-1); - ImGui.InputTextWithHint("##quote", "Between instinct and civilisation.", ref quote, ProfileLimits.QuoteMaxLength); + ImGui.InputTextWithHint("##quote", "A line in their own voice", ref quote, ProfileLimits.QuoteMaxLength); + + Ornament.FleuronDivider(Theme.BrassDim); + + Ornament.FleuronDivider(Theme.BrassDim); + + Ornament.Text(Theme.BrassBright, $"At first glance ({glances.Count} of {ProfileLimits.MaxGlanceNotes})"); + Ornament.TextWrapped(Theme.MutedDeep, + "What somebody notices before a word is exchanged. A label and a line: \"Hands\" / \"Ink to the\n" + + "knuckles, badly done\". Leave the label blank if the line speaks for itself."); + + var labelWidth = 130f * scale; + + for (var i = 0; i < glances.Count; i++) + { + var (label, text) = glances[i]; + + ImGui.SetNextItemWidth(labelWidth); + if (ImGui.InputTextWithHint($"##glanceLabel{i}", "Label", ref label, ProfileLimits.GlanceLabelMaxLength)) + glances[i] = (label, text); + + ImGui.SameLine(0, 6f * scale); + + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X - (32f * scale)); + if (ImGui.InputTextWithHint($"##glanceText{i}", "What they notice", ref text, ProfileLimits.GlanceTextMaxLength)) + glances[i] = (label, text); + + ImGui.SameLine(); + if (ImGui.SmallButton($"x##delglance{i}")) + { + glances.RemoveAt(i); + break; + } + } + + if (glances.Count < ProfileLimits.MaxGlanceNotes && ImGui.Button("Add an impression")) + glances.Add((string.Empty, string.Empty)); Ornament.FleuronDivider(Theme.BrassDim); @@ -530,7 +588,7 @@ private void DrawStyle(float scale) Ornament.Text(Theme.MutedDeep, "Shown on the face of your card, where it prevents trouble."); ImGui.SetNextItemWidth(-1); - ImGui.InputTextWithHint("##boundaries", "No romance. Ask before injury.", ref boundaries, ProfileLimits.BoundariesMaxLength); + ImGui.InputTextWithHint("##boundaries", "What you would rather not play", ref boundaries, ProfileLimits.BoundariesMaxLength); } /// @@ -617,6 +675,91 @@ private void DrawStory(float scale) ImGui.InputTextMultiline("##history", ref history, ProfileLimits.HistoryMaxLength, new Vector2(-1, 110f * scale)); } + // --- Right now -------------------------------------------------------- + + /// + /// The part of a card that is true today rather than true in general. + /// + /// Borrowed wholesale from the addon culture that has had it for years, including the thing that + /// makes it work: the live line is saved the moment you write it, never on the card's save button, + /// because a field you have to remember to publish is a field that goes stale in a week. + /// + private void DrawRightNow(float scale) + { + Ornament.Text(Theme.BrassBright, "Are you in character?"); + Ornament.TextWrapped(Theme.MutedDeep, + "The single most useful thing a card can say, and the one thing a fixed profile cannot."); + + DrawStanceOption(RpStance.Unstated, "Not saying", "The default, and what every card starts as."); + ImGui.SameLine(0, 6f * scale); + DrawStanceOption(RpStance.InCharacter, "In character", "Approach me as my character."); + ImGui.SameLine(0, 6f * scale); + DrawStanceOption(RpStance.OutOfCharacter, "Out of character", "Here, but not playing right now."); + + Ornament.FleuronDivider(Theme.BrassDim); + + Ornament.Text(Theme.BrassBright, "Currently"); + + if (editing is { } profile) + { + Ornament.TextWrapped(Theme.MutedDeep, + "What they are doing, in their own voice. Saved the moment you write it, not when you save\n" + + "the card. You can also set it without opening this window, with /beacon currently."); + + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##currently", "Camped above the falls, drying out", ref currently, ProfileLimits.CurrentlyMaxLength); + + if (ImGui.Button("Set it")) + profiles.SetCurrently(profile, currently, stance); + + ImGui.SameLine(); + + if (ImGui.Button("Clear it")) + { + currently = string.Empty; + profiles.SetCurrently(profile, null, stance); + } + + if (profile.Moment.UpdatedAt is { } when) + { + ImGui.SameLine(0, 10f * scale); + Ornament.Text(profile.Moment.IsFresh ? Theme.MutedDeep : Theme.Wax, $"written {Ornament.Ago(when)}"); + } + } + else + { + Ornament.TextWrapped(Theme.MutedDeep, + "Publish the card first. The live line is saved on its own, so it needs somewhere to live."); + } + + Ornament.FleuronDivider(Theme.BrassDim); + + Ornament.Text(Theme.BrassBright, "Out of character"); + Ornament.TextWrapped(Theme.MutedDeep, + "You speaking as yourself rather than as your character: a hiatus, a content warning, an\n" + + "invitation to ask. Saved with the rest of the card."); + + ImGui.InputTextMultiline( + "##ooc", + ref outOfCharacter, + ProfileLimits.OutOfCharacterMaxLength, + new Vector2(-1, 80f * scale)); + } + + private void DrawStanceOption(RpStance option, string label, string tooltip) + { + var selected = stance == option; + + ImGui.PushStyleColor(ImGuiCol.Button, selected ? Theme.BrassBright : Theme.PanelRaised); + ImGui.PushStyleColor(ImGuiCol.Text, selected ? Theme.Leather : Theme.CreamDim); + + if (ImGui.Button($"{label}##stance{option}")) + stance = option; + + ImGui.PopStyleColor(2); + Ornament.Tooltip(tooltip); + } + // --- Player ---------------------------------------------------------- private void DrawPlayer(float scale) @@ -821,6 +964,12 @@ private async Task SubmitAsync() }, Personality = [.. traits], Hooks = hooks.Where(h => !string.IsNullOrWhiteSpace(h)).Select(h => h.Trim()).ToList(), + AtFirstGlance = glances + .Where(g => !string.IsNullOrWhiteSpace(g.Text)) + .Select((g, i) => new GlanceNote { Label = g.Label.Trim(), Text = g.Text.Trim(), Order = i }) + .ToList(), + OutOfCharacter = Blank(outOfCharacter), + Stance = stance, Overview = Blank(overview), History = Blank(history), Goals = Blank(goals), diff --git a/src/Beacon.Plugin/Windows/SettingsWindow.cs b/src/Beacon.Plugin/Windows/SettingsWindow.cs index c8879cc..1f2e35c 100644 --- a/src/Beacon.Plugin/Windows/SettingsWindow.cs +++ b/src/Beacon.Plugin/Windows/SettingsWindow.cs @@ -21,6 +21,8 @@ public sealed class SettingsWindow : Window private readonly TravelService travel; + private readonly StageDressing stages; + private string serverUrl; private string displayName; @@ -40,7 +42,8 @@ public SettingsWindow( BeaconApi api, AtlasService atlas, BeaconHubClient hub, - TravelService travel) + TravelService travel, + StageDressing stages) : base("Beacon settings###BeaconSettings") { this.config = config; @@ -48,6 +51,7 @@ public SettingsWindow( this.atlas = atlas; this.hub = hub; this.travel = travel; + this.stages = stages; serverUrl = config.ServerUrl; displayName = config.DisplayName; @@ -374,6 +378,31 @@ private void DrawBehaviour() Ornament.FleuronDivider(Theme.BrassDim); + Ornament.Text(Theme.BrassBright, "Stages"); + + Toggle("Load every stage automatically when I arrive", + () => config.AutoLoadStages, v => config.AutoLoadStages = v, + "Off by default. A stage is another player's scenery appearing on your screen, so Beacon\n" + + "asks the first time and remembers your answer for that beacon. Turn this on if you would\n" + + "rather see every place as its keeper built it.\n" + + "Nothing is ever written into your own Stagehand library either way."); + + if (config.TrustedStageBeacons.Count > 0 || config.RefusedStageBeacons.Count > 0) + { + Ornament.Text( + Theme.MutedDeep, + $"{config.TrustedStageBeacons.Count} agreed to, {config.RefusedStageBeacons.Count} declined."); + + if (ImGui.Button("Ask me about all of them again")) + { + config.TrustedStageBeacons.Clear(); + config.RefusedStageBeacons.Clear(); + config.Save(); + } + } + + Ornament.FleuronDivider(Theme.BrassDim); + Ornament.Text(Theme.BrassBright, "Appearance"); Toggle("Use the Beacon parchment styling", @@ -407,6 +436,9 @@ private void DrawTravel() DrawDependency("vnavmesh", travel.NavmeshAvailable, "Optional. Walks the last stretch from the aetheryte to the beacon itself."); + DrawDependency("Stagehand", stages.Available, + "Optional. Lets you see a place dressed the way its keeper built it, and share your own."); + Ornament.FleuronDivider(Theme.BrassDim); Toggle("Walk the last stretch automatically", diff --git a/src/Beacon.Server/BeaconOptions.cs b/src/Beacon.Server/BeaconOptions.cs index 0bca06f..fcfd2ad 100644 --- a/src/Beacon.Server/BeaconOptions.cs +++ b/src/Beacon.Server/BeaconOptions.cs @@ -81,4 +81,7 @@ public string ResolveDataDirectory(string contentRoot) => public string ImageDirectory(string contentRoot) => Path.Combine(ResolveDataDirectory(contentRoot), "images"); + + public string StageDirectory(string contentRoot) => + Path.Combine(ResolveDataDirectory(contentRoot), "stages"); } diff --git a/src/Beacon.Server/Data/BeaconDbContext.cs b/src/Beacon.Server/Data/BeaconDbContext.cs index 6fadf12..71fb319 100644 --- a/src/Beacon.Server/Data/BeaconDbContext.cs +++ b/src/Beacon.Server/Data/BeaconDbContext.cs @@ -14,6 +14,8 @@ public class BeaconDbContext(DbContextOptions options) : DbCont public DbSet Images => Set(); + public DbSet Stages => Set(); + public DbSet Lights => Set(); public DbSet Favorites => Set(); @@ -26,6 +28,8 @@ public class BeaconDbContext(DbContextOptions options) : DbCont public DbSet ProfileHooks => Set(); + public DbSet ProfileGlances => Set(); + public DbSet ProfileImages => Set(); public DbSet ProfileLinks => Set(); @@ -127,6 +131,15 @@ protected override void OnModelCreating(ModelBuilder b) e.HasIndex(x => x.BeaconId); }); + b.Entity(e => + { + // One stage per beacon, so the beacon's id is the key rather than a surrogate. + e.HasKey(x => x.BeaconId); + e.Property(x => x.Name).HasMaxLength(128); + e.Property(x => x.AuthorName).HasMaxLength(64); + e.Property(x => x.Description).HasMaxLength(512); + }); + b.Entity(e => { e.HasKey(x => x.Id); @@ -170,6 +183,8 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(x => x.ArchetypeCsv).HasMaxLength(96); e.Property(x => x.Quote).HasMaxLength(200); e.Property(x => x.Boundaries).HasMaxLength(256); + e.Property(x => x.Currently).HasMaxLength(256); + e.Property(x => x.OutOfCharacter).HasMaxLength(512); e.Property(x => x.PlayerTimezone).HasMaxLength(48); e.Property(x => x.PlayerAvailability).HasMaxLength(200); e.Property(x => x.PlayerContact).HasMaxLength(96); @@ -220,6 +235,14 @@ protected override void OnModelCreating(ModelBuilder b) e.HasIndex(x => x.ProfileId); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Label).HasMaxLength(48).IsRequired(); + e.Property(x => x.Text).HasMaxLength(200).IsRequired(); + e.HasIndex(x => x.ProfileId); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/src/Beacon.Server/Data/BeaconMapper.cs b/src/Beacon.Server/Data/BeaconMapper.cs index 13358a5..d97b360 100644 --- a/src/Beacon.Server/Data/BeaconMapper.cs +++ b/src/Beacon.Server/Data/BeaconMapper.cs @@ -68,7 +68,25 @@ public static BeaconFlame ToFlame(this BeaconEntity e) => Note = e.LitNote, }; - public static BeaconDto ToDto(this BeaconEntity e, string ownerName, bool isFavorite = false) => + /// Describes a stored stage on the wire. Metadata only; the definition has its own route. + public static BeaconStageInfo ToInfo(this BeaconStageEntity e) => + new() + { + Name = e.Name, + AuthorName = e.AuthorName, + Description = e.Description, + IntendedTerritoryType = e.IntendedTerritoryType, + ObjectCount = e.ObjectCount, + ModpackCount = e.ModpackCount, + SizeBytes = e.SizeBytes, + UpdatedAt = e.UpdatedAt, + }; + + public static BeaconDto ToDto( + this BeaconEntity e, + string ownerName, + bool isFavorite = false, + BeaconStageEntity? stage = null) => new() { Id = e.Id, @@ -102,6 +120,7 @@ public static BeaconDto ToDto(this BeaconEntity e, string ownerName, bool isFavo Flame = e.ToFlame(), AllowPublicLighting = e.AllowPublicLighting, ImageId = e.ImageId, + Stage = stage?.ToInfo(), Tags = SplitTags(e.TagsCsv), Visibility = e.Visibility, ShareCode = e.ShareCode, diff --git a/src/Beacon.Server/Data/Entities/BeaconStageEntity.cs b/src/Beacon.Server/Data/Entities/BeaconStageEntity.cs new file mode 100644 index 0000000..78da743 --- /dev/null +++ b/src/Beacon.Server/Data/Entities/BeaconStageEntity.cs @@ -0,0 +1,34 @@ +namespace Beacon.Server.Data.Entities; + +/// +/// The Stagehand stage attached to a beacon. +/// +/// Only the description of it lives in the database. The definition itself is a JSON document that +/// can run to megabytes when it embeds its own models, and it follows the same rule as a screenshot: +/// bytes on disk, facts in SQLite. A beacon has at most one, so the beacon's own id is the key. +/// +public class BeaconStageEntity +{ + public Guid BeaconId { get; set; } + + public Guid UploadedByAccountId { get; set; } + + /// The stage's own name, as written by whoever built it. + public string Name { get; set; } = string.Empty; + + /// The credit the author gave themselves, which need not match the beacon's keeper. + public string AuthorName { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + /// The zone it was built for, so a guest standing somewhere else can be warned. + public uint IntendedTerritoryType { get; set; } + + public int ObjectCount { get; set; } + + public int ModpackCount { get; set; } + + public long SizeBytes { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/Beacon.Server/Data/Entities/ProfileEntity.cs b/src/Beacon.Server/Data/Entities/ProfileEntity.cs index e98c709..fec1419 100644 --- a/src/Beacon.Server/Data/Entities/ProfileEntity.cs +++ b/src/Beacon.Server/Data/Entities/ProfileEntity.cs @@ -64,6 +64,23 @@ public class ProfileEntity public string? PlayerContact { get; set; } + // --- Today ---------------------------------------------------------- + + /// The live line: what this character is doing right now, in their own voice. + public string? Currently { get; set; } + + /// The player speaking as themselves rather than as the character. + public string? OutOfCharacter { get; set; } + + /// Whether the player is in character right now. + public RpStance Stance { get; set; } + + /// + /// When the live line was last written. Separate from so that editing a + /// biography does not make a stale "currently" look freshly written. + /// + public DateTimeOffset? CurrentlyUpdatedAt { get; set; } + // --- Prose ----------------------------------------------------------- public string? Overview { get; set; } @@ -101,5 +118,7 @@ public class ProfileEntity public List Hooks { get; set; } = []; + public List Glances { get; set; } = []; + public List Images { get; set; } = []; } diff --git a/src/Beacon.Server/Data/Entities/ProfileGlanceEntity.cs b/src/Beacon.Server/Data/Entities/ProfileGlanceEntity.cs new file mode 100644 index 0000000..b8f5f4d --- /dev/null +++ b/src/Beacon.Server/Data/Entities/ProfileGlanceEntity.cs @@ -0,0 +1,22 @@ +namespace Beacon.Server.Data.Entities; + +/// +/// One of the things a stranger notices before a word is exchanged. +/// +/// A label and a line rather than a fixed set of physical fields. Height, build and eye colour make a +/// census entry; "Hands" / "Ink to the knuckles, badly done" makes somebody walk over. +/// +public class ProfileGlanceEntity +{ + public Guid Id { get; set; } + + public Guid ProfileId { get; set; } + + public ProfileEntity? Profile { get; set; } + + public string Label { get; set; } = string.Empty; + + public string Text { get; set; } = string.Empty; + + public int Order { get; set; } +} diff --git a/src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.Designer.cs b/src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.Designer.cs new file mode 100644 index 0000000..528956a --- /dev/null +++ b/src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.Designer.cs @@ -0,0 +1,818 @@ +// +using System; +using Beacon.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Beacon.Server.Data.Migrations +{ + [DbContext(typeof(BeaconDbContext))] + [Migration("20260922230017_AddBeaconStages")] + partial class AddBeaconStages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Beacon.Server.Data.Entities.AccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdultConfirmedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsModerator") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AethernetShard") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AllowPublicLighting") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DataCenter") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsLit") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LastLitAt") + .HasColumnType("INTEGER"); + + b.Property("LitAt") + .HasColumnType("INTEGER"); + + b.Property("LitByAccountId") + .HasColumnType("TEXT"); + + b.Property("LitByName") + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("LitNote") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LitUntil") + .HasColumnType("INTEGER"); + + b.Property("MapId") + .HasColumnType("INTEGER"); + + b.Property("MapX") + .HasColumnType("REAL"); + + b.Property("MapY") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NearestAetheryteId") + .HasColumnType("INTEGER"); + + b.Property("NearestAetheryteName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerAccountId") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("ShareCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubAreaName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TerritoryId") + .HasColumnType("INTEGER"); + + b.Property("TimesLit") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("WorldId") + .HasColumnType("INTEGER"); + + b.Property("WorldName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("REAL"); + + b.Property("ZoneName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataCenter"); + + b.HasIndex("OwnerAccountId"); + + b.HasIndex("ShareCode") + .IsUnique(); + + b.HasIndex("TerritoryId"); + + b.HasIndex("WorldId"); + + b.HasIndex("IsLit", "LitUntil"); + + b.HasIndex("IsDeleted", "Visibility", "IsLit"); + + b.ToTable("Beacons"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadedByAccountId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BeaconId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconLightEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("CharacterName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("ExtinguishedAt") + .HasColumnType("INTEGER"); + + b.Property("LitAt") + .HasColumnType("INTEGER"); + + b.Property("LitUntil") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("BeaconId"); + + b.ToTable("Lights"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconStageEntity", b => + { + b.Property("BeaconId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IntendedTerritoryType") + .HasColumnType("INTEGER"); + + b.Property("ModpackCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ObjectCount") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UploadedByAccountId") + .HasColumnType("TEXT"); + + b.HasKey("BeaconId"); + + b.ToTable("Stages"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.CharacterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("DataCenter") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("WorldId") + .HasColumnType("INTEGER"); + + b.Property("WorldName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Name", "WorldId") + .IsUnique(); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.FavoriteEntity", b => + { + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("AccountId", "BeaconId"); + + b.HasIndex("BeaconId"); + + b.ToTable("Favorites"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("INTEGER"); + + b.Property("ArchetypeCsv") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("Availability") + .HasColumnType("INTEGER"); + + b.Property("Boundaries") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CharacterName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Clan") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DataCenter") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Goals") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("History") + .HasMaxLength(4400) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsMature") + .HasColumnType("INTEGER"); + + b.Property("LastActiveAt") + .HasColumnType("INTEGER"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(600) + .HasColumnType("TEXT"); + + b.Property("OwnerAccountId") + .HasColumnType("TEXT"); + + b.Property("PlayerAvailability") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PlayerContact") + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("PlayerTimezone") + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("PortraitImageId") + .HasColumnType("TEXT"); + + b.Property("Quote") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Race") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ShareCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("WalkupsWelcome") + .HasColumnType("INTEGER"); + + b.Property("WorldId") + .HasColumnType("INTEGER"); + + b.Property("WorldName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataCenter"); + + b.HasIndex("OwnerAccountId"); + + b.HasIndex("Race"); + + b.HasIndex("ShareCode") + .IsUnique(); + + b.HasIndex("CharacterName", "WorldId") + .IsUnique(); + + b.HasIndex("IsDeleted", "Visibility", "LastActiveAt"); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileHookEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileHooks"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ImageId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileImages"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileLinkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConfirmedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("OtherProfileId") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OtherProfileId"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "OtherProfileId") + .IsUnique(); + + b.ToTable("ProfileLinks"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("Kind", "Value"); + + b.HasIndex("ProfileId", "Kind", "Value") + .IsUnique(); + + b.ToTable("ProfileTags"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterAccountId") + .HasColumnType("TEXT"); + + b.Property("Resolved") + .HasColumnType("INTEGER"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("ResolvedByAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BeaconId"); + + b.HasIndex("ProfileId"); + + b.HasIndex("Resolved", "CreatedAt"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.AccountEntity", "Owner") + .WithMany("Beacons") + .HasForeignKey("OwnerAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.CharacterEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.AccountEntity", "Account") + .WithMany("Characters") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.FavoriteEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.BeaconEntity", "Beacon") + .WithMany("Favorites") + .HasForeignKey("BeaconId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Beacon"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.AccountEntity", "Owner") + .WithMany() + .HasForeignKey("OwnerAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileHookEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Hooks") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileImageEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Images") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileLinkEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileTagEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Tags") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.AccountEntity", b => + { + b.Navigation("Beacons"); + + b.Navigation("Characters"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconEntity", b => + { + b.Navigation("Favorites"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileEntity", b => + { + b.Navigation("Hooks"); + + b.Navigation("Images"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.cs b/src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.cs new file mode 100644 index 0000000..a74b221 --- /dev/null +++ b/src/Beacon.Server/Data/Migrations/20260922230017_AddBeaconStages.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Beacon.Server.Data.Migrations +{ + /// + public partial class AddBeaconStages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Stages", + columns: table => new + { + BeaconId = table.Column(type: "TEXT", nullable: false), + UploadedByAccountId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + AuthorName = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 512, nullable: false), + IntendedTerritoryType = table.Column(type: "INTEGER", nullable: false), + ObjectCount = table.Column(type: "INTEGER", nullable: false), + ModpackCount = table.Column(type: "INTEGER", nullable: false), + SizeBytes = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Stages", x => x.BeaconId); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Stages"); + } + } +} diff --git a/src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.Designer.cs b/src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.Designer.cs new file mode 100644 index 0000000..0c40f9f --- /dev/null +++ b/src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.Designer.cs @@ -0,0 +1,874 @@ +// +using System; +using Beacon.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Beacon.Server.Data.Migrations +{ + [DbContext(typeof(BeaconDbContext))] + [Migration("20260922231111_AddChronicleMoment")] + partial class AddChronicleMoment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Beacon.Server.Data.Entities.AccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdultConfirmedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsModerator") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AethernetShard") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AllowPublicLighting") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DataCenter") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsLit") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LastLitAt") + .HasColumnType("INTEGER"); + + b.Property("LitAt") + .HasColumnType("INTEGER"); + + b.Property("LitByAccountId") + .HasColumnType("TEXT"); + + b.Property("LitByName") + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("LitNote") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LitUntil") + .HasColumnType("INTEGER"); + + b.Property("MapId") + .HasColumnType("INTEGER"); + + b.Property("MapX") + .HasColumnType("REAL"); + + b.Property("MapY") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NearestAetheryteId") + .HasColumnType("INTEGER"); + + b.Property("NearestAetheryteName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerAccountId") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("ShareCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubAreaName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TerritoryId") + .HasColumnType("INTEGER"); + + b.Property("TimesLit") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("WorldId") + .HasColumnType("INTEGER"); + + b.Property("WorldName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("REAL"); + + b.Property("ZoneName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataCenter"); + + b.HasIndex("OwnerAccountId"); + + b.HasIndex("ShareCode") + .IsUnique(); + + b.HasIndex("TerritoryId"); + + b.HasIndex("WorldId"); + + b.HasIndex("IsLit", "LitUntil"); + + b.HasIndex("IsDeleted", "Visibility", "IsLit"); + + b.ToTable("Beacons"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadedByAccountId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BeaconId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconLightEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("CharacterName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("ExtinguishedAt") + .HasColumnType("INTEGER"); + + b.Property("LitAt") + .HasColumnType("INTEGER"); + + b.Property("LitUntil") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("BeaconId"); + + b.ToTable("Lights"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconStageEntity", b => + { + b.Property("BeaconId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IntendedTerritoryType") + .HasColumnType("INTEGER"); + + b.Property("ModpackCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ObjectCount") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UploadedByAccountId") + .HasColumnType("TEXT"); + + b.HasKey("BeaconId"); + + b.ToTable("Stages"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.CharacterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("DataCenter") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("WorldId") + .HasColumnType("INTEGER"); + + b.Property("WorldName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Name", "WorldId") + .IsUnique(); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.FavoriteEntity", b => + { + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("AccountId", "BeaconId"); + + b.HasIndex("BeaconId"); + + b.ToTable("Favorites"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("INTEGER"); + + b.Property("ArchetypeCsv") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("Availability") + .HasColumnType("INTEGER"); + + b.Property("Boundaries") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CharacterName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Clan") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Currently") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CurrentlyUpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("DataCenter") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Goals") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("History") + .HasMaxLength(4400) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsMature") + .HasColumnType("INTEGER"); + + b.Property("LastActiveAt") + .HasColumnType("INTEGER"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OutOfCharacter") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(600) + .HasColumnType("TEXT"); + + b.Property("OwnerAccountId") + .HasColumnType("TEXT"); + + b.Property("PlayerAvailability") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PlayerContact") + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("PlayerTimezone") + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("PortraitImageId") + .HasColumnType("TEXT"); + + b.Property("Quote") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Race") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ShareCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Stance") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("WalkupsWelcome") + .HasColumnType("INTEGER"); + + b.Property("WorldId") + .HasColumnType("INTEGER"); + + b.Property("WorldName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataCenter"); + + b.HasIndex("OwnerAccountId"); + + b.HasIndex("Race"); + + b.HasIndex("ShareCode") + .IsUnique(); + + b.HasIndex("CharacterName", "WorldId") + .IsUnique(); + + b.HasIndex("IsDeleted", "Visibility", "LastActiveAt"); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileGlanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileGlances"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileHookEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileHooks"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ImageId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileImages"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileLinkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConfirmedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("OtherProfileId") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OtherProfileId"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "OtherProfileId") + .IsUnique(); + + b.ToTable("ProfileLinks"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("Kind", "Value"); + + b.HasIndex("ProfileId", "Kind", "Value") + .IsUnique(); + + b.ToTable("ProfileTags"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BeaconId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterAccountId") + .HasColumnType("TEXT"); + + b.Property("Resolved") + .HasColumnType("INTEGER"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("ResolvedByAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BeaconId"); + + b.HasIndex("ProfileId"); + + b.HasIndex("Resolved", "CreatedAt"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.AccountEntity", "Owner") + .WithMany("Beacons") + .HasForeignKey("OwnerAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.CharacterEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.AccountEntity", "Account") + .WithMany("Characters") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.FavoriteEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.BeaconEntity", "Beacon") + .WithMany("Favorites") + .HasForeignKey("BeaconId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Beacon"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.AccountEntity", "Owner") + .WithMany() + .HasForeignKey("OwnerAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileGlanceEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Glances") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileHookEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Hooks") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileImageEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Images") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileLinkEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileTagEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Tags") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.AccountEntity", b => + { + b.Navigation("Beacons"); + + b.Navigation("Characters"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconEntity", b => + { + b.Navigation("Favorites"); + }); + + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileEntity", b => + { + b.Navigation("Glances"); + + b.Navigation("Hooks"); + + b.Navigation("Images"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.cs b/src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.cs new file mode 100644 index 0000000..73713ca --- /dev/null +++ b/src/Beacon.Server/Data/Migrations/20260922231111_AddChronicleMoment.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Beacon.Server.Data.Migrations +{ + /// + public partial class AddChronicleMoment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Currently", + table: "Profiles", + type: "TEXT", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "CurrentlyUpdatedAt", + table: "Profiles", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "OutOfCharacter", + table: "Profiles", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "Stance", + table: "Profiles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "ProfileGlances", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProfileId = table.Column(type: "TEXT", nullable: false), + Label = table.Column(type: "TEXT", maxLength: 48, nullable: false), + Text = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Order = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProfileGlances", x => x.Id); + table.ForeignKey( + name: "FK_ProfileGlances_Profiles_ProfileId", + column: x => x.ProfileId, + principalTable: "Profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProfileGlances_ProfileId", + table: "ProfileGlances", + column: "ProfileId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProfileGlances"); + + migrationBuilder.DropColumn( + name: "Currently", + table: "Profiles"); + + migrationBuilder.DropColumn( + name: "CurrentlyUpdatedAt", + table: "Profiles"); + + migrationBuilder.DropColumn( + name: "OutOfCharacter", + table: "Profiles"); + + migrationBuilder.DropColumn( + name: "Stance", + table: "Profiles"); + } + } +} diff --git a/src/Beacon.Server/Data/Migrations/BeaconDbContextModelSnapshot.cs b/src/Beacon.Server/Data/Migrations/BeaconDbContextModelSnapshot.cs index ad4923f..a9899a9 100644 --- a/src/Beacon.Server/Data/Migrations/BeaconDbContextModelSnapshot.cs +++ b/src/Beacon.Server/Data/Migrations/BeaconDbContextModelSnapshot.cs @@ -292,6 +292,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Lights"); }); + modelBuilder.Entity("Beacon.Server.Data.Entities.BeaconStageEntity", b => + { + b.Property("BeaconId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IntendedTerritoryType") + .HasColumnType("INTEGER"); + + b.Property("ModpackCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ObjectCount") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UploadedByAccountId") + .HasColumnType("TEXT"); + + b.HasKey("BeaconId"); + + b.ToTable("Stages"); + }); + modelBuilder.Entity("Beacon.Server.Data.Entities.CharacterEntity", b => { b.Property("Id") @@ -383,6 +427,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("INTEGER"); + b.Property("Currently") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CurrentlyUpdatedAt") + .HasColumnType("INTEGER"); + b.Property("DataCenter") .IsRequired() .HasMaxLength(48) @@ -417,6 +468,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("TEXT"); + b.Property("OutOfCharacter") + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("Overview") .HasMaxLength(600) .HasColumnType("TEXT"); @@ -452,6 +507,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("TEXT"); + b.Property("Stance") + .HasColumnType("INTEGER"); + b.Property("Title") .HasMaxLength(80) .HasColumnType("TEXT"); @@ -492,6 +550,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Profiles"); }); + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileGlanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileGlances"); + }); + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileHookEntity", b => { b.Property("Id") @@ -701,6 +788,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Owner"); }); + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileGlanceEntity", b => + { + b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") + .WithMany("Glances") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileHookEntity", b => { b.HasOne("Beacon.Server.Data.Entities.ProfileEntity", "Profile") @@ -759,6 +857,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Beacon.Server.Data.Entities.ProfileEntity", b => { + b.Navigation("Glances"); + b.Navigation("Hooks"); b.Navigation("Images"); diff --git a/src/Beacon.Server/Data/ProfileMapper.cs b/src/Beacon.Server/Data/ProfileMapper.cs index 56fd8fb..0857cbb 100644 --- a/src/Beacon.Server/Data/ProfileMapper.cs +++ b/src/Beacon.Server/Data/ProfileMapper.cs @@ -111,6 +111,17 @@ public static ProfileDto ToDto( Contact = e.PlayerContact, }, Presence = BuildPresence(e, litBeacon), + Moment = new ProfileMoment + { + Currently = e.Currently, + OutOfCharacter = e.OutOfCharacter, + Stance = e.Stance, + UpdatedAt = e.CurrentlyUpdatedAt, + }, + AtFirstGlance = e.Glances + .OrderBy(g => g.Order) + .Select(g => new GlanceNote { Label = g.Label, Text = g.Text, Order = g.Order }) + .ToList(), Personality = TagsOf(e.Tags, ProfileTagKind.Personality), Hooks = e.Hooks .OrderBy(h => h.Order) diff --git a/src/Beacon.Server/Endpoints/ImageEndpoints.cs b/src/Beacon.Server/Endpoints/ImageEndpoints.cs index fdfbbc0..8a895ff 100644 --- a/src/Beacon.Server/Endpoints/ImageEndpoints.cs +++ b/src/Beacon.Server/Endpoints/ImageEndpoints.cs @@ -109,7 +109,9 @@ private static async Task UploadAsync( .Select(a => a.DisplayName) .FirstOrDefaultAsync(ct) ?? "Unknown"; - var dto = beacon.ToDto(ownerName); + var dto = beacon.ToDto( + ownerName, + stage: await db.Stages.FirstOrDefaultAsync(s => s.BeaconId == beacon.Id, ct)); hub.Broadcast(new BeaconEvent { Kind = BeaconEventKind.Updated, BeaconId = beacon.Id, Beacon = dto }); return Results.Ok(dto); diff --git a/src/Beacon.Server/Endpoints/ProfileEndpoints.cs b/src/Beacon.Server/Endpoints/ProfileEndpoints.cs index 6721497..5f7a2b9 100644 --- a/src/Beacon.Server/Endpoints/ProfileEndpoints.cs +++ b/src/Beacon.Server/Endpoints/ProfileEndpoints.cs @@ -55,6 +55,10 @@ public static void MapProfileEndpoints(this IEndpointRouteBuilder app) .RequireRateLimiting(RateLimits.Write) .WithSummary("Override the availability Beacon would otherwise derive."); + group.MapPut("/{id:guid}/currently", SetCurrentlyAsync) + .RequireRateLimiting(RateLimits.Write) + .WithSummary("Rewrite the live line: what this character is doing right now."); + group.MapPost("/{id:guid}/images", UploadImageAsync) .RequireRateLimiting(RateLimits.Upload) .DisableAntiforgery() @@ -192,6 +196,14 @@ private static async Task SetAvailabilityAsync( CancellationToken ct) => (await profiles.SetAvailabilityAsync(id, request.Availability, http.RequireAccount(), ct)).ToHttpResult(); + private static async Task SetCurrentlyAsync( + Guid id, + SetCurrentlyRequest request, + HttpContext http, + ProfileService profiles, + CancellationToken ct) => + (await profiles.SetCurrentlyAsync(id, request, http.RequireAccount(), ct)).ToHttpResult(); + private static async Task HeartbeatAsync( ActivityHeartbeatRequest request, HttpContext http, diff --git a/src/Beacon.Server/Endpoints/StageEndpoints.cs b/src/Beacon.Server/Endpoints/StageEndpoints.cs new file mode 100644 index 0000000..bc26e53 --- /dev/null +++ b/src/Beacon.Server/Endpoints/StageEndpoints.cs @@ -0,0 +1,138 @@ +using Beacon.Server.Auth; +using Beacon.Server.Data; +using Beacon.Server.Data.Entities; +using Beacon.Server.Services; +using Beacon.Shared.Beacons; +using Microsoft.EntityFrameworkCore; + +namespace Beacon.Server.Endpoints; + +/// +/// The stage a keeper has dressed their place with, for guests who run Stagehand. +/// +/// It is deliberately a separate route from the beacon itself. The atlas carries a description of the +/// stage -- its name, its weight, who built it -- and nothing more, so browsing stays cheap and the +/// definition is transferred only to somebody who has chosen to load it. +/// +public static class StageEndpoints +{ + public static void MapStageEndpoints(this IEndpointRouteBuilder app) + { + app.MapPut("/api/beacons/{id:guid}/stage", UploadAsync) + .RequireAuthorization() + .RequireRateLimiting(RateLimits.Upload) + .DisableAntiforgery() + .WithTags("Stages") + .WithSummary("Attach a Stagehand stage to a beacon."); + + app.MapGet("/api/beacons/{id:guid}/stage", DownloadAsync) + .RequireAuthorization() + .WithTags("Stages") + .WithSummary("Fetch a beacon's stage definition."); + + app.MapDelete("/api/beacons/{id:guid}/stage", RemoveAsync) + .RequireAuthorization() + .RequireRateLimiting(RateLimits.Write) + .WithTags("Stages") + .WithSummary("Remove a beacon's stage."); + } + + private static async Task UploadAsync( + Guid id, + HttpContext http, + BeaconDbContext db, + StageService stages, + CancellationToken ct) + { + var account = http.RequireAccount(); + + var beacon = await db.Beacons.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted, ct); + if (beacon is null) + return Results.NotFound(new { error = "That beacon no longer exists." }); + + if (beacon.OwnerAccountId != account.Id && !account.IsModerator) + return Results.Json(new { error = "That is not your beacon." }, statusCode: StatusCodes.Status403Forbidden); + + // Refuse on the declared length before reading, so an oversized upload is not streamed in full + // only to be thrown away. + if (http.Request.ContentLength > BeaconLimits.MaxStageBytes) + return Results.Json( + new { error = $"That stage is larger than {BeaconLimits.MaxStageBytes / (1024 * 1024)} MB." }, + statusCode: StatusCodes.Status413PayloadTooLarge); + + var (info, rejected) = await stages.StoreAsync( + id, + http.Request.Body, + ct); + + if (info is null) + return Results.BadRequest(new { error = rejected?.Reason ?? "That stage could not be read." }); + + var entity = await db.Stages.FirstOrDefaultAsync(s => s.BeaconId == id, ct); + if (entity is null) + { + entity = new BeaconStageEntity { BeaconId = id }; + db.Stages.Add(entity); + } + + entity.UploadedByAccountId = account.Id; + entity.Name = info.Name; + entity.AuthorName = info.AuthorName; + entity.Description = info.Description; + entity.IntendedTerritoryType = info.IntendedTerritoryType; + entity.ObjectCount = info.ObjectCount; + entity.ModpackCount = info.ModpackCount; + entity.SizeBytes = info.SizeBytes; + entity.UpdatedAt = info.UpdatedAt; + + beacon.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + return Results.Ok(info); + } + + private static async Task DownloadAsync( + Guid id, + BeaconDbContext db, + StageService stages, + CancellationToken ct) + { + var exists = await db.Beacons.AnyAsync(b => b.Id == id && !b.IsDeleted, ct); + if (!exists) + return Results.NotFound(new { error = "That beacon no longer exists." }); + + var stream = stages.Open(id); + if (stream is null) + return Results.NotFound(new { error = "That beacon has no stage." }); + + return Results.Stream(stream, "application/json"); + } + + private static async Task RemoveAsync( + Guid id, + HttpContext http, + BeaconDbContext db, + StageService stages, + CancellationToken ct) + { + var account = http.RequireAccount(); + + var beacon = await db.Beacons.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted, ct); + if (beacon is null) + return Results.NotFound(new { error = "That beacon no longer exists." }); + + if (beacon.OwnerAccountId != account.Id && !account.IsModerator) + return Results.Json(new { error = "That is not your beacon." }, statusCode: StatusCodes.Status403Forbidden); + + var entity = await db.Stages.FirstOrDefaultAsync(s => s.BeaconId == id, ct); + if (entity is not null) + db.Stages.Remove(entity); + + stages.Delete(id); + + beacon.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + return Results.NoContent(); + } +} diff --git a/src/Beacon.Server/Program.cs b/src/Beacon.Server/Program.cs index a603bae..472a9c8 100644 --- a/src/Beacon.Server/Program.cs +++ b/src/Beacon.Server/Program.cs @@ -87,6 +87,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); @@ -166,6 +167,7 @@ await context.Response.WriteAsJsonAsync(new app.MapAccountEndpoints(); app.MapBeaconEndpoints(); app.MapImageEndpoints(); +app.MapStageEndpoints(); app.MapProfileEndpoints(); app.MapGet(ApiRoutes.Health, (HttpContext http, BeaconHub hub, IOptions options) => diff --git a/src/Beacon.Server/Services/BeaconService.cs b/src/Beacon.Server/Services/BeaconService.cs index 71645b3..f298d3c 100644 --- a/src/Beacon.Server/Services/BeaconService.cs +++ b/src/Beacon.Server/Services/BeaconService.cs @@ -170,10 +170,17 @@ private async Task> HydrateAsync( var favoriteSet = favorites.ToHashSet(); + // Stages are described in the listing but never carried in it: a guest sees that a place is + // dressed, and fetches the definition itself only if they decide to load it. + var stages = await db.Stages + .Where(s => ids.Contains(s.BeaconId)) + .ToDictionaryAsync(s => s.BeaconId, ct); + return rows .Select(b => b.ToDto( owners.GetValueOrDefault(b.OwnerAccountId, "Unknown"), - favoriteSet.Contains(b.Id))) + favoriteSet.Contains(b.Id), + stages.GetValueOrDefault(b.Id))) .ToList(); } @@ -532,7 +539,8 @@ public async Task> SetFavoriteAsync( var dto = beacon.ToDto( await db.Accounts.Where(a => a.Id == beacon.OwnerAccountId) .Select(a => a.DisplayName).FirstOrDefaultAsync(ct) ?? "Unknown", - favorite); + favorite, + await db.Stages.FirstOrDefaultAsync(s => s.BeaconId == beacon.Id, ct)); return OperationResult.Ok(dto); } diff --git a/src/Beacon.Server/Services/ProfileService.cs b/src/Beacon.Server/Services/ProfileService.cs index 73c7316..940983a 100644 --- a/src/Beacon.Server/Services/ProfileService.cs +++ b/src/Beacon.Server/Services/ProfileService.cs @@ -36,7 +36,7 @@ private IQueryable Fetchable(AccountEntity caller) } private IQueryable WithDetail(IQueryable q) => - q.Include(p => p.Tags).Include(p => p.Hooks).Include(p => p.Images); + q.Include(p => p.Tags).Include(p => p.Hooks).Include(p => p.Glances).Include(p => p.Images); // --- Reads ----------------------------------------------------------- @@ -274,13 +274,16 @@ public async Task> SaveAsync( // diffing them would be more code for an identical result. db.ProfileTags.RemoveRange(profile.Tags); db.ProfileHooks.RemoveRange(profile.Hooks); + db.ProfileGlances.RemoveRange(profile.Glances); await db.SaveChangesAsync(ct); profile.Tags = ProfileMapper.BuildTags(profile.Id, request); profile.Hooks = CleanHooks(profile.Id, request.Hooks); + profile.Glances = CleanGlances(profile.Id, request.AtFirstGlance); db.ProfileTags.AddRange(profile.Tags); db.ProfileHooks.AddRange(profile.Hooks); + db.ProfileGlances.AddRange(profile.Glances); await db.SaveChangesAsync(ct); logger.LogInformation( @@ -313,6 +316,41 @@ public async Task> SetAvailabilityAsync( return OperationResult.Ok(await HydrateAsync(profile, ct)); } + /// + /// Rewrites the live line, and only that. + /// + /// Its own operation because the timestamp matters: a "currently" that inherits the card's general + /// UpdatedAt would look freshly written every time somebody fixed a typo in their biography, and + /// the whole point of the field is that its age is visible. + /// + public async Task> SetCurrentlyAsync( + Guid id, + SetCurrentlyRequest request, + AccountEntity caller, + CancellationToken ct) + { + var profile = await WithDetail(db.Profiles).FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted, ct); + if (profile is null) + return OperationResult.NotFound("No such profile."); + + if (profile.OwnerAccountId != caller.Id) + return OperationResult.Forbidden("That is not your profile."); + + var line = NullIfEmpty(Truncate(Validation.CleanLine(request.Currently), ProfileLimits.CurrentlyMaxLength)); + + profile.Currently = line; + + // Clearing the line clears its age too, so an empty "currently" never carries a date. + profile.CurrentlyUpdatedAt = line is null ? null : DateTimeOffset.UtcNow; + + if (request.Stance is { } stance) + profile.Stance = stance; + + await db.SaveChangesAsync(ct); + + return OperationResult.Ok(await HydrateAsync(profile, ct)); + } + public async Task> DeleteAsync(Guid id, AccountEntity caller, CancellationToken ct) { var profile = await db.Profiles.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted, ct); @@ -731,6 +769,12 @@ private static void Apply(ProfileEntity profile, SaveProfileRequest request) // to see any adult content at all, which is the one failure this must not permit. profile.IsMature = style.MatureThemes.Count > 0; + // The live "currently" line is deliberately not touched here. It has its own route so that + // rewriting it between scenes does not mean resubmitting a whole card, and so that saving a + // biography does not reset its age. + profile.OutOfCharacter = NullIfEmpty(Validation.CleanBlock(request.OutOfCharacter)); + profile.Stance = request.Stance; + profile.PlayerTimezone = NullIfEmpty(Validation.CleanLine(request.Player.Timezone)); profile.PlayerAvailability = NullIfEmpty(Validation.CleanLine(request.Player.Availability)); profile.PlayerContact = NullIfEmpty(Validation.CleanLine(request.Player.Contact)); @@ -743,6 +787,34 @@ private static void Apply(ProfileEntity profile, SaveProfileRequest request) profile.Availability = request.Availability; } + /// + /// Trims the "at first glance" slots. A slot with no text is dropped entirely rather than stored + /// as an empty label, so a half-filled form does not produce blank rows on somebody's card. + /// + private static List CleanGlances(Guid profileId, IReadOnlyList notes) + { + var order = 0; + return notes + .Select(n => new + { + Label = Truncate(Validation.CleanLine(n.Label), ProfileLimits.GlanceLabelMaxLength), + Text = Truncate(Validation.CleanLine(n.Text), ProfileLimits.GlanceTextMaxLength), + }) + .Where(n => n.Text.Length > 0) + .Take(ProfileLimits.MaxGlanceNotes) + .Select(n => new ProfileGlanceEntity + { + Id = Guid.NewGuid(), + ProfileId = profileId, + Label = n.Label, + Text = n.Text, + Order = order++, + }) + .ToList(); + } + + private static string Truncate(string value, int max) => value.Length > max ? value[..max] : value; + private static List CleanHooks(Guid profileId, IReadOnlyList hooks) { var order = 0; diff --git a/src/Beacon.Server/Services/StageService.cs b/src/Beacon.Server/Services/StageService.cs new file mode 100644 index 0000000..94b9124 --- /dev/null +++ b/src/Beacon.Server/Services/StageService.cs @@ -0,0 +1,195 @@ +using System.Text; +using System.Text.Json; +using Beacon.Shared.Beacons; +using Microsoft.Extensions.Options; + +namespace Beacon.Server.Services; + +/// Why a stage was refused, in words a keeper can act on. +public sealed record StageRejection(string Reason); + +/// +/// Stores the Stagehand stage a keeper has dressed their place with. +/// +/// The server deliberately does not link Stagehand's own libraries. It treats a stage as an opaque +/// document, reads only the few facts a guest needs in order to decide whether to load it, and +/// refuses the shapes that cannot possibly work for anyone but their author. Understanding the format +/// more deeply than that would tie the server's release cycle to a game plugin's. +/// +public sealed class StageService( + IOptions options, + IHostEnvironment env, + ILogger logger) +{ + private string Root => options.Value.StageDirectory(env.ContentRootPath); + + /// + /// Validates an uploaded definition and writes it to disk, replacing whatever was there. + /// Returns the facts to record, or a rejection to show the keeper. + /// + public async Task<(BeaconStageInfo? Info, StageRejection? Rejected)> StoreAsync( + Guid beaconId, + Stream source, + CancellationToken ct) + { + using var buffer = new MemoryStream(); + await source.CopyToAsync(buffer, ct); + + if (buffer.Length == 0) + return (null, new StageRejection("That stage file was empty.")); + + if (buffer.Length > BeaconLimits.MaxStageBytes) + return (null, new StageRejection( + $"That stage is {buffer.Length / (1024 * 1024)} MB. The limit is {BeaconLimits.MaxStageBytes / (1024 * 1024)} MB, " + + "so a guest is not made to download a mod collection in order to visit.")); + + var text = Encoding.UTF8.GetString(buffer.ToArray()); + + JsonDocument document; + try + { + document = JsonDocument.Parse(text); + } + catch (JsonException) + { + return (null, new StageRejection("That file is not a Stagehand stage.")); + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind is not JsonValueKind.Object || !root.TryGetProperty("Info", out var info)) + return (null, new StageRejection("That file is not a Stagehand stage.")); + + var objects = root.TryGetProperty("Objects", out var objectsElement) && objectsElement.ValueKind is JsonValueKind.Object + ? objectsElement.EnumerateObject().Count() + : 0; + + if (objects == 0) + return (null, new StageRejection("That stage does not place anything.")); + + if (objects > BeaconLimits.MaxStageObjects) + return (null, new StageRejection( + $"That stage places {objects} objects. The limit for a shared stage is {BeaconLimits.MaxStageObjects}, " + + "because every guest who loads it pays for all of them.")); + + var modpacks = root.TryGetProperty("EmbeddedModpacks", out var modpacksElement) && modpacksElement.ValueKind is JsonValueKind.Object + ? modpacksElement.EnumerateObject().Count() + : 0; + + if (modpacks > 0 && ReferencesLocalFiles(modpacksElement)) + return (null, new StageRejection( + "That stage loads files from your own disk, so it would arrive at a guest with holes in it. " + + "Embed the modpack in the stage before sharing it.")); + + var stage = new BeaconStageInfo + { + Name = ReadString(info, "Name", 128), + AuthorName = ReadString(info, "AuthorName", 64), + Description = ReadString(info, "Description", 512), + IntendedTerritoryType = info.TryGetProperty("IntendedTerritoryType", out var territory) + && territory.TryGetUInt32(out var value) + ? value + : 0, + ObjectCount = objects, + ModpackCount = modpacks, + SizeBytes = buffer.Length, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + await WriteAsync(beaconId, buffer, ct); + return (stage, null); + } + } + + /// Opens the stored definition, or null when there is none. + public Stream? Open(Guid beaconId) + { + var path = PathFor(beaconId); + + try + { + return File.Exists(path) ? File.OpenRead(path) : null; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not open the stage for beacon {BeaconId}.", beaconId); + return null; + } + } + + /// Deletes the stored definition. Missing is success: the caller wanted it gone. + public void Delete(Guid beaconId) + { + try + { + var path = PathFor(beaconId); + if (File.Exists(path)) + File.Delete(path); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not delete the stage for beacon {BeaconId}.", beaconId); + } + } + + private async Task WriteAsync(Guid beaconId, MemoryStream buffer, CancellationToken ct) + { + var path = PathFor(beaconId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + // Written beside the target and moved into place, so a failed upload cannot leave a guest + // downloading half a stage. + var partial = path + ".partial"; + buffer.Position = 0; + + await using (var file = File.Create(partial)) + await buffer.CopyToAsync(file, ct); + + File.Move(partial, path, overwrite: true); + } + + /// + /// True when any embedded resource is backed by a path on the author's machine. Such a stage + /// works perfectly for its author and is broken for everyone else, which is the worst failure to + /// discover after travelling to somebody's camp. + /// + private static bool ReferencesLocalFiles(JsonElement modpacks) + { + foreach (var modpack in modpacks.EnumerateObject()) + { + if (!modpack.Value.TryGetProperty("ModdedResources", out var resources) + || resources.ValueKind is not JsonValueKind.Object) + continue; + + foreach (var resource in resources.EnumerateObject()) + { + if (resource.Value.ValueKind is not JsonValueKind.Object) + continue; + + if (resource.Value.TryGetProperty("SourceDiskPath", out _)) + return true; + + if (resource.Value.TryGetProperty("Type", out var type) + && type.ValueKind is JsonValueKind.String + && type.GetString()?.Contains("Disk", StringComparison.OrdinalIgnoreCase) is true) + return true; + } + } + + return false; + } + + private static string ReadString(JsonElement info, string property, int maxLength) + { + if (!info.TryGetProperty(property, out var element) || element.ValueKind is not JsonValueKind.String) + return string.Empty; + + var value = element.GetString() ?? string.Empty; + return value.Length > maxLength ? value[..maxLength] : value; + } + + /// Sharded the same way screenshots are, for the same reason: flat directories get slow. + private string PathFor(Guid beaconId) => + Path.Combine(Root, beaconId.ToString("N")[..2], $"{beaconId:N}.json"); +} diff --git a/src/Beacon.Shared/ApiRoutes.cs b/src/Beacon.Shared/ApiRoutes.cs index 4d5ecc8..f0280ba 100644 --- a/src/Beacon.Shared/ApiRoutes.cs +++ b/src/Beacon.Shared/ApiRoutes.cs @@ -56,6 +56,9 @@ public static class Beacons public static string Report(Guid id) => $"{Root}/{id}/report"; public static string Image(Guid id) => $"{Root}/{id}/image"; + + /// The beacon's Stagehand stage: its definition on GET, replaced on PUT. + public static string Stage(Guid id) => $"{Root}/{id}/stage"; } public static class Profiles @@ -70,6 +73,9 @@ public static class Profiles public static string ById(Guid id) => $"{Root}/{id}"; + /// The live "currently" line, rewritten on its own so a chat command can do it. + public static string Currently(Guid id) => $"{Root}/{id}/currently"; + public static string ByShareCode(string code) => $"{Root}/code/{Uri.EscapeDataString(code)}"; /// diff --git a/src/Beacon.Shared/Beacons/BeaconDto.cs b/src/Beacon.Shared/Beacons/BeaconDto.cs index f9bd596..550b454 100644 --- a/src/Beacon.Shared/Beacons/BeaconDto.cs +++ b/src/Beacon.Shared/Beacons/BeaconDto.cs @@ -49,6 +49,12 @@ public sealed record BeaconDto /// Uploaded screenshot, if any. Resolve through the image endpoints. public Guid? ImageId { get; init; } + /// + /// The stage this place is dressed with, if its keeper built one. Metadata only: the definition + /// is fetched separately, by whoever chooses to load it. + /// + public BeaconStageInfo? Stage { get; init; } + /// Free-form tags for filtering: "tavern", "combat-rp", "18+", "drop-in". public IReadOnlyList Tags { get; init; } = []; @@ -80,6 +86,9 @@ public sealed record BeaconDto [JsonIgnore] public bool HasImage => ImageId is not null && ImageId != Guid.Empty; + /// True when there is something for a guest with Stagehand to load. + public bool HasStage => Stage is not null; + /// "Il Mheg - Balmung (Crystal)", the one-line answer to "where is this". [JsonIgnore] public string WhereText => $"{Location.ZoneName} - {Realm}"; diff --git a/src/Beacon.Shared/Beacons/BeaconLimits.cs b/src/Beacon.Shared/Beacons/BeaconLimits.cs index 98cb654..0b7eefe 100644 --- a/src/Beacon.Shared/Beacons/BeaconLimits.cs +++ b/src/Beacon.Shared/Beacons/BeaconLimits.cs @@ -46,6 +46,18 @@ public static class BeaconLimits /// Longest edge of the atlas thumbnail. public const int ThumbnailMaxEdge = 320; + /// + /// Largest stage definition a beacon may carry, in bytes. + /// + /// A stage that only places game objects is a few tens of kilobytes. One that embeds its own + /// modpacks can be far larger, and every visitor who opens the beacon pays for it, so the cap is + /// set where "this dresses the place" ends and "this is a mod distribution channel" begins. + /// + public const int MaxStageBytes = 2 * 1024 * 1024; + + /// Most objects a shared stage may place. Past this it is a performance problem for guests. + public const int MaxStageObjects = 400; + /// How many beacons a single account may own. A limit, not a target. public const int MaxBeaconsPerAccount = 50; diff --git a/src/Beacon.Shared/Beacons/BeaconStage.cs b/src/Beacon.Shared/Beacons/BeaconStage.cs new file mode 100644 index 0000000..ae6d819 --- /dev/null +++ b/src/Beacon.Shared/Beacons/BeaconStage.cs @@ -0,0 +1,38 @@ +namespace Beacon.Shared.Beacons; + +/// +/// What a keeper has built on top of a place, described the way Stagehand describes it. +/// +/// This is metadata only. The definition itself can be hundreds of kilobytes, so it is never carried +/// in the atlas listing -- it is fetched from its own route, once, by someone who has decided they +/// want to see the place dressed. +/// +public sealed record BeaconStageInfo +{ + /// The stage's own name, as its author wrote it. + public string Name { get; init; } = string.Empty; + + /// Whoever the author credited, which need not be the beacon's keeper. + public string AuthorName { get; init; } = string.Empty; + + public string Description { get; init; } = string.Empty; + + /// The zone the stage was built for. Shown so an obvious mismatch can be warned about. + public uint IntendedTerritoryType { get; init; } + + /// How many objects it places. The honest measure of "how heavy is this going to be". + public int ObjectCount { get; init; } + + /// How many embedded modpacks it carries. Non-zero means it brings its own textures and models. + public int ModpackCount { get; init; } + + public long SizeBytes { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } + + /// A short line for the beacon page: "41 objects, 2 modpacks". + public string Weight => + ModpackCount > 0 + ? $"{ObjectCount} object{(ObjectCount == 1 ? "" : "s")}, {ModpackCount} modpack{(ModpackCount == 1 ? "" : "s")}" + : $"{ObjectCount} object{(ObjectCount == 1 ? "" : "s")}"; +} diff --git a/src/Beacon.Shared/Profiles/ProfileDto.cs b/src/Beacon.Shared/Profiles/ProfileDto.cs index 6ade0cd..ae79be4 100644 --- a/src/Beacon.Shared/Profiles/ProfileDto.cs +++ b/src/Beacon.Shared/Profiles/ProfileDto.cs @@ -162,6 +162,59 @@ public sealed record ProfilePresence } /// A character's roleplay profile, as the Chronicle shows it. +/// +/// What a stranger notices before a word is exchanged. +/// +/// A label and a line: "Bearing" / "Stands like someone waiting to be told to leave". Deliberately +/// free text rather than a fixed set of physical fields, because the useful ones are never the same +/// twice and a form of Height/Weight/Eyes produces a census entry rather than an impression. +/// +public sealed record GlanceNote +{ + public string Label { get; init; } = string.Empty; + + public string Text { get; init; } = string.Empty; + + public int Order { get; init; } +} + +/// +/// The part of a card that is true today rather than true in general: what the character is doing +/// right now, whether the player is in character, and anything they want said out of character. +/// +/// This is the one piece of a profile that is expected to be wrong if it is not maintained, so it +/// carries its own timestamp and the card shows the age rather than presenting a month-old line as +/// though it were current. +/// +public sealed record ProfileMoment +{ + public static readonly ProfileMoment None = new(); + + /// What they are doing, in their own voice. + public string? Currently { get; init; } + + /// The player speaking as themselves: a warning, a hiatus, a "ask me anything". + public string? OutOfCharacter { get; init; } + + /// Whether the player is in character right now. + public RpStance Stance { get; init; } + + /// When the live line was last written. Null when it has never been set. + public DateTimeOffset? UpdatedAt { get; init; } + + /// True when there is anything here at all. + public bool HasAnything => + !string.IsNullOrWhiteSpace(Currently) + || !string.IsNullOrWhiteSpace(OutOfCharacter) + || Stance != RpStance.Unstated; + + /// + /// False once the live line is old enough that showing it without a date would be a small lie. + /// + public bool IsFresh => + UpdatedAt is { } when && DateTimeOffset.UtcNow - when < ProfileLimits.CurrentlyFreshFor; +} + public sealed record ProfileDto { public Guid Id { get; init; } @@ -185,6 +238,12 @@ public sealed record ProfileDto public ProfilePresence Presence { get; init; } = ProfilePresence.Unknown; + /// What is true today: the live line, the out-of-character note, and the IC/OOC flag. + public ProfileMoment Moment { get; init; } = ProfileMoment.None; + + /// What a stranger notices first, before anyone has spoken. + public IReadOnlyList AtFirstGlance { get; init; } = []; + public IReadOnlyList Personality { get; init; } = []; public IReadOnlyList Hooks { get; init; } = []; @@ -234,7 +293,7 @@ public float Completeness get { var earned = 0; - const int Total = 8; + const int Total = 9; if (HasPortrait) earned++; if (!string.IsNullOrWhiteSpace(Identity.Name)) earned++; @@ -244,6 +303,7 @@ public float Completeness if (Style.Tones.Count > 0 || Style.Activities.Count > 0) earned++; if (Identity.Archetype.Count > 0 || !string.IsNullOrWhiteSpace(Identity.Quote)) earned++; if (Gallery.Count > 1) earned++; + if (AtFirstGlance.Count > 0) earned++; return earned / (float)Total; } diff --git a/src/Beacon.Shared/Profiles/ProfileEnums.cs b/src/Beacon.Shared/Profiles/ProfileEnums.cs index 52c2171..10f9c1e 100644 --- a/src/Beacon.Shared/Profiles/ProfileEnums.cs +++ b/src/Beacon.Shared/Profiles/ProfileEnums.cs @@ -199,3 +199,20 @@ public enum ProfileSort Newest = 2, Name = 3, } + +/// +/// Whether the player behind a character is playing them right now. +/// +/// Borrowed from the addon culture that has had this for years: the difference between "I am in +/// character, approach me as one" and "I am here but out of character" is the single most useful +/// thing a card can say, and it is the one thing a static profile cannot express. +/// +public enum RpStance +{ + /// Not said either way. The honest default, and what every existing card has. + Unstated = 0, + + InCharacter = 1, + + OutOfCharacter = 2, +} diff --git a/src/Beacon.Shared/Profiles/ProfileLimits.cs b/src/Beacon.Shared/Profiles/ProfileLimits.cs index 5842549..8be5c33 100644 --- a/src/Beacon.Shared/Profiles/ProfileLimits.cs +++ b/src/Beacon.Shared/Profiles/ProfileLimits.cs @@ -40,6 +40,34 @@ public static class ProfileLimits public const int HookMaxLength = 140; public const int MaxHooks = 5; + /// + /// The live line: what this character is doing right now. + /// + /// Short on purpose. It is meant to be rewritten in ten seconds from a chat command between + /// scenes, not composed. A field long enough for a paragraph becomes a second biography that + /// nobody updates, which is worse than having none. + /// + public const int CurrentlyMaxLength = 200; + + /// The out-of-character note: the player speaking as themselves, not the character. + public const int OutOfCharacterMaxLength = 400; + + /// + /// What a stranger notices before a word is exchanged: height, bearing, the scar, the smell of + /// woodsmoke. Five slots, because this is the impression, not the description. + /// + public const int MaxGlanceNotes = 5; + public const int GlanceLabelMaxLength = 24; + public const int GlanceTextMaxLength = 140; + + /// + /// How long a "currently" line stays believable before the card should say how old it is. + /// + /// A live line that is three weeks old is not live, and silently presenting it as though it were + /// is the failure mode this whole field has in every other tool that offers it. + /// + public static readonly TimeSpan CurrentlyFreshFor = TimeSpan.FromDays(3); + public const int MaxPersonalityTraits = 6; public const int MaxTones = 5; public const int MaxActivities = 6; diff --git a/src/Beacon.Shared/Profiles/ProfileRequests.cs b/src/Beacon.Shared/Profiles/ProfileRequests.cs index 0a88e24..ed1c043 100644 --- a/src/Beacon.Shared/Profiles/ProfileRequests.cs +++ b/src/Beacon.Shared/Profiles/ProfileRequests.cs @@ -29,6 +29,18 @@ public sealed record SaveProfileRequest /// Replaces the hook list wholesale. Order is the order given. public IReadOnlyList Hooks { get; init; } = []; + /// What a stranger notices first. Ordered as given. + public IReadOnlyList AtFirstGlance { get; init; } = []; + + /// + /// The out-of-character note and the IC/OOC flag. The live "currently" line is deliberately not + /// here: it has its own route so it can be rewritten from a chat command without resubmitting a + /// whole profile. + /// + public string? OutOfCharacter { get; init; } + + public RpStance Stance { get; init; } + public string? Overview { get; init; } public string? History { get; init; } @@ -53,6 +65,21 @@ public sealed record ConfirmAdultRequest } /// Sets just the availability override, for the one-click "I am busy" toggle. +/// +/// Rewrites the live line, and nothing else. +/// +/// Its own route on purpose. The whole value of a "currently" is that updating it is a ten-second act +/// between scenes; making it a profile save means it is set once when the card is written and never +/// again, which is how this field dies in every tool that buries it in an editor. +/// +public sealed record SetCurrentlyRequest +{ + public string? Currently { get; init; } + + /// Optional: set the IC/OOC flag in the same breath. + public RpStance? Stance { get; init; } +} + public sealed record SetAvailabilityRequest { public AvailabilityOverride Availability { get; init; }