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; }